Skip to main content

max / alloy_tui

9.7 KB · 266 lines History Blame Raw
1 //! Invoking the CLIs the console fronts, and recording what was invoked.
2 //!
3 //! Every command the console runs passes through here, which is what makes the
4 //! log pane's promise honest: the pane cannot show a command the console did
5 //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the
6 //! console is not trying to hide the CLI, it's trying to make the CLI
7 //! approachable" — is enforced structurally rather than by remembering to log.
8
9 use std::collections::VecDeque;
10 use std::process::Command;
11
12 use alloy_tui::{LogEntry, Severity};
13 use anyhow::{Context, Result, bail};
14
15 /// How many invocations the log keeps. The pane shows a couple of rows; the
16 /// rest is scrollback for a future `alloy log` or a scroll binding.
17 const LOG_CAPACITY: usize = 256;
18
19 /// The command log — the console's transcript of what the user asked for.
20 ///
21 /// "What the user asked for" is the precise contract, and it is narrower than
22 /// "everything the console runs". Three kinds of invocation are the console's
23 /// own bookkeeping rather than a user action, and recording them would drown
24 /// the pane in traffic nobody triggered:
25 ///
26 /// - **Probes**, which run before the user has asked for anything.
27 /// - **Background polls**, which run on the shell tick so that a stream
28 /// appearing is visible without a keypress.
29 /// - **Post-action re-reads**, which confirm what an action actually did.
30 ///
31 /// Those go through [`CommandLog::quiet`]. Everything a keypress directly
32 /// causes is recorded, including an explicit refresh. Without this split a
33 /// single volume nudge writes its action plus a four-command re-read into a
34 /// two-row pane, and the command the user actually pressed a key for scrolls
35 /// off before they can read it.
36 #[derive(Debug, Default)]
37 pub struct CommandLog {
38 entries: VecDeque<LogEntry>,
39 muted: bool,
40 }
41
42 impl CommandLog {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn record(&mut self, command: impl Into<String>, outcome: Severity) {
48 if self.muted {
49 return;
50 }
51 if self.entries.len() == LOG_CAPACITY {
52 self.entries.pop_front();
53 }
54 self.entries.push_back(LogEntry::new(command, outcome));
55 }
56
57 /// Run `f` with recording suppressed, for console bookkeeping.
58 ///
59 /// Scoped rather than a pair of set-muted calls so the suppression cannot
60 /// leak: an early return or a `?` inside `f` still restores the previous
61 /// state. Nesting restores to the enclosing state rather than to unmuted.
62 pub fn quiet<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
63 let was_muted = self.muted;
64 self.muted = true;
65 let out = f(self);
66 self.muted = was_muted;
67 out
68 }
69
70 /// Entries oldest-first, for [`AlloyLog`](alloy_tui::AlloyLog).
71 ///
72 /// `VecDeque` is not contiguous, so the slice view needs the ring
73 /// straightened first; this is called once per frame, and after the first
74 /// call the deque is already contiguous.
75 pub fn entries(&mut self) -> &[LogEntry] {
76 self.entries.make_contiguous();
77 self.entries.as_slices().0
78 }
79 }
80
81 /// A command line, held as argv rather than a string so it is executed exactly
82 /// as displayed — no shell, no quoting round-trip, no injection surface.
83 #[derive(Debug, Clone)]
84 pub struct Invocation {
85 program: String,
86 args: Vec<String>,
87 }
88
89 impl Invocation {
90 pub fn new(program: impl Into<String>) -> Self {
91 Self {
92 program: program.into(),
93 args: Vec::new(),
94 }
95 }
96
97 pub fn arg(mut self, arg: impl Into<String>) -> Self {
98 self.args.push(arg.into());
99 self
100 }
101
102 pub fn args<I, S>(mut self, args: I) -> Self
103 where
104 I: IntoIterator<Item = S>,
105 S: Into<String>,
106 {
107 self.args.extend(args.into_iter().map(Into::into));
108 self
109 }
110
111 /// The command as a user would type it. Arguments containing whitespace are
112 /// quoted so the displayed line is copy-pasteable into a shell and means
113 /// the same thing there as it did here.
114 pub fn display(&self) -> String {
115 let mut out = String::from(&self.program);
116 for arg in &self.args {
117 out.push(' ');
118 if arg.contains(char::is_whitespace) {
119 out.push('\'');
120 out.push_str(arg);
121 out.push('\'');
122 } else {
123 out.push_str(arg);
124 }
125 }
126 out
127 }
128
129 /// Run the command and return its stdout, recording the invocation and its
130 /// outcome in `log`.
131 pub fn run(&self, log: &mut CommandLog) -> Result<String> {
132 let result = self.capture();
133 log.record(
134 self.display(),
135 if result.is_ok() { Severity::Healthy } else { Severity::Error },
136 );
137 result
138 }
139
140 /// Run without logging — for probes, which run before the user has asked
141 /// for anything and would otherwise fill the pane with noise the user did
142 /// not trigger.
143 pub fn probe(&self) -> bool {
144 self.capture().is_ok()
145 }
146
147 /// Run without logging, keeping the output.
148 ///
149 /// For console bookkeeping that needs a result rather than a yes/no:
150 /// one-time capability lookups at startup. Distinct from
151 /// [`run`](Self::run) with [`CommandLog::quiet`] in intent rather than
152 /// effect — this is for calls that should never be logged at all, such as
153 /// `debug` subcommands the console reads but no user should be told to
154 /// run.
155 pub fn capture_quiet(&self) -> Result<String> {
156 self.capture()
157 }
158
159 fn capture(&self) -> Result<String> {
160 let output = Command::new(&self.program)
161 .args(&self.args)
162 .output()
163 .with_context(|| format!("failed to invoke `{}`", self.display()))?;
164
165 if !output.status.success() {
166 let stderr = String::from_utf8_lossy(&output.stderr);
167 let detail = stderr.trim();
168 // A nonzero exit with nothing on stderr is common enough (nmcli
169 // does it for "no such device") that reporting an empty message
170 // would leave the user with no idea what happened.
171 if detail.is_empty() {
172 bail!("`{}` exited with {}", self.display(), output.status);
173 }
174 bail!("`{}`: {detail}", self.display());
175 }
176
177 String::from_utf8(output.stdout)
178 .with_context(|| format!("`{}` emitted non-UTF-8 output", self.display()))
179 }
180 }
181
182 #[cfg(test)]
183 mod tests {
184 use super::*;
185
186 #[test]
187 fn display_round_trips_a_plain_command() {
188 let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]);
189 assert_eq!(inv.display(), "nmcli -t -f DEVICE,TYPE device status");
190 }
191
192 // An SSID with a space is the common case that breaks a naive join. The
193 // displayed line is advertised as copy-pasteable, so it has to survive one.
194 #[test]
195 fn display_quotes_arguments_containing_whitespace() {
196 let inv = Invocation::new("nmcli").args(["connection", "up", "Coffee Shop Wifi"]);
197 assert_eq!(inv.display(), "nmcli connection up 'Coffee Shop Wifi'");
198 }
199
200 #[test]
201 fn log_keeps_insertion_order() {
202 let mut log = CommandLog::new();
203 log.record("first", Severity::Healthy);
204 log.record("second", Severity::Error);
205 let entries = log.entries();
206 assert_eq!(entries[0].command, "first");
207 assert_eq!(entries[1].command, "second");
208 assert_eq!(entries[1].outcome, Severity::Error);
209 }
210
211 // The ring must drop the oldest rather than grow without bound or, worse,
212 // silently stop recording once it is full.
213 #[test]
214 fn log_evicts_oldest_at_capacity() {
215 let mut log = CommandLog::new();
216 for i in 0..LOG_CAPACITY + 10 {
217 log.record(format!("cmd {i}"), Severity::Healthy);
218 }
219 let entries = log.entries();
220 assert_eq!(entries.len(), LOG_CAPACITY);
221 assert_eq!(entries[0].command, "cmd 10", "oldest entries were evicted");
222 assert_eq!(entries[LOG_CAPACITY - 1].command, format!("cmd {}", LOG_CAPACITY + 9));
223 }
224
225 #[test]
226 fn quiet_suppresses_recording_and_restores_after() {
227 let mut log = CommandLog::new();
228 log.record("visible", Severity::Healthy);
229 log.quiet(|log| log.record("hidden", Severity::Healthy));
230 log.record("visible again", Severity::Healthy);
231
232 let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect();
233 assert_eq!(commands, ["visible", "visible again"]);
234 }
235
236 // Nesting must restore to the enclosing state, not unconditionally to
237 // unmuted, or an inner scope silently re-enables logging for the outer one.
238 #[test]
239 fn nested_quiet_restores_to_the_enclosing_state() {
240 let mut log = CommandLog::new();
241 log.quiet(|log| {
242 log.quiet(|log| log.record("inner", Severity::Healthy));
243 log.record("outer", Severity::Healthy);
244 });
245 log.record("after", Severity::Healthy);
246
247 let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect();
248 assert_eq!(commands, ["after"], "both nested levels stayed muted");
249 }
250
251 // `entries()` straightens the deque; a wrapped ring must still read back in
252 // order, or the pane shows the transcript spliced at the wrap point.
253 #[test]
254 fn entries_are_contiguous_after_wrapping() {
255 let mut log = CommandLog::new();
256 for i in 0..LOG_CAPACITY * 2 {
257 log.record(format!("cmd {i}"), Severity::Healthy);
258 }
259 let entries = log.entries();
260 assert_eq!(entries.len(), LOG_CAPACITY, "no entries lost to the wrap");
261 for (offset, entry) in entries.iter().enumerate() {
262 assert_eq!(entry.command, format!("cmd {}", LOG_CAPACITY + offset));
263 }
264 }
265 }
266