1use std::process::Stdio;
20use std::time::Duration;
21
22use anstyle::{AnsiColor, Style};
23use tokio::io::AsyncBufReadExt;
24use tokio::process::Command as TokioCommand;
25use tokio::sync::mpsc;
26
27use super::{ExecError, apply_env, runtime};
28
29#[derive(Debug, Clone, Default)]
35pub struct ExecStreamedOpts {
36 pub working_dir: Option<String>,
37 pub use_scrolling_area: bool,
38 pub process_descriptive_name: Option<String>,
39 pub env: Option<Vec<String>>,
40 pub timeout: Option<Duration>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum StreamSource {
46 Stdout,
47 Stderr,
48}
49
50struct StreamLine {
53 source: StreamSource,
54 text: String,
55}
56
57async fn stream_lines<R>(reader: R, source: StreamSource, tx: mpsc::UnboundedSender<StreamLine>)
62where
63 R: tokio::io::AsyncRead + Unpin,
64{
65 let mut lines = tokio::io::BufReader::new(reader).lines();
66 while let Ok(Some(text)) = lines.next_line().await {
67 if tx.send(StreamLine { source, text }).is_err() {
68 return;
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum Streamer {
76 LimitedArea,
78 Prefixed,
80 Log,
82}
83
84fn choose_streamer(is_tty: bool, opts: &ExecStreamedOpts) -> Streamer {
88 if is_tty {
89 if opts.use_scrolling_area {
90 Streamer::LimitedArea
91 } else {
92 Streamer::Prefixed
93 }
94 } else {
95 Streamer::Log
96 }
97}
98
99fn quote_command(parts: &[&str]) -> String {
105 parts
106 .iter()
107 .map(|part| quote_arg(part))
108 .collect::<Vec<_>>()
109 .join(" ")
110}
111
112fn quote_arg(arg: &str) -> String {
113 let needs_quoting = arg.is_empty()
114 || arg
115 .chars()
116 .any(|c| c.is_whitespace() || "'\"\\$`!*?[]{}()<>|&;~#".contains(c));
117
118 if !needs_quoting {
119 return arg.to_string();
120 }
121
122 format!("'{}'", arg.replace('\'', r"'\''"))
123}
124
125fn run_log_streamer(process_name: &str, line: &StreamLine) {
127 match line.source {
128 StreamSource::Stdout => tracing::debug!(command = %process_name, "stdout" = %line.text),
129 StreamSource::Stderr => tracing::debug!(command = %process_name, "stderr" = %line.text),
130 }
131}
132
133async fn run_prefixed_streamer(
140 mut rx: mpsc::UnboundedReceiver<StreamLine>,
141 process_name: &str,
142 cmd_line: &str,
143 mut out: impl std::io::Write,
144) {
145 let header_style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
146 let _ = writeln!(out, "{header_style}{cmd_line}{header_style:#}");
147
148 let prefix = format_prefix(process_name);
149 let max_line_width = term_width_for_prefixed();
150
151 let stdout_style = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
152 let stderr_style = Style::new().fg_color(Some(AnsiColor::BrightWhite.into()));
153
154 while let Some(line) = rx.recv().await {
155 let style = match line.source {
156 StreamSource::Stdout => stdout_style,
157 StreamSource::Stderr => stderr_style,
158 };
159 for wrapped in wrap_line(&line.text, max_line_width) {
160 let _ = writeln!(out, "{prefix} {style}{wrapped}{style:#}");
161 }
162 }
163}
164
165fn format_prefix(process_name: &str) -> String {
168 let truncated: String = process_name.chars().take(8).collect();
169 let padded = format!("{truncated:<8}");
170 let style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
171 format!("{style}{padded}{style:#}")
172}
173
174fn term_width_for_prefixed() -> usize {
176 let width = terminal_width().unwrap_or(80);
177 width.saturating_sub(10).max(40)
178}
179
180fn terminal_width() -> Option<usize> {
182 terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w as usize)
183}
184
185async fn run_limited_area_streamer(
188 mut rx: mpsc::UnboundedReceiver<StreamLine>,
189 cmd_line: String,
190 mut out: impl std::io::Write,
191) {
192 const MAX_LINES: usize = 5;
193
194 let width = terminal_width().unwrap_or(80);
195 let max_line_width = width.saturating_sub(1).max(40);
196
197 let mut area = AreaPrinter::default();
198 let mut display_lines: Vec<String> = Vec::new();
199 let mut ticker = tokio::time::interval(Duration::from_millis(100));
200 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
201
202 loop {
203 tokio::select! {
204 received = rx.recv() => {
205 match received {
206 Some(line) => {
207 display_lines.push(line.text);
208 if display_lines.len() > MAX_LINES {
209 let excess = display_lines.len() - MAX_LINES;
210 display_lines.drain(0..excess);
211 }
212 }
213 None => break,
214 }
215 }
216 _ = ticker.tick() => {
217 flush_lines_to_area(&cmd_line, &display_lines, max_line_width, &mut area);
218 let _ = area.redraw(&mut out);
219 }
220 }
221 }
222
223 flush_lines_to_area(&cmd_line, &display_lines, max_line_width, &mut area);
224 let _ = area.redraw(&mut out);
225 let _ = area.finish(&mut out);
226}
227
228async fn exec_streamed_command_async(
229 opts: ExecStreamedOpts,
230 name: &str,
231 args: &[String],
232) -> Result<(), ExecError> {
233 let mut cmd = TokioCommand::new(name);
234 cmd.args(args);
235 apply_env(&mut cmd, &opts.env);
236 if let Some(dir) = &opts.working_dir
237 && !dir.is_empty()
238 {
239 cmd.current_dir(dir);
240 }
241 cmd.stdin(Stdio::null());
242 cmd.stdout(Stdio::piped());
243 cmd.stderr(Stdio::piped());
244
245 let process_name = opts
246 .process_descriptive_name
247 .clone()
248 .unwrap_or_else(|| name.to_string());
249
250 let mut child = cmd
251 .spawn()
252 .map_err(|error| ExecError::RunFailed(error.to_string()))?;
253
254 let stdout = child.stdout.take().expect("stdout was piped");
255 let stderr = child.stderr.take().expect("stderr was piped");
256
257 let (tx, rx) = mpsc::unbounded_channel::<StreamLine>();
258 let stdout_tx = tx.clone();
259 let stdout_task = tokio::spawn(stream_lines(stdout, StreamSource::Stdout, stdout_tx));
260 let stderr_task = tokio::spawn(stream_lines(stderr, StreamSource::Stderr, tx.clone()));
261 drop(tx);
262
263 let streamer_kind = choose_streamer(is_stderr_tty(), &opts);
264 let cmd_line_parts: Vec<&str> = std::iter::once(name)
265 .chain(args.iter().map(String::as_str))
266 .collect();
267 let cmd_line = quote_command(&cmd_line_parts);
268
269 let renderer_task = match streamer_kind {
270 Streamer::Log => {
271 let process_name = process_name.clone();
272 tokio::spawn(async move {
273 let mut rx = rx;
274 while let Some(line) = rx.recv().await {
275 run_log_streamer(&process_name, &line);
276 }
277 })
278 }
279 Streamer::Prefixed => {
280 let process_name = process_name.clone();
281 tokio::spawn(async move {
282 run_prefixed_streamer(rx, &process_name, &cmd_line, anstream::stderr()).await
283 })
284 }
285 Streamer::LimitedArea => {
286 tokio::spawn(run_limited_area_streamer(rx, cmd_line, anstream::stderr()))
287 }
288 };
289
290 let wait_result = match opts.timeout {
291 Some(timeout) => {
292 tokio::select! {
293 res = child.wait() => Some(res),
294 () = tokio::time::sleep(timeout) => None,
295 }
296 }
297 None => Some(child.wait().await),
298 };
299
300 let cancelled = wait_result.is_none();
301 let final_status = match wait_result {
302 Some(res) => res,
303 None => {
304 let _ = child.kill().await;
305 child.wait().await
306 }
307 };
308
309 let _ = stdout_task.await;
310 let _ = stderr_task.await;
311 let _ = renderer_task.await;
312
313 if cancelled {
314 return Err(ExecError::Cancelled);
315 }
316
317 match final_status {
318 Ok(status) => {
319 if status.success() {
320 Ok(())
321 } else {
322 Err(ExecError::CommandFailed(status.code().unwrap_or(-1)))
323 }
324 }
325 Err(error) => Err(ExecError::RunFailed(error.to_string())),
326 }
327}
328
329pub fn exec_streamed_command(
331 opts: Option<ExecStreamedOpts>,
332 name: &str,
333 args: &[String],
334) -> Result<(), ExecError> {
335 runtime().block_on(exec_streamed_command_async(
336 opts.unwrap_or_default(),
337 name,
338 args,
339 ))
340}
341
342pub fn truncate_line(line: &str, max_width: usize) -> String {
344 line.chars().take(max_width).collect()
345}
346
347pub fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
349 if max_width == 0 {
350 return vec![line.to_string()];
351 }
352
353 let chars: Vec<char> = line.chars().collect();
354 if chars.len() <= max_width {
355 return vec![line.to_string()];
356 }
357
358 chars
359 .chunks(max_width)
360 .map(|chunk| chunk.iter().collect())
361 .collect()
362}
363
364pub fn is_stderr_tty() -> bool {
366 use std::io::IsTerminal;
367 std::io::stderr().is_terminal()
368}
369
370pub fn write_prefixed_lines_filtered(
379 tui: Option<&crate::logfilter::Tui>,
380 stdout: &[String],
381 stderr: &[String],
382 styled_prefix: &str,
383 max_line_width: usize,
384) {
385 let matches = |line: &str| tui.is_none_or(|tui| tui.matches(line));
386
387 for line in stdout {
388 if !matches(line) {
389 continue;
390 }
391 for wrapped in wrap_line(line, max_line_width) {
392 tracing::debug!("{styled_prefix} {wrapped}");
393 }
394 }
395
396 for line in stderr {
397 if !matches(line) {
398 continue;
399 }
400 for wrapped in wrap_line(line, max_line_width) {
401 tracing::debug!("{styled_prefix} {wrapped}");
402 }
403 }
404}
405
406#[derive(Default)]
417pub struct AreaPrinter {
418 pub buffer: String,
419 rendered_lines: usize,
423}
424
425impl AreaPrinter {
426 pub fn redraw(&mut self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
431 let lines: Vec<&str> = self.buffer.lines().collect();
432
433 if self.rendered_lines > 0 {
434 write!(writer, "\x1b[{}A", self.rendered_lines)?;
435 }
436
437 let header_style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
438 let content_style = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
439
440 for (i, line) in lines.iter().enumerate() {
441 let style = if i == 0 { header_style } else { content_style };
442 write!(writer, "\r\x1b[2K{style}{line}{style:#}\n")?;
443 }
444
445 self.rendered_lines = lines.len();
446 writer.flush()
447 }
448
449 pub fn finish(&mut self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
453 self.rendered_lines = 0;
454 writeln!(writer)
455 }
456}
457
458pub fn flush_lines_to_area(
460 cmd_line: &str,
461 display_lines: &[String],
462 max_line_width: usize,
463 area: &mut AreaPrinter,
464) {
465 if display_lines.is_empty() {
466 return;
467 }
468
469 let header = truncate_line(cmd_line, max_line_width);
470 let mut content = header;
471 for line in display_lines {
472 content.push('\n');
473 content.push_str(&truncate_line(line, max_line_width));
474 }
475 area.buffer = content;
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn exec_streamed_command_success() {
484 let err = exec_streamed_command(None, "echo", &["hello world".into()]);
485 assert!(err.is_ok());
486 }
487
488 #[test]
489 fn exec_streamed_command_failure() {
490 let err = exec_streamed_command(None, "sh", &["-c".into(), "exit 1".into()]).unwrap_err();
491 assert!(matches!(err, ExecError::CommandFailed(1)));
492 }
493
494 #[test]
495 fn exec_streamed_command_large_output() {
496 let err = exec_streamed_command(None, "seq", &["1".into(), "100".into()]);
497 assert!(err.is_ok());
498 }
499
500 #[test]
501 fn exec_streamed_command_very_large_output() {
502 let err = exec_streamed_command(None, "seq", &["1".into(), "1000".into()]);
503 assert!(err.is_ok());
504 }
505
506 #[test]
515 fn exec_streamed_command_context_cancellation() {
516 let err = exec_streamed_command(
521 Some(ExecStreamedOpts {
522 timeout: Some(Duration::from_millis(100)),
523 ..Default::default()
524 }),
525 "sleep",
526 &["10".into()],
527 );
528 assert!(err.is_err(), "expected error after cancellation");
529 }
530
531 #[test]
532 fn exec_streamed_command_context_timeout() {
533 let err = exec_streamed_command(
535 Some(ExecStreamedOpts {
536 timeout: Some(Duration::from_millis(200)),
537 ..Default::default()
538 }),
539 "sleep",
540 &["10".into()],
541 )
542 .unwrap_err();
543 assert!(matches!(err, ExecError::Cancelled));
544 }
545
546 #[test]
547 fn exec_streamed_command_mixed_output() {
548 let err = exec_streamed_command(
549 None,
550 "sh",
551 &["-c".into(), "echo stdout; echo stderr >&2".into()],
552 );
553 assert!(err.is_ok());
554 }
555
556 #[test]
557 fn exec_streamed_command_command_not_found() {
558 let err = exec_streamed_command(None, "nonexistent-command-xyz", &[]);
559 assert!(err.is_err(), "expected error for nonexistent command");
560 }
561
562 #[test]
563 fn exec_streamed_command_empty_output() {
564 let err = exec_streamed_command(None, "true", &[]);
565 assert!(err.is_ok());
566 }
567
568 #[test]
569 fn exec_streamed_command_long_running_command() {
570 let err = exec_streamed_command(
571 None,
572 "sh",
573 &[
574 "-c".into(),
575 "for i in $(seq 1 20); do echo $i; sleep 0.01; done".into(),
576 ],
577 );
578 assert!(err.is_ok());
579 }
580
581 #[test]
582 fn exec_streamed_command_long_lines() {
583 let long_string = "a".repeat(1 << 10);
584 let err = exec_streamed_command(None, "echo", &[long_string]);
585 assert!(err.is_ok());
586 }
587
588 #[test]
589 fn truncate_line_cases() {
590 let cases: &[(&str, &str, usize, &str)] = &[
591 ("line shorter than max", "hello", 10, "hello"),
592 ("line equal to max", "hello", 5, "hello"),
593 ("line longer than max", "hello world", 5, "hello"),
594 ("empty line", "", 10, ""),
595 (
596 "very long line",
597 "this is a very long line that should be truncated",
598 20,
599 "this is a very long ",
600 ),
601 ("emoji truncation", "hello ๐ world ๐", 10, "hello ๐ wo"),
602 (
603 "multi-byte unicode characters",
604 "ๆฅๆฌ่ชใฎใใญในใ",
605 5,
606 "ๆฅๆฌ่ชใฎใ",
607 ),
608 ("mixed ascii and unicode", "test ๆต่ฏ ัะตัั", 8, "test ๆต่ฏ "),
609 ("truncate at emoji boundary", "abc๐def", 4, "abc๐"),
610 ];
611
612 for (name, line, max_width, want) in cases {
613 assert_eq!(truncate_line(line, *max_width), *want, "{name}");
614 }
615 }
616
617 #[test]
618 fn flush_lines_to_area_long_cmd_line() {
619 let mut area = AreaPrinter::default();
620
621 let long_cmd = "a".repeat(200);
622 let max_width = 80;
623
624 flush_lines_to_area(
625 &long_cmd,
626 &["output line".to_string()],
627 max_width,
628 &mut area,
629 );
630
631 for line in area.buffer.lines() {
632 assert!(
633 line.chars().count() <= max_width,
634 "area header line exceeds max_width: {line:?}"
635 );
636 }
637 }
638
639 #[test]
645 fn area_printer_redraw_first_call_has_no_cursor_up() {
646 let mut area = AreaPrinter {
647 buffer: "header\nline one\nline two".to_string(),
648 ..Default::default()
649 };
650
651 let mut out = Vec::new();
652 area.redraw(&mut out).unwrap();
653 let rendered = String::from_utf8(out).unwrap();
654
655 assert!(
656 !rendered.starts_with("\x1b["),
657 "first redraw should not move the cursor: {rendered:?}"
658 );
659 assert!(rendered.contains("header"));
660 assert!(rendered.contains("line one"));
661 assert!(rendered.contains("line two"));
662 assert_eq!(area.rendered_lines, 3);
663 }
664
665 #[test]
669 fn area_printer_redraw_second_call_moves_cursor_up_by_previous_line_count() {
670 let mut area = AreaPrinter {
671 buffer: "header\nline one".to_string(),
672 ..Default::default()
673 };
674 area.redraw(&mut Vec::new()).unwrap();
675 assert_eq!(area.rendered_lines, 2);
676
677 area.buffer = "header\nline one\nline two".to_string();
678 let mut out = Vec::new();
679 area.redraw(&mut out).unwrap();
680 let rendered = String::from_utf8(out).unwrap();
681
682 assert!(
683 rendered.starts_with("\x1b[2A"),
684 "should move up by the previous render's 2 lines: {rendered:?}"
685 );
686 assert_eq!(area.rendered_lines, 3);
687 }
688
689 #[test]
695 fn area_printer_finish_resets_and_writes_trailing_newline() {
696 let mut area = AreaPrinter {
697 buffer: "header\nline one".to_string(),
698 ..Default::default()
699 };
700 area.redraw(&mut Vec::new()).unwrap();
701 assert_eq!(area.rendered_lines, 2);
702
703 let mut out = Vec::new();
704 area.finish(&mut out).unwrap();
705
706 assert_eq!(area.rendered_lines, 0);
707 assert_eq!(out, b"\n");
708 }
709
710 #[test]
714 fn quote_command_cases() {
715 let cases: &[(&str, &[&str], &str)] = &[
716 (
717 "plain args need no quoting",
718 &["helm", "install"],
719 "helm install",
720 ),
721 (
722 "an argument with a space is quoted",
723 &["echo", "hello world"],
724 "echo 'hello world'",
725 ),
726 ("an empty argument is quoted", &["echo", ""], "echo ''"),
727 (
728 "an embedded single quote is escaped",
729 &["echo", "it's"],
730 "echo 'it'\\''s'",
731 ),
732 (
733 "a shell metacharacter is quoted",
734 &["sh", "-c", "a && b"],
735 "sh -c 'a && b'",
736 ),
737 ];
738
739 for (name, parts, want) in cases {
740 assert_eq!(quote_command(parts), *want, "{name}");
741 }
742 }
743
744 #[test]
748 fn format_prefix_pads_and_truncates() {
749 assert_eq!(strip_ansi(&format_prefix("gitaly")), "gitaly ");
750 assert_eq!(
751 strip_ansi(&format_prefix("a-very-long-process-name")),
752 "a-very-l"
753 );
754 }
755
756 fn strip_ansi(s: &str) -> String {
759 let mut result = String::new();
760 let mut chars = s.chars();
761 while let Some(c) = chars.next() {
762 if c == '\x1b' {
763 for esc_c in chars.by_ref() {
764 if esc_c == 'm' {
765 break;
766 }
767 }
768 continue;
769 }
770 result.push(c);
771 }
772 result
773 }
774
775 #[test]
776 fn is_stderr_tty_does_not_panic() {
777 let _ = is_stderr_tty();
778 }
779
780 #[test]
784 fn choose_streamer_cases() {
785 let scrolling = ExecStreamedOpts {
786 use_scrolling_area: true,
787 ..Default::default()
788 };
789 let not_scrolling = ExecStreamedOpts {
790 use_scrolling_area: false,
791 ..Default::default()
792 };
793
794 assert_eq!(
795 choose_streamer(true, &scrolling),
796 Streamer::LimitedArea,
797 "TTY + scrolling area"
798 );
799 assert_eq!(
800 choose_streamer(true, ¬_scrolling),
801 Streamer::Prefixed,
802 "TTY without scrolling area"
803 );
804 assert_eq!(
805 choose_streamer(false, &scrolling),
806 Streamer::Log,
807 "no TTY always falls back to the logger, regardless of the option"
808 );
809 assert_eq!(
810 choose_streamer(false, ¬_scrolling),
811 Streamer::Log,
812 "no TTY, no scrolling area"
813 );
814 }
815
816 #[test]
820 fn run_prefixed_streamer_prints_header_then_prefixed_lines() {
821 let (tx, rx) = mpsc::unbounded_channel();
822 tx.send(StreamLine {
823 source: StreamSource::Stdout,
824 text: "building...".to_string(),
825 })
826 .unwrap();
827 tx.send(StreamLine {
828 source: StreamSource::Stderr,
829 text: "a warning".to_string(),
830 })
831 .unwrap();
832 drop(tx);
833
834 let mut out = Vec::new();
835 runtime().block_on(run_prefixed_streamer(
836 rx,
837 "helm",
838 "helm install app",
839 &mut out,
840 ));
841 let rendered = strip_ansi(&String::from_utf8(out).unwrap());
842
843 let lines: Vec<&str> = rendered.lines().collect();
844 assert_eq!(lines[0], "helm install app", "header line");
845 assert_eq!(lines[1], "helm building...");
846 assert_eq!(lines[2], "helm a warning");
847 }
848
849 #[test]
852 fn run_limited_area_streamer_keeps_only_the_last_five_lines() {
853 let (tx, rx) = mpsc::unbounded_channel();
854 for i in 1..=8 {
855 tx.send(StreamLine {
856 source: StreamSource::Stdout,
857 text: format!("line {i}"),
858 })
859 .unwrap();
860 }
861 drop(tx);
862
863 let mut out = Vec::new();
864 runtime().block_on(run_limited_area_streamer(
865 rx,
866 "a-long-running-command".to_string(),
867 &mut out,
868 ));
869 let rendered = strip_ansi(&String::from_utf8(out).unwrap());
870
871 assert!(rendered.contains("line 8"));
874 assert!(rendered.contains("line 4"));
875 assert!(!rendered.contains("line 3"));
876 assert!(!rendered.contains("line 1"));
877 }
878
879 #[test]
880 fn exec_streamed_command_rapid_output() {
881 let err = exec_streamed_command(None, "seq", &["1".into(), "100000".into()]);
882 assert!(err.is_ok());
883 }
884
885 #[test]
886 fn exec_streamed_command_binary_output() {
887 let err = exec_streamed_command(
888 None,
889 "sh",
890 &["-c".into(), r"printf '\x00\x01\x02\x03\xff\xfe\xfd'".into()],
891 );
892 assert!(err.is_ok());
893 }
894
895 #[test]
896 fn exec_streamed_command_unicode_output() {
897 let err = exec_streamed_command(
898 None,
899 "sh",
900 &["-c".into(), "echo 'โ Success ๐ ๆฅๆฌ่ช ๐'".into()],
901 );
902 assert!(err.is_ok());
903 }
904
905 #[test]
906 fn exec_streamed_command_with_opts_success() {
907 let err = exec_streamed_command(
908 Some(ExecStreamedOpts {
909 process_descriptive_name: Some("test-process".into()),
910 use_scrolling_area: false,
911 ..Default::default()
912 }),
913 "echo",
914 &["hello world".into()],
915 );
916 assert!(err.is_ok());
917 }
918
919 #[test]
920 fn exec_streamed_command_with_opts_failure() {
921 let err = exec_streamed_command(
922 Some(ExecStreamedOpts {
923 process_descriptive_name: Some("test-process".into()),
924 use_scrolling_area: false,
925 ..Default::default()
926 }),
927 "sh",
928 &["-c".into(), "exit 1".into()],
929 )
930 .unwrap_err();
931 assert!(matches!(err, ExecError::CommandFailed(1)));
932 }
933
934 #[test]
935 fn exec_streamed_command_with_opts_context_cancellation() {
936 let err = exec_streamed_command(
937 Some(ExecStreamedOpts {
938 process_descriptive_name: Some("long-process".into()),
939 use_scrolling_area: false,
940 timeout: Some(Duration::from_millis(100)),
941 ..Default::default()
942 }),
943 "sleep",
944 &["10".into()],
945 );
946 assert!(err.is_err(), "expected error after cancellation");
947 }
948
949 #[test]
951 fn exec_streamed_command_multiple_processes() {
952 for i in 1..=3 {
953 let err = exec_streamed_command(
954 Some(ExecStreamedOpts {
955 process_descriptive_name: Some(format!("process-{i}")),
956 use_scrolling_area: false,
957 ..Default::default()
958 }),
959 "echo",
960 &[format!("process {i} output")],
961 );
962 assert!(err.is_ok());
963 }
964 }
965
966 #[test]
967 fn exec_streamed_command_process_identification() {
968 let err = exec_streamed_command(
969 Some(ExecStreamedOpts {
970 process_descriptive_name: Some("gitaly-process".into()),
971 use_scrolling_area: false,
972 ..Default::default()
973 }),
974 "echo",
975 &["gitaly output".into()],
976 );
977 assert!(err.is_ok());
978 }
979
980 #[test]
981 fn exec_streamed_command_working_directory() {
982 let tmp_dir = std::env::temp_dir();
983
984 let err = exec_streamed_command(
985 Some(ExecStreamedOpts {
986 working_dir: Some(tmp_dir.to_string_lossy().into_owned()),
987 process_descriptive_name: Some("test-process".into()),
988 use_scrolling_area: false,
989 ..Default::default()
990 }),
991 "pwd",
992 &[],
993 );
994 assert!(err.is_ok());
995 }
996
997 #[test]
998 fn exec_streamed_command_empty_dir() {
999 let err = exec_streamed_command(
1000 Some(ExecStreamedOpts {
1001 working_dir: Some(String::new()),
1002 process_descriptive_name: Some("test-process".into()),
1003 use_scrolling_area: false,
1004 ..Default::default()
1005 }),
1006 "echo",
1007 &["test".into()],
1008 );
1009 assert!(err.is_ok());
1010 }
1011
1012 #[test]
1013 fn exec_streamed_command_nonexistent_dir() {
1014 let err = exec_streamed_command(
1015 Some(ExecStreamedOpts {
1016 working_dir: Some("/nonexistent/directory/path".into()),
1017 process_descriptive_name: Some("test-process".into()),
1018 use_scrolling_area: false,
1019 ..Default::default()
1020 }),
1021 "echo",
1022 &["test".into()],
1023 );
1024 assert!(err.is_err());
1025 }
1026
1027 #[test]
1028 fn write_prefixed_lines_filtered_nil_tui_does_not_panic() {
1029 write_prefixed_lines_filtered(None, &["[vite] some log".to_string()], &[], "prefix", 80);
1030 }
1031}