diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs deleted file mode 100644 index eb750ef..0000000 --- a/iota-cli/src/controls/dialog.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, KeyHint, Screen, UiEvent}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DialogButton { - Cancel, - Confirm, - Custom(usize), -} - -pub struct ConfirmDialog { - title: String, - message: Vec, - buttons: Vec, - focused_button: usize, - on_confirm: Option InteractionResult + Send + Sync>>, - on_cancel: Option InteractionResult + Send + Sync>>, -} - -struct DialogButtonConfig { - label: String, - intent: ButtonIntent, - enabled: bool, -} - -impl ConfirmDialog { - pub fn new(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Confirm".to_owned(), - intent: ButtonIntent::Primary, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn destructive(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Delete".to_owned(), - intent: ButtonIntent::Destructive, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn with_message_line(mut self, line: impl Into) -> Self { - self.message.push(line.into()); - self - } - - pub fn with_button(mut self, label: impl Into, intent: ButtonIntent) -> Self { - self.buttons.push(DialogButtonConfig { - label: label.into(), - intent, - enabled: true, - }); - self - } - - pub fn with_confirm_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_confirm = Some(Box::new(action)); - self - } - - pub fn with_cancel_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_cancel = Some(Box::new(action)); - self - } - - fn activate(&self) -> InteractionResult { - match self.focused_button { - 0 => { - if let Some(action) = &self.on_cancel { - action() - } else { - InteractionResult::CloseScreen - } - } - 1 => { - if let Some(action) = &self.on_confirm { - action() - } else { - InteractionResult::CloseScreen - } - } - _ => InteractionResult::CloseScreen, - } - } - - fn next_button(&mut self) { - self.focused_button = (self.focused_button + 1) % self.buttons.len(); - } - - fn prev_button(&mut self) { - if self.focused_button == 0 { - self.focused_button = self.buttons.len() - 1; - } else { - self.focused_button -= 1; - } - } -} - -impl Screen for ConfirmDialog { - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 50, - height: (self.message.len() + 8) as u16, - }, - ); - - f.render_widget(Clear, area); - let block = Block::default() - .title(format!(" {} ", self.title)) - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - - let inner = block.inner(area); - f.render_widget(block, area); - - let rows = Layout::vertical([ - Constraint::Min(self.message.len() as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - let lines: Vec = self - .message - .iter() - .map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal))) - .collect(); - f.render_widget(Paragraph::new(lines), rows[0]); - - let buttons_area = rows[2]; - let button_widths: Vec = self - .buttons - .iter() - .map(|b| { - crate::controls::button::button_minimum_width(&b.label) - }) - .collect(); - - let total_width: u16 = button_widths.iter().sum(); - let spacing = self.buttons.len().saturating_sub(1) as u16; - let available = buttons_area.width; - let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; - - let mut x = start_x; - for (i, (button_config, &width)) in - self.buttons.iter().zip(&button_widths).enumerate() - { - let button_area = Rect { - x, - y: buttons_area.y, - width, - height: 1, - }; - x = x.saturating_add(width + 1); - - render_button( - f, - button_area, - ActionButton { - label: &button_config.label, - intent: button_config.intent, - focused: self.focused_button == i, - enabled: button_config.enabled, - }, - context.theme, - ); - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Tab => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Left => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Right => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Tab", - action: "Switch button", - }, - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } -} diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index 09a589f..71af229 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -1,58 +1,25 @@ -use crate::ipc_client::{DaemonStatus, IpcConnectionState}; use crate::theme::ResolvedTheme; use crate::{ - controls::button::ButtonIntent, + controls::button::{ActionButton, ButtonIntent, render_button}, screens::screens::{AppAction, HitMap}, }; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, - text::{Line, Span}, + text::Span, widgets::Paragraph, }; -fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) { - match state { - IpcConnectionState::Connected => ("OK", theme.status.success), - IpcConnectionState::Connecting => ("..", theme.status.warning), - IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error), - IpcConnectionState::Disconnected => ("WARN", theme.status.warning), - } -} - -fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) { - use iota_ipc::ComponentId; - let health = daemon.components.get(&ComponentId::Omikron); - let (label, style) = match health.map(|h| h.status) { - Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success), - Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning), - Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error), - None => ("--", theme.text.muted), - }; - let detail = health - .and_then(|h| h.message.as_deref()) - .map(|m| format!(" {m}")) - .unwrap_or_default(); - (format!("{label}{detail}"), style) -} - +/// Shared application bar. The brand cell is deliberately an action so it is +/// a reliable way home from every screen. pub fn render_header( frame: &mut Frame, area: Rect, - connection: &IpcConnectionState, - daemon: &DaemonStatus, + title: &str, theme: &ResolvedTheme, hits: &mut HitMap, focused_action: Option, ) { - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - - let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); let cells = Layout::horizontal([ Constraint::Min(28), Constraint::Length(12), @@ -60,75 +27,36 @@ pub fn render_header( Constraint::Length(12), Constraint::Length(8), ]) - .split(rows[0]); - let cells2 = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(rows[1]); - - let (ipc_label, ipc_style) = connection_badge(connection, theme); - let (omikron_text, omikron_style) = omikron_badge(daemon, theme); - - let brand_line1 = Line::from(vec![ - Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar), - Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style), - ]); - let brand_line2 = Line::from(vec![ - Span::styled(" Omikron: ", theme.surfaces.toolbar), - Span::styled(format!("[{omikron_text}]"), omikron_style), - ]); - let brand_area = Rect { - x: area.x, - y: area.y, - width: cells[0].width, - height: area.height, - }; + .split(area); frame.render_widget( - Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar), - brand_area, + Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), + cells[0], ); - hits.register(brand_area, AppAction::OpenMain); - - for (index, (top, _bottom, label, intent, action)) in [ - (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview), - (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers), - (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings), - (cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit), + hits.register(cells[0], AppAction::OpenMain); + for (index, (area, label, action)) in [ + (cells[1], "Overview", AppAction::OpenOverview), + (cells[2], "Users", AppAction::OpenUsers), + (cells[3], "Settings", AppAction::OpenSettings), + (cells[4], "Quit", AppAction::Quit), ] .into_iter() .enumerate() { - let button_area = Rect { - x: top.x, - y: top.y, - width: top.width, - height: area.height, - }; - let style = match (intent, focused_action == Some(index)) { - (ButtonIntent::Primary, true) => theme.buttons.primary_focused, - (ButtonIntent::Primary, false) => theme.buttons.primary, - (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, - (ButtonIntent::Neutral, false) => theme.buttons.neutral, - (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, - (ButtonIntent::Cancel, false) => theme.buttons.cancel, - (ButtonIntent::Destructive, _) => theme.buttons.destructive, - }; - let display_label = if focused_action == Some(index) { - format!("› {label}") - } else { - label.to_owned() - }; - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled(display_label, style)), - Line::from(""), - ]), - button_area, + render_button( + frame, + area, + ActionButton { + label, + intent: if action == AppAction::Quit { + ButtonIntent::Destructive + } else { + ButtonIntent::Neutral + }, + focused: focused_action == Some(index), + enabled: true, + }, + theme, ); - hits.register(button_area, action); + hits.register(area, action); } } diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 02eaa84..fae2452 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -2,7 +2,6 @@ pub mod action; pub mod button; pub mod checkbox_group; pub mod choice; -pub mod dialog; pub mod header; pub mod navigation; pub mod panel; diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs deleted file mode 100644 index 007c223..0000000 --- a/iota-cli/src/help_overlay.rs +++ /dev/null @@ -1,219 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, - theme::ResolvedTheme, -}; - -pub struct HelpOverlay { - scroll: usize, -} - -impl HelpOverlay { - pub fn new() -> Self { - Self { scroll: 0 } - } - - fn build_lines(&self, theme: &ResolvedTheme) -> Vec> { - vec![ - Line::from(""), - Line::from(Span::styled( - "Global Keyboard Shortcuts", - theme.text.heading, - )), - Line::from(""), - Line::from(vec![ - Span::styled(" F6", theme.text.link), - Span::styled(" Toggle header navigation", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Tab", theme.text.link), - Span::styled(" Move focus to next panel", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Shift+Tab", theme.text.link), - Span::styled(" Move focus to previous panel", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Esc", theme.text.link), - Span::styled(" Go back / Close dialog", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Ctrl+C", theme.text.link), - Span::styled(" Quit the application", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Dashboard Navigation", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" o/O", theme.text.link), - Span::styled(" Open Overview screen", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" u/U", theme.text.link), - Span::styled(" Open Users screen", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" m/M", theme.text.link), - Span::styled(" Open Metrics screen", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Log Panel", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" j/Down", theme.text.link), - Span::styled(" Scroll down", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" k/Up", theme.text.link), - Span::styled(" Scroll up", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Enter", theme.text.link), - Span::styled(" Lock/unlock scroll", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /", theme.text.link), - Span::styled(" Filter logs", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Console Panel", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" Enter", theme.text.link), - Span::styled(" Send command", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Up/Down", theme.text.link), - Span::styled(" Command history", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Tab", theme.text.link), - Span::styled(" Auto-complete", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /help", theme.text.link), - Span::styled(" List available commands", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("List Navigation", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" j/Down", theme.text.link), - Span::styled(" Next item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" k/Up", theme.text.link), - Span::styled(" Previous item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" PgUp/PgDn", theme.text.link), - Span::styled(" Page up/down", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Home", theme.text.link), - Span::styled(" First item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" End", theme.text.link), - Span::styled(" Last item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /", theme.text.link), - Span::styled(" Filter list", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled( - "Press ? or Esc to close this overlay", - theme.text.muted, - )), - ] - } -} - -impl Screen for HelpOverlay { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 52, - height: 40, - }, - ); - - f.render_widget(Clear, area); - let block = Block::default() - .title(" Keyboard Shortcuts (?) ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - - let inner = block.inner(area); - f.render_widget(block, area); - - let lines = self.build_lines(context.theme); - let paragraph = Paragraph::new(lines) - .scroll((self.scroll as u16, 0)) - .style(context.theme.text.normal); - f.render_widget(paragraph, inner); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { - InteractionResult::CloseScreen - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll = self.scroll.saturating_add(1); - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - self.scroll = self.scroll.saturating_sub(1); - InteractionResult::Handled - } - KeyCode::PageDown => { - self.scroll = self.scroll.saturating_add(10); - InteractionResult::Handled - } - KeyCode::PageUp => { - self.scroll = self.scroll.saturating_sub(10); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - crate::screens::screens::KeyHint { - keys: "Up/Down", - action: "Scroll", - }, - crate::screens::screens::KeyHint { - keys: "Esc/?", - action: "Close", - }, - ] - } -} - -use std::any::Any; diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index bd2e44d..eee1487 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -23,12 +23,10 @@ pub mod util { } pub mod app_state; pub mod controls; -pub mod help_overlay; pub mod input_handler; pub mod interaction_result; pub mod ipc_client; pub mod layout; -pub mod notification; pub mod render_context; pub mod theme; pub mod ui; diff --git a/iota-cli/src/notification.rs b/iota-cli/src/notification.rs deleted file mode 100644 index 0fd3cb7..0000000 --- a/iota-cli/src/notification.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::time::{Duration, Instant}; - -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::theme::ResolvedTheme; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotificationKind { - Success, - Warning, - Error, - Info, -} - -#[derive(Clone)] -pub struct Notification { - pub message: String, - pub kind: NotificationKind, - pub created_at: Instant, - pub duration: Duration, -} - -impl Notification { - pub fn success(message: impl Into) -> Self { - Self::new(message, NotificationKind::Success, Duration::from_secs(3)) - } - - pub fn warning(message: impl Into) -> Self { - Self::new(message, NotificationKind::Warning, Duration::from_secs(4)) - } - - pub fn error(message: impl Into) -> Self { - Self::new(message, NotificationKind::Error, Duration::from_secs(5)) - } - - pub fn info(message: impl Into) -> Self { - Self::new(message, NotificationKind::Info, Duration::from_secs(3)) - } - - fn new(message: impl Into, kind: NotificationKind, duration: Duration) -> Self { - Self { - message: message.into(), - kind, - created_at: Instant::now(), - duration, - } - } - - pub fn is_expired(&self) -> bool { - self.created_at.elapsed() >= self.duration - } - - pub fn remaining(&self) -> Duration { - self.duration.saturating_sub(self.created_at.elapsed()) - } - - pub fn progress(&self) -> f64 { - let elapsed = self.created_at.elapsed().as_secs_f64(); - let total = self.duration.as_secs_f64(); - (elapsed / total).min(1.0) - } -} - -pub fn render_notification( - frame: &mut Frame, - area: Rect, - notification: &Notification, - theme: &ResolvedTheme, -) { - let (prefix, style) = match notification.kind { - NotificationKind::Success => ("✓ ", theme.status.success), - NotificationKind::Warning => ("⚠ ", theme.status.warning), - NotificationKind::Error => ("✗ ", theme.status.error), - NotificationKind::Info => ("ℹ ", theme.status.info), - }; - - let remaining = notification.remaining().as_secs(); - let progress = notification.progress(); - - let mut spans = vec![ - Span::styled(prefix, style), - Span::styled(¬ification.message, theme.text.normal), - ]; - - if remaining > 0 { - let bar_width = 10; - let filled = ((1.0 - progress) * bar_width as f64) as usize; - let empty = bar_width - filled; - let bar: String = "█".repeat(filled) + &"░".repeat(empty); - spans.push(Span::styled( - format!(" [{bar}] {remaining}s"), - theme.text.muted, - )); - } - - let paragraph = Paragraph::new(Line::from(spans)); - frame.render_widget(paragraph, area); -} - -pub fn render_notification_area( - frame: &mut Frame, - area: Rect, - notifications: &[Notification], - theme: &ResolvedTheme, -) { - if notifications.is_empty() { - return; - } - - let visible_height = area.height as usize; - let start = notifications.len().saturating_sub(visible_height); - let visible = ¬ifications[start..]; - - for (i, notification) in visible.iter().enumerate() { - let row = Rect { - x: area.x, - y: area.y + i as u16, - width: area.width, - height: 1, - }; - render_notification(frame, row, notification, theme); - } -} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 53e68b3..40abb83 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -49,6 +49,28 @@ impl MainScreen { pub fn daemon_status(&self) -> watch::Receiver { self.daemon_status_rx.clone() } + fn status_summary(&self) -> String { + let connection = match self.connection_status_rx.borrow().clone() { + IpcConnectionState::Connected => "[OK] Connected".to_owned(), + IpcConnectionState::Connecting => "[..] Connecting".to_owned(), + IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { + "[FAIL] Failed".to_owned() + } + IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), + }; + let daemon = self.daemon_status_rx.borrow().clone(); + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + let ready = daemon + .startup_phase + .map(|phase| format!(" {:?}", phase)) + .unwrap_or_default(); + format!("IOTA{version} {connection}{ready}") + } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -232,6 +254,39 @@ impl Screen for MainScreen { fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { self.layout_width.store(rect.width, Ordering::Relaxed); + // A watch Ref blocks senders until it is dropped. Rendering may do + // terminal I/O, so retain only owned snapshots for the whole frame. + let status = self.connection_status_rx.borrow().clone(); + let daemon = self.daemon_status_rx.borrow().clone(); + let status_text = match status { + IpcConnectionState::Connected => "Connected".to_string(), + IpcConnectionState::Connecting => "Connecting...".to_string(), + IpcConnectionState::Reconnecting { attempt } => { + format!("Reconnecting (attempt {})...", attempt) + } + IpcConnectionState::Incompatible { message } => { + format!("Incompatible protocol: {}", message) + } + IpcConnectionState::Failed { message } => { + format!("Connection failed: {}", message) + } + IpcConnectionState::Disconnected => "Disconnected".to_string(), + }; + let readiness = daemon + .startup_phase + .map(|phase| format!("{:?}", phase)) + .unwrap_or_else(|| "Waiting for status".into()); + let health = daemon + .degraded_reason + .as_deref() + .map(|reason| format!(" — {reason}")) + .unwrap_or_default(); + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + let _ = (status_text, readiness, health, version); f.render_widget( ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), rect, @@ -448,6 +503,9 @@ impl Screen for MainScreen { _ => InteractionResult::Unhandled, } } + fn app_title(&self) -> String { + self.status_summary() + } fn key_hints(&self) -> Vec { if self.selected_coords == (2, 0) { vec![ diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index 5299b3b..f2e61f5 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -30,8 +30,6 @@ pub enum AppEvent { theme: crate::theme::ThemeName, color: crate::theme::TerminalPolicy, unicode: crate::theme::TerminalPolicy, - cli_output: crate::theme::CliOutputFormat, - cli_require_confirmation: bool, }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), @@ -120,6 +118,9 @@ pub trait Screen: Send + Sync + Any { fn handle_action(&mut self, _action: AppAction) -> InteractionResult { InteractionResult::Unhandled } + fn app_title(&self) -> String { + "IOTA".to_owned() + } fn key_hints(&self) -> Vec { vec![ KeyHint { diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index 56293c5..dd26878 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -13,14 +13,12 @@ use crate::{ interaction_result::InteractionResult, render_context::RenderContext, screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, - theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig}, + theme::{TerminalPolicy, ThemeName, UiConfig}, }; #[derive(Clone, Copy, PartialEq, Eq)] enum Focus { Theme, - CliOutput, - CliConfirm, RegenerateKeys, Back, } @@ -36,8 +34,6 @@ pub struct SettingsScreen { message: String, color: TerminalPolicy, unicode: TerminalPolicy, - cli_output: CliOutputFormat, - cli_require_confirmation: bool, focus: Focus, dialog: Option, pending: bool, @@ -49,15 +45,16 @@ impl SettingsScreen { .iter() .position(|theme| *theme == current) .unwrap_or(0); - let config = UiConfig::load_or_default(); Self { selected, saved: current, message: "Left/Right previews. Enter saves.".into(), - color: config.color, - unicode: config.unicode, - cli_output: config.cli_output, - cli_require_confirmation: config.cli_require_confirmation, + color: UiConfig::load() + .map(|config| config.color) + .unwrap_or_default(), + unicode: UiConfig::load() + .map(|config| config.unicode) + .unwrap_or_default(), focus: Focus::Theme, dialog: None, pending: false, @@ -85,9 +82,7 @@ impl SettingsScreen { fn next_focus(&mut self) { self.focus = match self.focus { - Focus::Theme => Focus::CliOutput, - Focus::CliOutput => Focus::CliConfirm, - Focus::CliConfirm => Focus::RegenerateKeys, + Focus::Theme => Focus::RegenerateKeys, Focus::RegenerateKeys => Focus::Back, Focus::Back => Focus::Theme, }; @@ -97,9 +92,7 @@ impl SettingsScreen { self.focus = match self.focus { Focus::Theme => Focus::Back, Focus::Back => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::CliConfirm, - Focus::CliConfirm => Focus::CliOutput, - Focus::CliOutput => Focus::Theme, + Focus::RegenerateKeys => Focus::Theme, }; } @@ -121,44 +114,24 @@ impl SettingsScreen { match self.focus { Focus::Theme => { self.message = "Saving theme…".into(); - } - Focus::CliOutput => { - self.message = "Output format updated.".into(); - return InteractionResult::Handled; - } - Focus::CliConfirm => { - self.cli_require_confirmation = !self.cli_require_confirmation; - self.message = format!( - "Confirm: {}", - if self.cli_require_confirmation { - "On" - } else { - "Off" - }, - ); - return InteractionResult::Handled; + let theme = self.selected_theme(); + let color = self.color; + let unicode = self.unicode; + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + }) + }), + } } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); - return InteractionResult::Handled; + InteractionResult::Handled } - Focus::Back => return InteractionResult::CloseScreen, - } - let theme = self.selected_theme(); - let color = self.color; - let unicode = self.unicode; - let cli_output = self.cli_output; - let cli_require_confirmation = self.cli_require_confirmation; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - cli_output, - cli_require_confirmation, - }) - }), + Focus::Back => InteractionResult::CloseScreen, } } } @@ -179,16 +152,13 @@ impl Screen for SettingsScreen { context: &RenderContext<'_>, hits: &mut HitMap, ) { - let header_block = Block::default() + let block = Block::default() .title(" Settings ") .borders(Borders::ALL) .border_style(context.theme.borders.focused); - let inner = header_block.inner(area); - frame.render_widget(header_block, area); - - let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]) - .split(inner); - + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); frame.render_widget( Paragraph::new(format!( "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", @@ -199,28 +169,18 @@ impl Screen for SettingsScreen { " [preview]" }, self.color, - self.unicode, + self.unicode )) .style(context.theme.text.heading), - sections[0], - ); - - let cli_line = format!( - "CLI output: {:?} (L) Confirm: {} (K)", - self.cli_output, - if self.cli_require_confirmation { - "required" - } else { - "disabled" - }, + rows[0], ); let bottom_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]); + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); let lines = vec![ Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(Span::styled(&cli_line, context.theme.text.normal)), + Line::from(""), Line::from("Preview"), Line::from("[OK] Healthy"), Line::from("[WARN] Degraded"), @@ -392,23 +352,6 @@ impl Screen for SettingsScreen { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } - KeyCode::Char('l') | KeyCode::Char('L') => { - self.cli_output = self.cli_output.next(); - self.message = format!("CLI output: {:?}", self.cli_output); - InteractionResult::Handled - } - KeyCode::Char('k') | KeyCode::Char('K') => { - self.cli_require_confirmation = !self.cli_require_confirmation; - self.message = format!( - "CLI confirm: {}", - if self.cli_require_confirmation { - "On" - } else { - "Off" - }, - ); - InteractionResult::Handled - } KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { InteractionResult::CloseScreen } @@ -462,10 +405,6 @@ impl Screen for SettingsScreen { keys: "C/U", action: "Color/Unicode", }, - KeyHint { - keys: "L/K", - action: "CLI Out/Confirm", - }, KeyHint { keys: "Esc/B", action: "Back", diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index fab710c..ac271a8 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -19,7 +19,7 @@ use std::{ any::Any, sync::{ Arc, - atomic::{AtomicU8, AtomicUsize, Ordering}, + atomic::{AtomicUsize, Ordering}, }, }; @@ -56,7 +56,6 @@ pub struct UsersScreen { viewport_height: AtomicUsize, filter: String, filtering: bool, - tick: AtomicU8, } impl UsersScreen { @@ -75,7 +74,6 @@ impl UsersScreen { viewport_height: AtomicUsize::new(1), filter: String::new(), filtering: false, - tick: AtomicU8::new(0), } } @@ -117,12 +115,7 @@ impl UsersScreen { }; if self.loading { - const SPINNERS: &[u8] = b"|/-\\"; - let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; - f.render_widget( - Paragraph::new(format!("{ch} Loading users…")), - inner, - ); + f.render_widget(Paragraph::new("Loading users…"), inner); return; } if visible_indices.is_empty() { diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index 26d6046..79d1990 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -17,55 +17,6 @@ pub struct UiConfig { pub color: TerminalPolicy, #[serde(default)] pub unicode: TerminalPolicy, - /// Default CLI output format for headless commands. - #[serde(default)] - pub cli_output: CliOutputFormat, - /// Whether destructive CLI operations require --yes by default. - #[serde(default = "default_false")] - pub cli_require_confirmation: bool, -} - -fn default_false() -> bool { - false -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum CliOutputFormat { - #[default] - Text, - Json, - Yaml, - Table, -} - -impl CliOutputFormat { - pub fn all() -> &'static [CliOutputFormat] { - &[ - CliOutputFormat::Text, - CliOutputFormat::Json, - CliOutputFormat::Yaml, - CliOutputFormat::Table, - ] - } - - pub fn name(&self) -> &'static str { - match self { - CliOutputFormat::Text => "text", - CliOutputFormat::Json => "json", - CliOutputFormat::Yaml => "yaml", - CliOutputFormat::Table => "table", - } - } - - pub fn next(&self) -> Self { - match self { - CliOutputFormat::Text => CliOutputFormat::Json, - CliOutputFormat::Json => CliOutputFormat::Yaml, - CliOutputFormat::Yaml => CliOutputFormat::Table, - CliOutputFormat::Table => CliOutputFormat::Text, - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -77,16 +28,6 @@ pub enum TerminalPolicy { Never, } -impl TerminalPolicy { - pub fn next(&self) -> Self { - match self { - TerminalPolicy::Auto => TerminalPolicy::Always, - TerminalPolicy::Always => TerminalPolicy::Never, - TerminalPolicy::Never => TerminalPolicy::Auto, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DaemonStartPolicy { #[default] @@ -121,21 +62,8 @@ impl UiConfig { pub fn path() -> PathBuf { iota_paths::config_dir().join("ui.yaml") } - - fn fallback_path() -> Option { - std::env::var_os("HOME") - .map(PathBuf::from) - .map(|d| d.join(".config").join("iota").join("ui.yaml")) - } - pub fn load() -> Result { - let path = match (|| std::panic::catch_unwind(|| Self::path()))() { - Ok(path) => path, - Err(_) => Self::fallback_path().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "could not determine config path") - })?, - }; - Self::load_from(&path) + Self::load_from(&Self::path()) } fn load_from(path: &Path) -> Result { @@ -154,12 +82,6 @@ impl UiConfig { fs::write(path, yaml) } - /// Load from config path, or return defaults if the config path can't be resolved. - /// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests). - pub fn load_or_default() -> Self { - Self::load().unwrap_or_default() - } - pub fn resolve_theme(override_theme: Option) -> ThemeName { Self::resolve_theme_from( override_theme, @@ -195,33 +117,6 @@ impl UiConfig { } } } - - /// Resolve the default CLI output format from config file and environment. - /// Priority: IOTA_OUTPUT env var > config file > "text" default. - pub fn resolve_cli_output(&self) -> CliOutputFormat { - if let Ok(value) = std::env::var("IOTA_OUTPUT") { - match value.to_ascii_lowercase().as_str() { - "json" => return CliOutputFormat::Json, - "yaml" | "yml" => return CliOutputFormat::Yaml, - "table" => return CliOutputFormat::Table, - _ => {} - } - } - self.cli_output - } - - /// Resolve the default --yes behavior from config file and environment. - /// Priority: IOTA_YES env var > config file > false default. - pub fn resolve_cli_require_confirmation(&self) -> bool { - if let Ok(value) = std::env::var("IOTA_YES") { - match value.to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "y" => return false, - "0" | "false" | "no" | "n" => return true, - _ => {} - } - } - self.cli_require_confirmation - } } #[cfg(test)] @@ -278,37 +173,4 @@ mod tests { ThemeName::Ansi ); } - - #[test] - fn cli_output_defaults_to_text() { - let config = UiConfig::default(); - assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text); - } - - #[test] - fn cli_output_cycles_through_variants() { - assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json); - assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml); - assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table); - assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text); - } - - #[test] - fn require_confirmation_defaults_to_false() { - let config = UiConfig::default(); - assert!(!config.resolve_cli_require_confirmation()); - } - - #[test] - fn cli_output_serializes_roundtrip() { - let config = UiConfig { - cli_output: CliOutputFormat::Table, - cli_require_confirmation: true, - ..Default::default() - }; - let yaml = serde_yaml::to_string(&config).unwrap(); - let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap(); - assert_eq!(loaded.cli_output, CliOutputFormat::Table); - assert!(loaded.cli_require_confirmation); - } } diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index 3154704..d185c6f 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,7 +3,7 @@ mod model; mod name; mod presets; -pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig}; +pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index b06dab1..90d0b0b 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,10 +1,8 @@ use crate::{ controls::header::render_header, - help_overlay::HelpOverlay, input_handler::setup_input_handler, interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcClient, IpcConnectionState}, - notification::{Notification, render_notification_area}, + ipc_client::IpcClient, render_context::RenderContext, screens::{ main_screen::MainScreen, @@ -23,7 +21,7 @@ use once_cell::sync::Lazy; use ratatui::{ Terminal, backend::CrosstermBackend, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Layout}, }; use std::{ io, @@ -55,7 +53,6 @@ pub struct UI { app_event_tx: mpsc::UnboundedSender, app_event_rx: Mutex>>, header_focus: Mutex>, - notifications: Arc>>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -240,7 +237,6 @@ impl UI { app_event_tx, app_event_rx: Mutex::new(Some(app_event_rx)), header_focus: Mutex::new(None), - notifications: Arc::new(Mutex::new(Vec::new())), }) } @@ -288,27 +284,6 @@ impl UI { self.cancellation.clone() } - pub async fn push_notification(&self, notification: Notification) { - if let Ok(mut notifications) = self.notifications.lock() { - notifications.push(notification); - self.invalidate(); - } - } - - pub async fn clear_expired_notifications(&self) { - if let Ok(mut notifications) = self.notifications.lock() { - let before = notifications.len(); - notifications.retain(|n| !n.is_expired()); - if notifications.len() != before { - self.invalidate(); - } - } - } - - pub async fn notifications(&self) -> Vec { - self.notifications.lock().map(|n| n.clone()).unwrap_or_default() - } - pub async fn set_screen(&self, screen: Box) { self.screen_stack.write().await.push(screen); self.invalidate(); @@ -357,8 +332,6 @@ impl UI { theme, color, unicode, - cli_output, - cli_require_confirmation, }) = &event { self.set_theme(theme::resolve(*theme)).await; @@ -366,8 +339,6 @@ impl UI { config.theme = *theme; config.color = *color; config.unicode = *unicode; - config.cli_output = *cli_output; - config.cli_require_confirmation = *cli_require_confirmation; let result = config .save() .map_err(|error| format!("Could not save UI settings: {error}")); @@ -414,18 +385,6 @@ impl UI { self.invalidate(); return; } - if key.code == KeyCode::Char('?') { - let has_help_overlay = self - .screen_stack - .read() - .await - .iter() - .any(|s| s.as_any().downcast_ref::().is_some()); - if !has_help_overlay { - self.set_screen(Box::new(HelpOverlay::new())).await; - } - return; - } if header_is_focused { let mut action = None; if let Ok(mut focus) = self.header_focus.lock() { @@ -592,61 +551,35 @@ impl UI { self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) .await; let sender = self.app_event_tx.clone(); - let ui = self.clone(); tokio::spawn(async move { - let load = async { - match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - }) - .collect()) - } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) - } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), - Err(error) => Err(format!("Cannot load users: {error}")), + let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|u| UserEntry { + user_id: u.user_id, + username: u.username, + }) + .collect()) } - }; - tokio::pin!(load); - let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let result = loop { - tokio::select! { - result = &mut load => break result, - _ = ticker.tick() => { - ui.invalidate(); - } + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot load users: {error}")) } + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), }; let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); }); } pub async fn render(&self) -> io::Result<()> { - self.clear_expired_notifications().await; let theme = self.theme.read().await.clone(); let context = RenderContext { theme: theme.as_ref(), }; // The renderer is the only task that takes the terminal lock. Screen // mutations use the stack lock briefly before invalidating a frame. - let stack_guard = self.screen_stack.read().await; - let (connection, daemon) = stack_guard - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| { - ( - main.connection_status().borrow().clone(), - main.daemon_status().borrow().clone(), - ) - }) - .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); - if let Some(screen) = stack_guard.last() { + if let Some(screen) = self.screen_stack.read().await.last() { let mut terminal = self .terminal .lock() @@ -659,12 +592,22 @@ impl UI { Constraint::Length(1), ]) .split(f.area()); + let header_title = self + .screen_stack + .try_read() + .ok() + .and_then(|stack| { + stack + .iter() + .find_map(|item| item.as_any().downcast_ref::()) + .map(|main| main.app_title()) + }) + .unwrap_or_else(|| screen.app_title()); let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); render_header( f, rows[0], - &connection, - &daemon, + &header_title, context.theme, &mut hits, header_focus, @@ -672,15 +615,12 @@ impl UI { let hints = if header_focus.is_some() { " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() } else { - let mut screen_hints: Vec = screen + screen .key_hints() .into_iter() .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect(); - if !screen_hints.iter().any(|h| h.contains("?")) { - screen_hints.push("?: Help".to_owned()); - } - screen_hints.join(" ") + .collect::>() + .join(" ") }; f.render_widget( ratatui::widgets::Paragraph::new(format!(" {hints}")).style( @@ -693,18 +633,6 @@ impl UI { rows[2], ); screen.render(f, rows[1], &context, &mut hits); - - if let Ok(notifications) = self.notifications.try_lock() { - if !notifications.is_empty() { - let notification_area = Rect { - x: rows[1].x + rows[1].width.saturating_sub(40), - y: rows[1].y, - width: 40.min(rows[1].width), - height: 3.min(rows[1].height), - }; - render_notification_area(f, notification_area, ¬ifications, context.theme); - } - } })?; if let Ok(mut current) = self.hits.lock() { *current = hits; diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 666bffe..470e514 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -44,9 +44,7 @@ async fn settings_preview_and_save_emit_typed_application_events() { UiEvent::App(AppEvent::SaveSettings { theme: ThemeName::Surface, color: _, - unicode: _, - cli_output: _, - cli_require_confirmation: _ + unicode: _ }) )); } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 48c8ead..a90e106 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,5 +1,5 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; -use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; +use iota_cli::theme::ThemeName; use iota_terms::TermsType; #[derive(Debug)] @@ -23,7 +23,6 @@ pub enum OutputFormat { Text, Json, Yaml, - Table, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -44,17 +43,6 @@ impl From for ThemeName { } } -impl From for OutputFormat { - fn from(value: CliOutputFormat) -> Self { - match value { - CliOutputFormat::Text => OutputFormat::Text, - CliOutputFormat::Json => OutputFormat::Json, - CliOutputFormat::Yaml => OutputFormat::Yaml, - CliOutputFormat::Table => OutputFormat::Table, - } - } -} - #[derive(Parser, Debug)] #[command( name = "iota", @@ -324,11 +312,6 @@ impl CliInvocation { if args.as_slice() == ["help"] { return Ok(Self::special(Command::Help)); } - - let config = UiConfig::load_or_default(); - let config_output: OutputFormat = config.cli_output.into(); - let require_confirmation = config.resolve_cli_require_confirmation(); - let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { match error.kind() { @@ -343,20 +326,6 @@ impl CliInvocation { Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), Err(error) => return Err(error), }; - - let output = if parsed.output == OutputFormat::Text { - config_output - } else { - parsed.output - }; - - let resolve_confirmed = |yes_flag: bool| -> bool { - if yes_flag { - return true; - } - !require_confirmation - }; - let command = match parsed.command { None => Command::Dashboard, Some(CliCommand::Status) => Command::Status, @@ -370,7 +339,7 @@ impl CliInvocation { UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, - confirmed: resolve_confirmed(yes), + confirmed: yes, }, UsersAction::Import { username } => Command::UsersImport { username }, }, @@ -379,18 +348,14 @@ impl CliInvocation { OmikronAction::Status => Command::OmikronStatus, }, Some(CliCommand::Identity(identity)) => match identity.action { - IdentityAction::Rotate { yes } => Command::IdentityRotate { - confirmed: resolve_confirmed(yes), - }, + IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes }, }, Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload, }, - Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { - confirmed: resolve_confirmed(yes), - }, + Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck, @@ -406,12 +371,8 @@ impl CliInvocation { TermsAction::Accept { system } => Command::TermsAccept { system }, }, Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { - confirmed: resolve_confirmed(yes), - }, - DaemonAction::Stop { yes } => Command::DaemonStop { - confirmed: resolve_confirmed(yes), - }, + DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, + DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, DaemonAction::Status => Command::DaemonDaemonStatus, @@ -424,7 +385,7 @@ impl CliInvocation { }; Ok(Self { theme_override: parsed.theme.map(Into::into), - output, + output: parsed.output, color: if parsed.no_color { CapabilityPolicy::Never } else { @@ -558,12 +519,7 @@ mod tests { #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::DaemonStop { - confirmed: true - } - ); + assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); } #[test] @@ -604,7 +560,7 @@ mod tests { invocation.command, Command::UsersRemove { user_id: 42, - confirmed: true, + confirmed: false, } ); } @@ -634,7 +590,7 @@ mod tests { let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); assert_eq!( invocation.command, - Command::IdentityRotate { confirmed: true } + Command::IdentityRotate { confirmed: false } ); let invocation = CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs deleted file mode 100644 index 2f7cb38..0000000 --- a/iota/src/cli_color.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::env; - -#[derive(Debug, Clone, Copy)] -pub struct ColorConfig { - pub enabled: bool, -} - -impl Default for ColorConfig { - fn default() -> Self { - Self::new() - } -} - -impl ColorConfig { - pub fn new() -> Self { - let enabled = env::var("NO_COLOR").is_err() - && env::var("TERM") - .map(|t| t != "dumb") - .unwrap_or(true); - Self { enabled } - } - - pub fn colorize(&self, text: &str, style: Style) -> String { - if !self.enabled { - return text.to_string(); - } - format!("{}{}\x1b[0m", style.prefix(), text) - } -} - -#[derive(Debug, Clone, Copy)] -pub struct Style { - pub fg: Option, - pub bg: Option, - pub bold: bool, -} - -impl Style { - pub const fn new() -> Self { - Self { - fg: None, - bg: None, - bold: false, - } - } - - pub const fn fg(mut self, color: u8) -> Self { - self.fg = Some(color); - self - } - - pub const fn bold(mut self) -> Self { - self.bold = true; - self - } - - fn prefix(&self) -> String { - let mut codes = Vec::new(); - if self.bold { - codes.push("1".to_string()); - } - if let Some(fg) = self.fg { - codes.push(format!("3{}", fg)); - } - if let Some(bg) = self.bg { - codes.push(format!("4{}", bg)); - } - if codes.is_empty() { - String::new() - } else { - format!("\x1b[{}m", codes.join(";")) - } - } -} - -pub const SUCCESS: Style = Style::new().fg(2); -pub const WARNING: Style = Style::new().fg(3); -pub const ERROR: Style = Style::new().fg(1); -pub const INFO: Style = Style::new().fg(4); -pub const MUTED: Style = Style::new().fg(8); -pub const HEADING: Style = Style::new().bold(); - -pub fn success(config: &ColorConfig, text: &str) -> String { - config.colorize(text, SUCCESS) -} - -pub fn warning(config: &ColorConfig, text: &str) -> String { - config.colorize(text, WARNING) -} - -pub fn error(config: &ColorConfig, text: &str) -> String { - config.colorize(text, ERROR) -} - -pub fn info(config: &ColorConfig, text: &str) -> String { - config.colorize(text, INFO) -} - -pub fn muted(config: &ColorConfig, text: &str) -> String { - config.colorize(text, MUTED) -} - -pub fn heading(config: &ColorConfig, text: &str) -> String { - config.colorize(text, HEADING) -} diff --git a/iota/src/main.rs b/iota/src/main.rs index 192f1c8..1f19d89 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -7,14 +7,12 @@ use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; mod cli_args; -mod cli_color; mod daemon_setup_flow; mod local_daemon; mod startup_error; mod terms; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; -use cli_color::ColorConfig; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -23,7 +21,7 @@ async fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(error) => { if !matches!(error, StartupError::Cancelled) { - startup_error::print_error(&error); + eprintln!("{error}"); } startup_error::exit_code(&error) } @@ -385,93 +383,7 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - let color = cli_color::ColorConfig::new(); - println!( - "{}", - cli_color::heading(&color, "Iota Operator Console") - ); - println!(); - println!("Usage: iota [OPTIONS] [COMMAND]"); - println!(); - println!( - "{}", - cli_color::info(&color, "Commands:") - ); - println!(" (no command) Launch the interactive dashboard"); - println!(" status Show daemon status"); - println!(" tasks List active tasks"); - println!(" users list List all users"); - println!(" users show Show user details"); - println!(" users add Create a new user"); - println!(" users remove Remove a user (requires --yes)"); - println!(" omikron status Show Omikron connection status"); - println!(" omikron reconnect Reconnect to Omikron"); - println!(" identity rotate Rotate identity keys (requires --yes)"); - println!(" config get Show current configuration"); - println!(" config set Set a configuration value"); - println!(" config reload Reload configuration"); - println!(" components Show component health"); - println!(" logs [--limit N] Show recent log entries"); - println!(" update check Check for updates"); - println!(" community list List communities"); - println!(" terms status Show terms acceptance status"); - println!(" terms show Show a terms document"); - println!(" terms accept Accept required terms"); - println!(" daemon restart Restart the daemon (requires --yes)"); - println!(" daemon stop Stop the daemon (requires --yes)"); - println!(" daemon enable Enable daemon at startup"); - println!(" daemon disable-startup Disable daemon at startup"); - println!(" daemon startup-status Show startup configuration"); - println!(" daemon start Start the daemon"); - println!(" daemon restart-service Restart the daemon service"); - println!(" daemon stop-service Stop the daemon service"); - println!(" daemon install Install from a bundle"); - println!(" help Show this help message"); - println!(" completions Generate shell completions"); - println!(" man Show the man page"); - println!(); - println!( - "{}", - cli_color::info(&color, "Options:") - ); - println!(" --theme Theme: monospace, binary, ansi, surface"); - println!(" --output Output format: text, json, yaml, table"); - println!(" --color Color: auto, always, never"); - println!(" --unicode Unicode: auto, always, never"); - println!(" --no-color Disable colored output"); - println!(" --yes, -y Confirm destructive operations"); - println!(" -h, --help Show help"); - println!(" -V, --version Show version"); - println!(); - println!( - "{}", - cli_color::info(&color, "Examples:") - ); - println!(" iota Launch the interactive dashboard"); - println!(" iota status Show daemon status"); - println!(" iota users list --output=json List users in JSON format"); - println!(" iota users add alice Create a user named 'alice'"); - println!(" iota users remove 42 --yes Remove user 42"); - println!(" iota config get --output=yaml Show config in YAML format"); - println!(" iota logs --limit 50 Show last 50 log entries"); - println!(" iota completions bash Generate bash completions"); - println!(); - println!( - "{}", - cli_color::info(&color, "Exit Codes:") - ); - println!(" 0 Success"); - println!(" 1 General error"); - println!(" 2 Invalid command or arguments"); - println!(" 130 Interrupted (Ctrl+C)"); - println!(); - println!( - "{}", - cli_color::muted(&color, "Environment Variables:") - ); - println!(" NO_COLOR Disable colored output when set"); - println!(" TERM Terminal type (dumb disables colors)"); - println!(" IOTA_THEME Default theme override"); + println!("{}", CliInvocation::help_text()); } fn print_completions(shell: &str) -> Result<(), StartupError> { @@ -510,47 +422,14 @@ fn print_man_page() { println!(".TH IOTA 1"); println!(".SH NAME\n iota \\- Iota operator console"); println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); - println!(".SH DESCRIPTION"); - println!("Iota is the operator console for managing Iota daemon instances."); - println!("It provides both an interactive dashboard and headless CLI commands."); println!(".SH COMMANDS"); for command in CliInvocation::command_paths() { println!(".TP\n.B {command}"); } println!(".SH GLOBAL OPTIONS"); - println!(".TP\n.B --output text|json|yaml|table"); - println!("Set the output format for headless commands."); + println!(".TP\n.B --output text|json|yaml"); println!(".TP\n.B --color auto|always|never"); - println!("Control colored output."); println!(".TP\n.B --unicode auto|always|never"); - println!("Control Unicode character rendering."); - println!(".TP\n.B --yes, -y"); - println!("Confirm destructive operations without prompting."); - println!(".SH EXIT CODES"); - println!(".TP\n.B 0"); - println!("Success"); - println!(".TP\n.B 1"); - println!("General error"); - println!(".TP\n.B 2"); - println!("Invalid command or arguments"); - println!(".TP\n.B 130"); - println!("Interrupted (Ctrl+C)"); - println!(".SH EXAMPLES"); - println!(".TP\n.B iota"); - println!("Launch the interactive dashboard"); - println!(".TP\n.B iota status"); - println!("Show daemon status"); - println!(".TP\n.B iota users list --output=json"); - println!("List users in JSON format"); - println!(".TP\n.B iota users add alice"); - println!("Create a user named 'alice'"); - println!(".SH ENVIRONMENT"); - println!(".TP\n.B NO_COLOR"); - println!("Disable colored output when set"); - println!(".TP\n.B TERM"); - println!("Terminal type (dumb disables colors)"); - println!(".TP\n.B IOTA_THEME"); - println!("Default theme override"); } async fn run_command( @@ -558,7 +437,6 @@ async fn run_command( command: Command, output: OutputFormat, ) -> Result<(), StartupError> { - let color = ColorConfig::new(); let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, @@ -630,31 +508,18 @@ async fn run_command( } match payload { ResponsePayload::Status(status) => { - let phase_color = if status.degraded_reason.is_some() { - cli_color::WARNING - } else { - cli_color::SUCCESS - }; - print!( - "{} {}", - cli_color::info(&color, "Phase:"), - color.colorize(&status.phase, phase_color) - ); + print!("Phase: {}", status.phase); if !status.tasks.is_empty() { - print!( - ", {} {}", - cli_color::info(&color, "Tasks:"), - status.tasks.join(", ") - ); + print!(", Tasks: {}", status.tasks.join(", ")); } if let Some(reason) = status.degraded_reason { - print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason); + print!(", Degraded: {}", reason); } println!(); } ResponsePayload::Tasks(tasks) => { if tasks.is_empty() { - println!("{}", cli_color::muted(&color, "No active tasks.")); + println!("No active tasks."); } else { for task in &tasks { println!("{}", task.name); @@ -663,31 +528,18 @@ async fn run_command( } ResponsePayload::Users(users) => { if users.is_empty() { - println!("{}", cli_color::muted(&color, "No users.")); + println!("No users."); } else { for user in &users { - println!( - "{} ({})", - cli_color::heading(&color, &user.username), - user.user_id - ); + println!("{} ({})", user.username, user.user_id); } } } ResponsePayload::UserCreated { user_id, username } => { - println!( - "{} {} ({})", - cli_color::success(&color, "Created user"), - cli_color::heading(&color, &username), - user_id - ); + println!("Created user {} ({})", username, user_id); } ResponsePayload::UserRemoved { user_id } => { - println!( - "{} {}", - cli_color::warning(&color, "Removed user"), - user_id - ); + println!("Removed user {}", user_id); } ResponsePayload::Acknowledged { message } => { println!("{}", message); @@ -699,57 +551,32 @@ async fn run_command( println!("{}", config.yaml); } ResponsePayload::OmikronStatus(status) => { - println!( - "{}: {}", - cli_color::info(&color, "Connected"), - status.connected - ); + println!("Connected: {}", status.connected); if let Some(id) = status.iota_id { - println!( - "{}: {}", - cli_color::info(&color, "Iota ID"), - id - ); + println!("Iota ID: {}", id); } } ResponsePayload::Components(components) => { if components.is_empty() { - println!( - "{}", - cli_color::muted(&color, "No component health data available.") - ); + println!("No component health data available."); } else { for comp in &components { - let (status_str, style) = match comp.status { - iota_ipc::HealthStatus::Healthy => { - ("healthy", cli_color::SUCCESS) - } - iota_ipc::HealthStatus::Degraded => { - ("degraded", cli_color::WARNING) - } - iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR), + let status_str = match comp.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", }; let suffix = comp .message .as_deref() .map(|m| format!(" ({m})")) .unwrap_or_default(); - println!( - "{:?}: {}{}", - comp.id, - color.colorize(status_str, style), - suffix - ); + println!("{:?}: {}{}", comp.id, status_str, suffix); } } } ResponsePayload::UserDetail(user) => { - println!( - "{}: {} ({})", - cli_color::info(&color, "User"), - cli_color::heading(&color, &user.username), - user.user_id - ); + println!("User: {} ({})", user.username, user.user_id); if let Some(ref name) = user.display_name { println!("Display Name: {name}"); } @@ -761,42 +588,23 @@ async fn run_command( ResponsePayload::LogEntries(logs) => { for entry in &logs.entries { let ts = entry.timestamp_ms; - let (level, style) = if entry.is_error { - ("ERR", cli_color::ERROR) - } else { - ("INF", cli_color::INFO) - }; - println!( - "[{ts}] {} {}: {}", - color.colorize(level, style), - entry.sender, - entry.message - ); + let level = if entry.is_error { "ERR" } else { "INF" }; + println!("[{ts}] {level} {}: {}", entry.sender, entry.message); } } ResponsePayload::UpdateStatus(status) => { if status.available { - println!( - "{}", - cli_color::success(&color, "Update available.") - ); + println!("Update available."); } else { - println!( - "{}", - cli_color::info(&color, "Up to date.") - ); + println!("Up to date."); } } ResponsePayload::Communities(communities) => { if communities.is_empty() { - println!("{}", cli_color::muted(&color, "No communities.")); + println!("No communities."); } else { for c in &communities { - println!( - "{} ({})", - cli_color::heading(&color, &c.title), - c.name - ); + println!("{} ({})", c.title, c.name); } } } @@ -826,130 +634,7 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result< "Cannot encode YAML output: {error}" )))? ), - OutputFormat::Table => render_table(payload), OutputFormat::Text => unreachable!(), } Ok(()) } - -fn render_table(payload: &ResponsePayload) { - match payload { - ResponsePayload::Users(users) => { - if users.is_empty() { - println!("No users."); - return; - } - println!("{:<8} {}", "ID", "USERNAME"); - println!("{:<8} {}", "--------", "--------"); - for user in users { - println!("{:<8} {}", user.user_id, user.username); - } - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - println!("No active tasks."); - return; - } - println!("{}", "NAME"); - println!("{}", "--------"); - for task in tasks { - println!("{}", task.name); - } - } - ResponsePayload::Components(components) => { - if components.is_empty() { - println!("No component health data available."); - return; - } - println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE"); - println!("{:<20} {:<10} {}", "--------", "--------", "--------"); - for comp in components { - let status_str = match comp.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - let message = comp.message.as_deref().unwrap_or("-"); - println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message); - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - println!("No communities."); - return; - } - println!("{:<20} {}", "NAME", "TITLE"); - println!("{:<20} {}", "--------", "--------"); - for c in communities { - println!("{:<20} {}", c.name, c.title); - } - } - ResponsePayload::LogEntries(logs) => { - if logs.entries.is_empty() { - println!("No log entries."); - return; - } - println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"); - println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------"); - for entry in &logs.entries { - let level = if entry.is_error { "ERR" } else { "INF" }; - println!( - "{:<20} {:<6} {:<12} {}", - entry.timestamp_ms, level, entry.sender, entry.message - ); - } - } - ResponsePayload::Status(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Phase", status.phase); - if !status.tasks.is_empty() { - println!("{:<15} {}", "Tasks", status.tasks.join(", ")); - } - if let Some(reason) = &status.degraded_reason { - println!("{:<15} {}", "Degraded", reason); - } - } - ResponsePayload::DaemonStatus(status) => { - println!("{}", status.formatted); - } - ResponsePayload::Config(config) => { - println!("{}", config.yaml); - } - ResponsePayload::OmikronStatus(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Connected", status.connected); - if let Some(id) = &status.iota_id { - println!("{:<15} {}", "Iota ID", id); - } - } - ResponsePayload::UpdateStatus(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Available", status.available); - } - ResponsePayload::UserCreated { user_id, username } => { - println!("Created user {} ({})", username, user_id); - } - ResponsePayload::UserRemoved { user_id } => { - println!("Removed user {}", user_id); - } - ResponsePayload::Acknowledged { message } => { - println!("{}", message); - } - ResponsePayload::UserDetail(user) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Username", user.username); - println!("{:<15} {}", "User ID", user.user_id); - if let Some(ref name) = user.display_name { - println!("{:<15} {}", "Display Name", name); - } - println!("{:<15} {}", "Created At", user.created_at); - if !user.trusted_apps.is_empty() { - println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", ")); - } - } - } -} diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs index 583c281..461e272 100644 --- a/iota/src/startup_error.rs +++ b/iota/src/startup_error.rs @@ -28,52 +28,6 @@ impl StartupError { _ => 1, } } - - pub fn suggestion(&self) -> Option<&'static str> { - match self { - Self::DaemonExecutableMissing(_) => { - Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.") - } - Self::LocalSocketNotWritable(_, _) => { - Some("Check permissions on the parent directory or run as your user (not root).") - } - Self::SystemManagerUnavailable => { - Some("Install systemd or another supported process manager.") - } - Self::SystemPermissionDenied(_) => { - Some("Run with appropriate privileges or use a user-level daemon instead.") - } - Self::SocketPermissionDenied(_) => { - Some( - "Check file permissions on the socket or ensure the daemon is running as your user.", - ) - } - Self::IpcTimedOut(_) => { - Some( - "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", - ) - } - Self::ProtocolMismatch { .. } => { - Some("Update your CLI or daemon to match versions.") - } - Self::DaemonExited { .. } => { - Some("Restart the daemon with `iota daemon restart`.") - } - Self::IpcBindUnavailable(_) => { - Some("Another instance may be running. Stop it first or use a different socket path.") - } - Self::Terminal(_) => { - Some("Use a terminal that supports interactive mode, or run commands headlessly.") - } - Self::Consent(_) => { - Some("Run `iota terms accept` in an interactive terminal to review and accept terms.") - } - Self::InvalidCommand(_) => { - Some("Run `iota --help` to see available commands.") - } - _ => None, - } - } } impl fmt::Display for StartupError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -121,18 +75,6 @@ pub fn exit_code(error: &StartupError) -> ExitCode { ExitCode::from(error.exit_code()) } -pub fn print_error(error: &StartupError) { - let color = crate::cli_color::ColorConfig::new(); - eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error); - if let Some(suggestion) = error.suggestion() { - eprintln!( - " {} {}", - crate::cli_color::info(&color, "hint:"), - suggestion - ); - } -} - #[cfg(test)] mod tests { use super::*; @@ -147,10 +89,4 @@ mod tests { assert!(error.to_string().contains("authorization")); assert!(error.to_string().contains("administrator")); } - #[test] - fn most_errors_have_suggestions() { - assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some()); - assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some()); - assert!(StartupError::Cancelled.suggestion().is_none()); - } }