//! Invoking the CLIs the console fronts, and recording what was invoked. //! //! Every command the console runs passes through here, which is what makes the //! log pane's promise honest: the pane cannot show a command the console did //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the //! console is not trying to hide the CLI, it's trying to make the CLI //! approachable" — is enforced structurally rather than by remembering to log. use std::collections::VecDeque; use std::process::Command; use alloy_tui::{LogEntry, Severity}; use anyhow::{Context, Result, bail}; /// How many invocations the log keeps. The pane shows a couple of rows; the /// rest is scrollback for a future `alloy log` or a scroll binding. const LOG_CAPACITY: usize = 256; /// The command log — the console's transcript of what the user asked for. /// /// "What the user asked for" is the precise contract, and it is narrower than /// "everything the console runs". Three kinds of invocation are the console's /// own bookkeeping rather than a user action, and recording them would drown /// the pane in traffic nobody triggered: /// /// - **Probes**, which run before the user has asked for anything. /// - **Background polls**, which run on the shell tick so that a stream /// appearing is visible without a keypress. /// - **Post-action re-reads**, which confirm what an action actually did. /// /// Those go through [`CommandLog::quiet`]. Everything a keypress directly /// causes is recorded, including an explicit refresh. Without this split a /// single volume nudge writes its action plus a four-command re-read into a /// two-row pane, and the command the user actually pressed a key for scrolls /// off before they can read it. #[derive(Debug, Default)] pub struct CommandLog { entries: VecDeque, muted: bool, } impl CommandLog { pub fn new() -> Self { Self::default() } pub fn record(&mut self, command: impl Into, outcome: Severity) { if self.muted { return; } if self.entries.len() == LOG_CAPACITY { self.entries.pop_front(); } self.entries.push_back(LogEntry::new(command, outcome)); } /// Run `f` with recording suppressed, for console bookkeeping. /// /// Scoped rather than a pair of set-muted calls so the suppression cannot /// leak: an early return or a `?` inside `f` still restores the previous /// state. Nesting restores to the enclosing state rather than to unmuted. pub fn quiet(&mut self, f: impl FnOnce(&mut Self) -> T) -> T { let was_muted = self.muted; self.muted = true; let out = f(self); self.muted = was_muted; out } /// Entries oldest-first, for [`AlloyLog`](alloy_tui::AlloyLog). /// /// `VecDeque` is not contiguous, so the slice view needs the ring /// straightened first; this is called once per frame, and after the first /// call the deque is already contiguous. pub fn entries(&mut self) -> &[LogEntry] { self.entries.make_contiguous(); self.entries.as_slices().0 } } /// A command line, held as argv rather than a string so it is executed exactly /// as displayed — no shell, no quoting round-trip, no injection surface. #[derive(Debug, Clone)] pub struct Invocation { program: String, args: Vec, } impl Invocation { pub fn new(program: impl Into) -> Self { Self { program: program.into(), args: Vec::new(), } } pub fn arg(mut self, arg: impl Into) -> Self { self.args.push(arg.into()); self } pub fn args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { self.args.extend(args.into_iter().map(Into::into)); self } /// The command as a user would type it. Arguments containing whitespace are /// quoted so the displayed line is copy-pasteable into a shell and means /// the same thing there as it did here. pub fn display(&self) -> String { let mut out = String::from(&self.program); for arg in &self.args { out.push(' '); if arg.contains(char::is_whitespace) { out.push('\''); out.push_str(arg); out.push('\''); } else { out.push_str(arg); } } out } /// Run the command and return its stdout, recording the invocation and its /// outcome in `log`. pub fn run(&self, log: &mut CommandLog) -> Result { let result = self.capture(); log.record( self.display(), if result.is_ok() { Severity::Healthy } else { Severity::Error }, ); result } /// Run without logging — for probes, which run before the user has asked /// for anything and would otherwise fill the pane with noise the user did /// not trigger. pub fn probe(&self) -> bool { self.capture().is_ok() } /// Run without logging, keeping the output. /// /// For console bookkeeping that needs a result rather than a yes/no: /// one-time capability lookups at startup. Distinct from /// [`run`](Self::run) with [`CommandLog::quiet`] in intent rather than /// effect — this is for calls that should never be logged at all, such as /// `debug` subcommands the console reads but no user should be told to /// run. pub fn capture_quiet(&self) -> Result { self.capture() } fn capture(&self) -> Result { let output = Command::new(&self.program) .args(&self.args) .output() .with_context(|| format!("failed to invoke `{}`", self.display()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let detail = stderr.trim(); // A nonzero exit with nothing on stderr is common enough (nmcli // does it for "no such device") that reporting an empty message // would leave the user with no idea what happened. if detail.is_empty() { bail!("`{}` exited with {}", self.display(), output.status); } bail!("`{}`: {detail}", self.display()); } String::from_utf8(output.stdout) .with_context(|| format!("`{}` emitted non-UTF-8 output", self.display())) } } #[cfg(test)] mod tests { use super::*; #[test] fn display_round_trips_a_plain_command() { let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]); assert_eq!(inv.display(), "nmcli -t -f DEVICE,TYPE device status"); } // An SSID with a space is the common case that breaks a naive join. The // displayed line is advertised as copy-pasteable, so it has to survive one. #[test] fn display_quotes_arguments_containing_whitespace() { let inv = Invocation::new("nmcli").args(["connection", "up", "Coffee Shop Wifi"]); assert_eq!(inv.display(), "nmcli connection up 'Coffee Shop Wifi'"); } #[test] fn log_keeps_insertion_order() { let mut log = CommandLog::new(); log.record("first", Severity::Healthy); log.record("second", Severity::Error); let entries = log.entries(); assert_eq!(entries[0].command, "first"); assert_eq!(entries[1].command, "second"); assert_eq!(entries[1].outcome, Severity::Error); } // The ring must drop the oldest rather than grow without bound or, worse, // silently stop recording once it is full. #[test] fn log_evicts_oldest_at_capacity() { let mut log = CommandLog::new(); for i in 0..LOG_CAPACITY + 10 { log.record(format!("cmd {i}"), Severity::Healthy); } let entries = log.entries(); assert_eq!(entries.len(), LOG_CAPACITY); assert_eq!(entries[0].command, "cmd 10", "oldest entries were evicted"); assert_eq!(entries[LOG_CAPACITY - 1].command, format!("cmd {}", LOG_CAPACITY + 9)); } #[test] fn quiet_suppresses_recording_and_restores_after() { let mut log = CommandLog::new(); log.record("visible", Severity::Healthy); log.quiet(|log| log.record("hidden", Severity::Healthy)); log.record("visible again", Severity::Healthy); let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect(); assert_eq!(commands, ["visible", "visible again"]); } // Nesting must restore to the enclosing state, not unconditionally to // unmuted, or an inner scope silently re-enables logging for the outer one. #[test] fn nested_quiet_restores_to_the_enclosing_state() { let mut log = CommandLog::new(); log.quiet(|log| { log.quiet(|log| log.record("inner", Severity::Healthy)); log.record("outer", Severity::Healthy); }); log.record("after", Severity::Healthy); let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect(); assert_eq!(commands, ["after"], "both nested levels stayed muted"); } // `entries()` straightens the deque; a wrapped ring must still read back in // order, or the pane shows the transcript spliced at the wrap point. #[test] fn entries_are_contiguous_after_wrapping() { let mut log = CommandLog::new(); for i in 0..LOG_CAPACITY * 2 { log.record(format!("cmd {i}"), Severity::Healthy); } let entries = log.entries(); assert_eq!(entries.len(), LOG_CAPACITY, "no entries lost to the wrap"); for (offset, entry) in entries.iter().enumerate() { assert_eq!(entry.command, format!("cmd {}", LOG_CAPACITY + offset)); } } }