1use 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
24const BLOCK_KEYS: &str = "r: run again · Enter: back to Trail";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RunResult {
30 Success,
32 Failed {
38 code: Option<i32>,
40 },
41 CouldNotRun(String),
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RunReport {
49 pub label: Option<String>,
53 pub program: String,
55 pub result: RunResult,
57 pub elapsed: Duration,
59}
60
61impl RunReport {
62 pub fn succeeded(&self) -> bool {
64 self.result == RunResult::Success
65 }
66
67 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 fn name(&self) -> String {
84 sanitize(self.label.as_deref().unwrap_or(&self.program))
85 }
86
87 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
106pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum BlockAnswer {
120 RunAgain,
122 Return,
124 Ignore,
126}
127
128pub 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
148const RERUN_KEY_QUIET: Duration = Duration::from_millis(1100);
155
156#[derive(Debug, Default)]
169pub struct RerunKey {
170 last_seen_down: Option<Instant>,
172}
173
174impl RerunKey {
175 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#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct ResultBlock {
200 pub header_start: String,
202 pub outcome: String,
204 pub header_end: String,
206 pub succeeded: bool,
208 pub reason: Option<String>,
210 pub folder: String,
212 pub keys: &'static str,
214 pub closing: String,
216}
217
218impl ResultBlock {
219 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 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 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
265fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}