Skip to main content

trail/actions/
run_report.rs

1//! What became of an external command: the result block printed under its
2//! output, the key that answers the block, and the status bar's report.
3//!
4//! Everything here is pure, so it can be tested without a terminal; the printing
5//! and the waiting are `shell_exec`'s. The block is designed in
6//! `upcoming_features.md` §2.25:
7//!
8//! ```text
9//! ── !cargo test ───────────────────── failed: exit 101 · 12.4s
10//! C:\Users\weedo\proj\it\util\trail
11//! r: run again · Enter: back to Trail
12//! ─────────────────────────────────────────────────────────────
13//! ```
14
15use std::path::Path;
16use std::time::{Duration, Instant};
17
18use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
19
20use crate::input::is_text_modifiers;
21use crate::pathfmt;
22use crate::preview::provider::sanitize;
23
24/// The line that says what can be done next.
25const BLOCK_KEYS: &str = "r: run again · Enter: back to Trail";
26
27/// How a command ended.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RunResult {
30    /// It exited with status zero.
31    Success,
32    /// It exited with a failure. `code` is `None` when there is no exit code,
33    /// which is how a process killed by a signal looks on Unix.
34    ///
35    /// Commands run through `[general] shell`, so this is the shell's code:
36    /// PowerShell's `-Command` can report a failed tool as plain `1`.
37    Failed {
38        /// The exit code, when the platform reported one.
39        code: Option<i32>,
40    },
41    /// It never ran to an exit: the program could not be started, or waiting
42    /// for it failed. Holds the operating system's reason.
43    CouldNotRun(String),
44}
45
46/// One run of an external command.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RunReport {
49    /// The command as typed, `!cargo test` or `:git status`. `None` for what
50    /// Trail starts on the user's behalf — the editor, the OS file handler —
51    /// which report only when they fail.
52    pub label: Option<String>,
53    /// The program spawned, which names the run when there is no `label`.
54    pub program: String,
55    /// How it ended.
56    pub result: RunResult,
57    /// How long it ran.
58    pub elapsed: Duration,
59}
60
61impl RunReport {
62    /// Whether the command succeeded.
63    pub fn succeeded(&self) -> bool {
64        self.result == RunResult::Success
65    }
66
67    /// The result in words: `ok`, or `failed: exit 101`.
68    ///
69    /// The words carry the meaning, so it reads correctly without colour.
70    pub fn outcome(&self) -> String {
71        match &self.result {
72            RunResult::Success => "ok".to_owned(),
73            RunResult::Failed { code: Some(code) } => format!("failed: exit {code}"),
74            RunResult::Failed { code: None } => "failed: terminated".to_owned(),
75            RunResult::CouldNotRun(_) => "failed: could not start".to_owned(),
76        }
77    }
78
79    /// What names this run on screen: the command as typed, else the program.
80    ///
81    /// Sanitized, because a pasted command may hold control characters, and
82    /// this is printed straight to the terminal.
83    fn name(&self) -> String {
84        sanitize(self.label.as_deref().unwrap_or(&self.program))
85    }
86
87    /// The status bar's report, or `None` when there is nothing to say: the
88    /// editor and the OS file handler report only a failure.
89    ///
90    /// Deliberately independent of `[general] shell_pause`. The block is out of
91    /// sight as soon as the alternate screen returns, and under `never` this is
92    /// the only report there is.
93    pub fn status_line(&self) -> Option<String> {
94        if self.label.is_none() && self.succeeded() {
95            return None;
96        }
97        let name = self.name();
98        let outcome = self.outcome();
99        Some(match &self.result {
100            RunResult::CouldNotRun(reason) => format!("{name} {outcome}: {reason}"),
101            _ => format!("{name} {outcome} ({})", format_elapsed(self.elapsed)),
102        })
103    }
104}
105
106/// Spells a duration the way the block and the status bar show it: `0.3s`,
107/// `12.4s`, `2m 05s`, `1h 02m`.
108pub fn format_elapsed(elapsed: Duration) -> String {
109    let secs = elapsed.as_secs();
110    match secs {
111        0..=59 => format!("{:.1}s", elapsed.as_secs_f64()),
112        60..=3599 => format!("{}m {:02}s", secs / 60, secs % 60),
113        _ => format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60),
114    }
115}
116
117/// What a key means while the result block is on screen.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum BlockAnswer {
120    /// Run the same command again, in the same folder.
121    RunAgain,
122    /// Go back to Trail.
123    Return,
124    /// Not an answer; keep waiting.
125    Ignore,
126}
127
128/// Decides what `key` means at the result block.
129///
130/// Only presses count: Windows also reports key releases, and the release of
131/// the Enter that launched the command must not read as an answer. `R` is
132/// accepted so Caps Lock does not get in the way, but `Ctrl-r` is a chord, not
133/// the letter.
134///
135/// This cannot tell a held `r` from a new one, because Windows reports a held
136/// key as more presses, not as `Repeat`. [`RerunKey`] is what tells them apart.
137pub fn answer_for(key: &KeyEvent) -> BlockAnswer {
138    if key.kind != KeyEventKind::Press {
139        return BlockAnswer::Ignore;
140    }
141    match key.code {
142        KeyCode::Char('r' | 'R') if is_text_modifiers(key.modifiers) => BlockAnswer::RunAgain,
143        KeyCode::Enter | KeyCode::Esc => BlockAnswer::Return,
144        _ => BlockAnswer::Ignore,
145    }
146}
147
148/// How long `r` must have been silent before a press with no release in between
149/// counts as new.
150///
151/// Longer than the wait before a key starts repeating, which Windows lets the
152/// user set to at most a second. Otherwise, after a command that finishes
153/// quickly, a held key's first repeat would look like a second press.
154const RERUN_KEY_QUIET: Duration = Duration::from_millis(1100);
155
156/// Tracks whether `r` is still down, so that holding it runs the command once.
157///
158/// Holding a key reaches Trail as a stream of presses, on Windows and on any
159/// terminal that reports no key kinds, so the block has to work this out itself.
160/// A press counts only if `r` came up first. A release says so directly. Where
161/// none arrives, either because the terminal does not report them or because the
162/// command read it from the console, a long enough silence says so instead. That
163/// makes `r` tapped again within about a second do nothing on those terminals: the
164/// safe way to be wrong, since the command may be `!del old.log`.
165///
166/// One value lasts across every block of a run, and it must see every key, those
167/// thrown away as typed too early included, or it loses track of the key.
168#[derive(Debug, Default)]
169pub struct RerunKey {
170    /// When `r` was last seen going down, while it is thought to still be down.
171    last_seen_down: Option<Instant>,
172}
173
174impl RerunKey {
175    /// Decides what `key`, received at `now`, means at the result block.
176    pub fn answer(&mut self, key: &KeyEvent, now: Instant) -> BlockAnswer {
177        let answer = answer_for(key);
178        if !matches!(key.code, KeyCode::Char('r' | 'R')) {
179            return answer;
180        }
181        if key.kind == KeyEventKind::Release {
182            self.last_seen_down = None;
183            return answer;
184        }
185        let still_down = self
186            .last_seen_down
187            .is_some_and(|seen| now.saturating_duration_since(seen) < RERUN_KEY_QUIET);
188        self.last_seen_down = Some(now);
189        if still_down {
190            BlockAnswer::Ignore
191        } else {
192            answer
193        }
194    }
195}
196
197/// The result block, laid out for a terminal of a given width.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct ResultBlock {
200    /// The start of the header, up to the result: `── !cargo test ─────── `.
201    pub header_start: String,
202    /// The result in words, printed in green or red.
203    pub outcome: String,
204    /// The end of the header: ` · 12.4s`.
205    pub header_end: String,
206    /// Whether to print `outcome` as a success.
207    pub succeeded: bool,
208    /// Why a command could not start, which nothing else on screen would say.
209    pub reason: Option<String>,
210    /// The folder it ran in.
211    pub folder: String,
212    /// What can be done next.
213    pub keys: &'static str,
214    /// The rule that ends the block, which keeps runs apart.
215    pub closing: String,
216}
217
218impl ResultBlock {
219    /// Lays out the block for `report`, run in `cwd`, in `columns` columns.
220    ///
221    /// Everything stays one column short of the width: a line that exactly fills
222    /// the row leaves a blank line after it on some Windows consoles. Columns
223    /// are counted in characters, as the command line counts them.
224    pub fn new(report: &RunReport, cwd: &Path, columns: u16) -> Self {
225        let width = usize::from(columns).saturating_sub(1);
226        let outcome = report.outcome();
227        let header_end = format!(" · {}", format_elapsed(report.elapsed));
228
229        // `── name ─ outcome · time`: three columns before the name, then a
230        // space, at least one column of rule, and a space.
231        let tail = outcome.chars().count() + header_end.chars().count();
232        let room = width.saturating_sub(tail + 6);
233        let name = shorten(&report.name(), room);
234        let rule = width.saturating_sub(tail + 5 + name.chars().count()).max(1);
235
236        ResultBlock {
237            header_start: format!("── {name} {} ", "─".repeat(rule)),
238            outcome,
239            header_end,
240            succeeded: report.succeeded(),
241            reason: match &report.result {
242                RunResult::CouldNotRun(reason) => Some(sanitize(reason)),
243                _ => None,
244            },
245            folder: pathfmt::elide_front(&pathfmt::display(cwd), width),
246            keys: BLOCK_KEYS,
247            closing: "─".repeat(width),
248        }
249    }
250
251    /// The block as plain lines, top to bottom.
252    pub fn lines(&self) -> Vec<String> {
253        let mut lines = vec![format!(
254            "{}{}{}",
255            self.header_start, self.outcome, self.header_end
256        )];
257        lines.extend(self.reason.clone());
258        lines.push(self.folder.clone());
259        lines.push(self.keys.to_owned());
260        lines.push(self.closing.clone());
261        lines
262    }
263}
264
265/// Fits `text` into `width` columns, marking anything dropped with `…`.
266fn shorten(text: &str, width: usize) -> String {
267    if text.chars().count() <= width {
268        return text.to_owned();
269    }
270    let kept: String = text.chars().take(width.saturating_sub(1)).collect();
271    if width == 0 {
272        kept
273    } else {
274        format!("{kept}…")
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crossterm::event::{KeyEventState, KeyModifiers};
282
283    fn report(result: RunResult) -> RunReport {
284        RunReport {
285            label: Some("!cargo test".to_owned()),
286            program: "cmd.exe".to_owned(),
287            result,
288            elapsed: Duration::from_millis(12_400),
289        }
290    }
291
292    fn key(code: KeyCode, kind: KeyEventKind) -> KeyEvent {
293        KeyEvent {
294            code,
295            modifiers: KeyModifiers::NONE,
296            kind,
297            state: KeyEventState::NONE,
298        }
299    }
300
301    #[test]
302    fn r_runs_again_and_enter_or_esc_go_back() {
303        let press = |code| answer_for(&key(code, KeyEventKind::Press));
304        assert_eq!(press(KeyCode::Char('r')), BlockAnswer::RunAgain);
305        assert_eq!(
306            press(KeyCode::Char('R')),
307            BlockAnswer::RunAgain,
308            "Caps Lock must not get in the way"
309        );
310        assert_eq!(press(KeyCode::Enter), BlockAnswer::Return);
311        assert_eq!(press(KeyCode::Esc), BlockAnswer::Return);
312        assert_eq!(press(KeyCode::Char('x')), BlockAnswer::Ignore);
313        assert_eq!(press(KeyCode::Char(' ')), BlockAnswer::Ignore);
314        assert_eq!(press(KeyCode::Down), BlockAnswer::Ignore);
315    }
316
317    /// The release of the Enter that launched the command arrives after the
318    /// command has started, and must not dismiss its result.
319    #[test]
320    fn only_a_press_is_an_answer() {
321        for kind in [KeyEventKind::Release, KeyEventKind::Repeat] {
322            for code in [KeyCode::Char('r'), KeyCode::Enter, KeyCode::Esc] {
323                assert_eq!(
324                    answer_for(&key(code, kind)),
325                    BlockAnswer::Ignore,
326                    "{code:?} {kind:?}"
327                );
328            }
329        }
330    }
331
332    fn r(kind: KeyEventKind) -> KeyEvent {
333        key(KeyCode::Char('r'), kind)
334    }
335
336    /// What Windows sends for a held key: presses, and no `Repeat`. The gaps are
337    /// its default wait before repeating, then its default repeat rate.
338    #[test]
339    fn holding_r_is_one_answer_however_long_it_is_held() {
340        let start = Instant::now();
341        let mut rerun = RerunKey::default();
342
343        let first = rerun.answer(&r(KeyEventKind::Press), start);
344        assert_eq!(first, BlockAnswer::RunAgain);
345
346        let mut now = start + Duration::from_millis(500);
347        for _ in 0..100 {
348            let repeat = rerun.answer(&r(KeyEventKind::Press), now);
349            assert_eq!(repeat, BlockAnswer::Ignore);
350            now += Duration::from_millis(33);
351        }
352    }
353
354    /// The longest wait Windows can be set to before a key starts repeating.
355    #[test]
356    fn the_first_repeat_of_a_slow_keyboard_is_still_the_same_hold() {
357        let start = Instant::now();
358        let mut rerun = RerunKey::default();
359        rerun.answer(&r(KeyEventKind::Press), start);
360
361        let repeat = rerun.answer(&r(KeyEventKind::Press), start + Duration::from_secs(1));
362        assert_eq!(repeat, BlockAnswer::Ignore);
363    }
364
365    #[test]
366    fn r_works_again_at_once_after_a_release() {
367        let start = Instant::now();
368        let mut rerun = RerunKey::default();
369        rerun.answer(&r(KeyEventKind::Press), start);
370        let released = rerun.answer(&r(KeyEventKind::Release), start);
371
372        assert_eq!(released, BlockAnswer::Ignore);
373        assert_eq!(
374            rerun.answer(&r(KeyEventKind::Press), start),
375            BlockAnswer::RunAgain
376        );
377    }
378
379    /// Terminals that report no releases, and a command that read the release
380    /// from the console: `r` must not stay dead for good.
381    #[test]
382    fn r_works_again_after_silence_when_no_release_arrives() {
383        let start = Instant::now();
384        let mut rerun = RerunKey::default();
385        rerun.answer(&r(KeyEventKind::Press), start);
386
387        let later = start + RERUN_KEY_QUIET;
388        assert_eq!(
389            rerun.answer(&r(KeyEventKind::Press), later),
390            BlockAnswer::RunAgain
391        );
392    }
393
394    /// `r` held through a long command, whose repeats were thrown away as typed
395    /// too early: the block that follows must not take the next one as an answer.
396    #[test]
397    fn a_hold_seen_among_discarded_keys_still_counts_as_held() {
398        let start = Instant::now();
399        let mut rerun = RerunKey::default();
400        rerun.answer(&r(KeyEventKind::Press), start);
401
402        let next = rerun.answer(&r(KeyEventKind::Press), start + Duration::from_millis(33));
403        assert_eq!(next, BlockAnswer::Ignore);
404    }
405
406    /// `r` tapped during a command, then tapped at its block.
407    #[test]
408    fn a_tap_during_the_command_does_not_block_the_next_one() {
409        let start = Instant::now();
410        let mut rerun = RerunKey::default();
411        rerun.answer(&r(KeyEventKind::Press), start);
412        rerun.answer(&r(KeyEventKind::Release), start);
413
414        assert_eq!(
415            rerun.answer(&r(KeyEventKind::Press), start),
416            BlockAnswer::RunAgain
417        );
418    }
419
420    #[test]
421    fn other_keys_answer_while_r_is_held() {
422        let start = Instant::now();
423        let mut rerun = RerunKey::default();
424        rerun.answer(&r(KeyEventKind::Press), start);
425
426        let enter = key(KeyCode::Enter, KeyEventKind::Press);
427        assert_eq!(rerun.answer(&enter, start), BlockAnswer::Return);
428        let other = key(KeyCode::Char('x'), KeyEventKind::Press);
429        assert_eq!(rerun.answer(&other, start), BlockAnswer::Ignore);
430    }
431
432    #[test]
433    fn ctrl_r_is_a_chord_not_the_letter() {
434        let chord = KeyEvent {
435            modifiers: KeyModifiers::CONTROL,
436            ..key(KeyCode::Char('r'), KeyEventKind::Press)
437        };
438        assert_eq!(answer_for(&chord), BlockAnswer::Ignore);
439    }
440
441    #[test]
442    fn the_block_says_what_happened_on_success() {
443        let block = ResultBlock::new(&report(RunResult::Success), Path::new("/work"), 61);
444        let lines = block.lines();
445
446        assert_eq!(lines.len(), 4);
447        assert!(lines[0].starts_with("── !cargo test ─"), "{}", lines[0]);
448        assert!(lines[0].ends_with("─ ok · 12.4s"), "{}", lines[0]);
449        assert_eq!(lines[1], crate::pathfmt::display(Path::new("/work")));
450        assert_eq!(lines[2], "r: run again · Enter: back to Trail");
451        assert_eq!(lines[3], "─".repeat(60));
452        assert!(block.succeeded);
453    }
454
455    #[test]
456    fn the_block_says_what_happened_on_failure() {
457        let block = ResultBlock::new(
458            &report(RunResult::Failed { code: Some(101) }),
459            Path::new("/work"),
460            61,
461        );
462        assert!(
463            block.lines()[0].ends_with("─ failed: exit 101 · 12.4s"),
464            "{}",
465            block.lines()[0]
466        );
467        assert!(!block.succeeded);
468    }
469
470    #[test]
471    fn a_failed_spawn_says_why_on_a_line_of_its_own() {
472        let block = ResultBlock::new(
473            &report(RunResult::CouldNotRun("program not found".to_owned())),
474            Path::new("/work"),
475            61,
476        );
477        let lines = block.lines();
478        assert!(lines[0].contains("failed: could not start"), "{}", lines[0]);
479        assert_eq!(lines[1], "program not found");
480        assert_eq!(lines.len(), 5);
481    }
482
483    #[test]
484    fn a_long_command_is_shortened_to_the_width_and_keeps_the_result() {
485        let mut long = report(RunResult::Failed { code: Some(1) });
486        long.label = Some(format!("!{}", "x".repeat(300)));
487        let header = ResultBlock::new(&long, Path::new("/work"), 80).lines()[0].clone();
488
489        assert_eq!(header.chars().count(), 79, "{header}");
490        assert!(header.contains('…'), "{header}");
491        assert!(header.ends_with("failed: exit 1 · 12.4s"), "{header}");
492    }
493
494    #[test]
495    fn every_header_fills_the_same_width_as_the_closing_rule() {
496        let block = ResultBlock::new(&report(RunResult::Success), Path::new("/work"), 61);
497        let lines = block.lines();
498        assert_eq!(lines[0].chars().count(), lines[3].chars().count());
499    }
500
501    /// A terminal too narrow for any of it must not panic or lose the result.
502    #[test]
503    fn a_tiny_terminal_still_lays_out() {
504        for columns in [0, 1, 5, 20] {
505            let lines =
506                ResultBlock::new(&report(RunResult::Success), Path::new("/w"), columns).lines();
507            assert!(lines[0].contains("ok · 12.4s"), "{columns}: {}", lines[0]);
508        }
509    }
510
511    #[test]
512    fn a_control_character_in_a_command_cannot_reach_the_terminal() {
513        let mut pasted = report(RunResult::Success);
514        pasted.label = Some("!echo \u{1b}[2Jhi".to_owned());
515        let header = ResultBlock::new(&pasted, Path::new("/w"), 80).lines()[0].clone();
516        assert!(!header.contains('\u{1b}'), "{header:?}");
517    }
518
519    #[test]
520    fn the_status_line_reports_a_command_either_way() {
521        assert_eq!(
522            report(RunResult::Failed { code: Some(101) }).status_line(),
523            Some("!cargo test failed: exit 101 (12.4s)".to_owned())
524        );
525        assert_eq!(
526            report(RunResult::Success).status_line(),
527            Some("!cargo test ok (12.4s)".to_owned())
528        );
529    }
530
531    #[test]
532    fn the_editor_reports_only_a_failure() {
533        let mut editor = report(RunResult::Success);
534        editor.label = None;
535        editor.program = "nvim".to_owned();
536        assert_eq!(editor.status_line(), None);
537
538        editor.result = RunResult::Failed { code: Some(1) };
539        assert_eq!(
540            editor.status_line(),
541            Some("nvim failed: exit 1 (12.4s)".to_owned())
542        );
543    }
544
545    #[test]
546    fn a_process_with_no_exit_code_still_reads_as_failed() {
547        let outcome = report(RunResult::Failed { code: None }).outcome();
548        assert_eq!(outcome, "failed: terminated");
549    }
550
551    #[test]
552    fn durations_are_spelled_for_a_person() {
553        assert_eq!(format_elapsed(Duration::from_millis(300)), "0.3s");
554        assert_eq!(format_elapsed(Duration::from_millis(12_400)), "12.4s");
555        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
556        assert_eq!(format_elapsed(Duration::from_secs(3720)), "1h 02m");
557    }
558}