1#![allow(dead_code)]
9
10use std::{cell::RefCell, collections::VecDeque, sync::mpsc, thread, time::Duration};
11
12use super::{
13 CommandResult, Shell, ShellConfig, ShellError,
14 exec::{Line, render_overlay_lines}
15};
16use crate::output::{Output, OutputMode};
17
18enum ScriptEvent {
23 Out(String),
24 Err(String),
25 Delay(u64)
26}
27
28pub struct Script {
41 events: Vec<ScriptEvent>,
42 success: bool
43}
44
45impl Default for Script {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl Script {
52 pub fn new() -> Self {
54 Self { events: Vec::new(), success: true }
55 }
56
57 pub fn out(mut self, text: &str) -> Self {
59 self.events.push(ScriptEvent::Out(text.to_string()));
60 self
61 }
62
63 pub fn out_ms(self, text: &str, ms: u64) -> Self {
65 self.out(text).delay_ms(ms)
66 }
67
68 pub fn out_line(self, text: &str) -> Self {
70 self.out(text).out("\n")
71 }
72
73 pub fn out_cr(mut self, text: &str) -> Self {
75 self.events.push(ScriptEvent::Out(format!("{text}\r")));
76 self
77 }
78
79 pub fn out_line_ms(self, text: &str, ms: u64) -> Self {
81 self.out_line(text).delay_ms(ms)
82 }
83
84 pub fn out_cr_ms(self, text: &str, ms: u64) -> Self {
86 self.out_cr(text).delay_ms(ms)
87 }
88
89 pub fn err(mut self, text: &str) -> Self {
91 self.events.push(ScriptEvent::Err(text.to_string()));
92 self
93 }
94
95 pub fn err_ms(self, text: &str, ms: u64) -> Self {
97 self.err(text).delay_ms(ms)
98 }
99
100 pub fn err_line(self, text: &str) -> Self {
102 self.err(text).err("\n")
103 }
104
105 pub fn err_line_ms(self, text: &str, ms: u64) -> Self {
107 self.err_line(text).delay_ms(ms)
108 }
109
110 pub fn delay_ms(mut self, ms: u64) -> Self {
112 self.events.push(ScriptEvent::Delay(ms));
113 self
114 }
115
116 pub fn exit_failure(mut self) -> Self {
118 self.success = false;
119 self
120 }
121}
122
123pub struct ScriptedShell {
138 scripts: RefCell<VecDeque<Script>>,
139 config: ShellConfig
140}
141
142impl Default for ScriptedShell {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl ScriptedShell {
149 pub fn new() -> Self {
151 Self { scripts: RefCell::new(VecDeque::new()), config: ShellConfig::default() }
152 }
153
154 pub fn with_config(mut self, config: ShellConfig) -> Self {
156 self.config = config;
157 self
158 }
159
160 pub fn push(self, script: Script) -> Self {
162 self.scripts.borrow_mut().push_back(script);
163 self
164 }
165}
166
167impl Shell for ScriptedShell {
168 fn run_command(
169 &self,
170 label: &str,
171 _program: &str,
172 _args: &[&str],
173 output: &mut dyn Output,
174 _mode: OutputMode
175 ) -> Result<CommandResult, ShellError> {
176 let script =
177 self.scripts.borrow_mut().pop_front().expect("ScriptedShell: run_command called but script queue is empty");
178
179 let (tx, rx) = mpsc::channel::<Line>();
180 let success = script.success;
181
182 thread::spawn(move || {
183 let mut stdout_buf = String::new();
184 let mut stderr_buf = String::new();
185
186 for event in script.events {
187 match event {
188 ScriptEvent::Out(s) => feed(&s, &mut stdout_buf, false, &tx),
189 ScriptEvent::Err(s) => feed(&s, &mut stderr_buf, true, &tx),
190 ScriptEvent::Delay(ms) => thread::sleep(Duration::from_millis(ms))
191 }
192 }
193
194 if !stdout_buf.is_empty() {
196 let _ = tx.send(Line::Stdout(std::mem::take(&mut stdout_buf)));
197 }
198 if !stderr_buf.is_empty() {
199 let _ = tx.send(Line::Stderr(std::mem::take(&mut stderr_buf)));
200 }
201 });
203
204 let rendered = render_overlay_lines(label, rx, self.config.viewport_size);
205 output.step_result(label, success, rendered.elapsed.as_millis(), &rendered.viewport);
206
207 Ok(CommandResult { success, stderr: rendered.stderr_lines.join("\n") })
208 }
209
210 fn shell_exec(
211 &self,
212 _script: &str,
213 _output: &mut dyn Output,
214 _mode: OutputMode
215 ) -> Result<CommandResult, ShellError> {
216 Ok(CommandResult { success: true, stderr: String::new() })
217 }
218
219 fn command_exists(&self, _program: &str) -> bool {
220 true
221 }
222
223 fn command_output(&self, _program: &str, _args: &[&str]) -> Result<String, ShellError> {
224 Ok(String::new())
225 }
226
227 fn exec_capture(
228 &self,
229 _cmd: &str,
230 _output: &mut dyn Output,
231 _mode: OutputMode
232 ) -> Result<CommandResult, ShellError> {
233 Ok(CommandResult { success: true, stderr: String::new() })
234 }
235
236 fn exec_interactive(&self, _cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<(), ShellError> {
237 Ok(())
238 }
239}
240
241#[cfg(test)]
250mod tests {
251 use std::sync::mpsc;
252
253 use super::{Line, Script, ScriptedShell, feed};
254 use crate::{
255 output::{OutputMode, StringOutput},
256 shell::{Shell, ShellConfig}
257 };
258
259 fn default_mode() -> OutputMode {
264 OutputMode::default()
265 }
266
267 fn collect_lines(rx: mpsc::Receiver<Line>) -> Vec<Line> {
269 rx.into_iter().collect()
270 }
271
272 fn feed_all(input: &str, is_stderr: bool) -> (Vec<Line>, String) {
275 let (tx, rx) = mpsc::channel::<Line>();
276 let mut buf = String::new();
277 feed(input, &mut buf, is_stderr, &tx);
278 drop(tx);
279 let lines = collect_lines(rx);
280 (lines, buf)
281 }
282
283 #[test]
288 fn feed_single_complete_stdout_line() {
289 let (lines, buf) = feed_all("hello\n", false);
290 assert_eq!(lines.len(), 1);
291 assert!(matches!(&lines[0], Line::Stdout(s) if s == "hello"));
292 assert!(buf.is_empty());
293 }
294
295 #[test]
296 fn feed_multiple_newline_lines() {
297 let (lines, buf) = feed_all("a\nb\nc\n", false);
298 assert_eq!(lines.len(), 3);
299 assert!(matches!(&lines[0], Line::Stdout(s) if s == "a"));
300 assert!(matches!(&lines[1], Line::Stdout(s) if s == "b"));
301 assert!(matches!(&lines[2], Line::Stdout(s) if s == "c"));
302 assert!(buf.is_empty());
303 }
304
305 #[test]
306 fn feed_partial_line_stays_in_buffer() {
307 let (lines, buf) = feed_all("partial", false);
308 assert!(lines.is_empty());
309 assert_eq!(buf, "partial");
310 }
311
312 #[test]
313 fn feed_partial_line_flushed_by_subsequent_newline() {
314 let (tx, rx) = mpsc::channel::<Line>();
315 let mut buf = String::new();
316 feed("partial", &mut buf, false, &tx);
317 feed(" line\n", &mut buf, false, &tx);
318 drop(tx);
319 let lines = collect_lines(rx);
320 assert_eq!(lines.len(), 1);
321 assert!(matches!(&lines[0], Line::Stdout(s) if s == "partial line"));
322 assert!(buf.is_empty());
323 }
324
325 #[test]
330 fn feed_cr_produces_stdout_cr() {
331 let (lines, buf) = feed_all("progress\r", false);
332 assert_eq!(lines.len(), 1);
333 assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "progress"));
334 assert!(buf.is_empty());
335 }
336
337 #[test]
338 fn feed_cr_overwrites_accumulate_correctly() {
339 let (lines, buf) = feed_all("10%\r50%\r100%\r", false);
341 assert_eq!(lines.len(), 3);
342 assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "10%"));
343 assert!(matches!(&lines[1], Line::StdoutCr(s) if s == "50%"));
344 assert!(matches!(&lines[2], Line::StdoutCr(s) if s == "100%"));
345 assert!(buf.is_empty());
346 }
347
348 #[test]
349 fn feed_cr_then_newline_sends_cr_then_stdout() {
350 let (lines, buf) = feed_all("10%\r\n", false);
352 assert_eq!(lines.len(), 2);
353 assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "10%"));
354 assert!(matches!(&lines[1], Line::Stdout(s) if s.is_empty()));
355 assert!(buf.is_empty());
356 }
357
358 #[test]
359 fn feed_newline_in_cr_payload_is_preserved() {
360 let (lines, buf) = feed_all("line1\nline2\r", false);
364 assert_eq!(lines.len(), 1);
365 assert!(matches!(&lines[0], Line::StdoutCr(s) if s == "line1\nline2"));
366 assert!(buf.is_empty());
367 }
368
369 #[test]
374 fn feed_stderr_produces_stderr_lines() {
375 let (lines, buf) = feed_all("error msg\n", true);
376 assert_eq!(lines.len(), 1);
377 assert!(matches!(&lines[0], Line::Stderr(s) if s == "error msg"));
378 assert!(buf.is_empty());
379 }
380
381 #[test]
382 fn feed_stderr_cr_treated_as_newline() {
383 let (lines, _buf) = feed_all("err\r", true);
385 assert_eq!(lines.len(), 1);
386 assert!(matches!(&lines[0], Line::Stderr(_)));
387 }
388
389 #[test]
390 fn feed_stderr_partial_stays_in_buffer() {
391 let (lines, buf) = feed_all("partial err", true);
392 assert!(lines.is_empty());
393 assert_eq!(buf, "partial err");
394 }
395
396 #[test]
401 fn feed_empty_input_produces_nothing() {
402 let (lines, buf) = feed_all("", false);
403 assert!(lines.is_empty());
404 assert!(buf.is_empty());
405 }
406
407 #[test]
408 fn feed_only_newline_produces_empty_stdout_line() {
409 let (lines, buf) = feed_all("\n", false);
410 assert_eq!(lines.len(), 1);
411 assert!(matches!(&lines[0], Line::Stdout(s) if s.is_empty()));
412 assert!(buf.is_empty());
413 }
414
415 #[test]
416 fn feed_only_cr_produces_empty_stdout_cr_line() {
417 let (lines, buf) = feed_all("\r", false);
418 assert_eq!(lines.len(), 1);
419 assert!(matches!(&lines[0], Line::StdoutCr(s) if s.is_empty()));
420 assert!(buf.is_empty());
421 }
422
423 #[test]
428 fn scripted_shell_success_result() {
429 let shell = ScriptedShell::new().push(Script::new().out_line("step done"));
430 let mut out = StringOutput::new();
431 let result = shell.run_command("build", "unused", &[], &mut out, default_mode()).unwrap();
432 assert!(result.success);
433 }
434
435 #[test]
436 fn scripted_shell_failure_result() {
437 let shell = ScriptedShell::new().push(Script::new().err_line("something broke").exit_failure());
438 let mut out = StringOutput::new();
439 let result = shell.run_command("deploy", "unused", &[], &mut out, default_mode()).unwrap();
440 assert!(!result.success);
441 }
442
443 #[test]
444 fn scripted_shell_stderr_captured_in_result() {
445 let shell = ScriptedShell::new().push(Script::new().err_line("warn: low disk").exit_failure());
446 let mut out = StringOutput::new();
447 let result = shell.run_command("check", "unused", &[], &mut out, default_mode()).unwrap();
448 assert_eq!(result.stderr, "warn: low disk");
449 }
450
451 #[test]
452 fn scripted_shell_multiple_stderr_lines_joined() {
453 let shell =
454 ScriptedShell::new().push(Script::new().err_line("error: line 1").err_line("error: line 2").exit_failure());
455 let mut out = StringOutput::new();
456 let result = shell.run_command("test", "unused", &[], &mut out, default_mode()).unwrap();
457 assert_eq!(result.stderr, "error: line 1\nerror: line 2");
458 }
459
460 #[test]
461 fn scripted_shell_step_result_written_to_output() {
462 let shell = ScriptedShell::new().push(Script::new().out_line("ok"));
463 let mut out = StringOutput::new();
464 shell.run_command("mytask", "unused", &[], &mut out, default_mode()).unwrap();
465 assert!(out.log().contains("mytask"));
467 assert!(out.log().starts_with('✓'));
468 }
469
470 #[test]
471 fn scripted_shell_failure_step_result_uses_cross() {
472 let shell = ScriptedShell::new().push(Script::new().err_line("bad").exit_failure());
473 let mut out = StringOutput::new();
474 shell.run_command("mytask", "unused", &[], &mut out, default_mode()).unwrap();
475 assert!(out.log().starts_with('✗'));
476 }
477
478 #[test]
479 fn scripted_shell_multiple_scripts_consumed_in_order() {
480 let shell = ScriptedShell::new()
481 .push(Script::new().out_line("first"))
482 .push(Script::new().out_line("second").exit_failure());
483 let mut out = StringOutput::new();
484
485 let r1 = shell.run_command("step1", "unused", &[], &mut out, default_mode()).unwrap();
486 let r2 = shell.run_command("step2", "unused", &[], &mut out, default_mode()).unwrap();
487
488 assert!(r1.success);
489 assert!(!r2.success);
490 }
491
492 #[test]
493 fn scripted_shell_empty_queue_panics() {
494 let shell = ScriptedShell::new();
495 let mut out = StringOutput::new();
496 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
497 shell.run_command("oops", "unused", &[], &mut out, default_mode()).unwrap();
498 }));
499 assert!(result.is_err());
500 }
501
502 #[test]
507 fn scripted_shell_shell_exec_returns_success() {
508 let shell = ScriptedShell::new();
509 let mut out = StringOutput::new();
510 let result = shell.shell_exec("echo hi", &mut out, default_mode()).unwrap();
511 assert!(result.success);
512 }
513
514 #[test]
515 fn scripted_shell_command_exists_returns_true() {
516 let shell = ScriptedShell::new();
517 assert!(shell.command_exists("anything"));
518 }
519
520 #[test]
521 fn scripted_shell_command_output_returns_empty_string() {
522 let shell = ScriptedShell::new();
523 let result = shell.command_output("anything", &["--version"]).unwrap();
524 assert_eq!(result, "");
525 }
526
527 #[test]
528 fn scripted_shell_exec_capture_returns_success() {
529 let shell = ScriptedShell::new();
530 let mut out = StringOutput::new();
531 let result = shell.exec_capture("echo hi", &mut out, default_mode()).unwrap();
532 assert!(result.success);
533 }
534
535 #[test]
536 fn scripted_shell_exec_interactive_returns_ok() {
537 let shell = ScriptedShell::new();
538 let mut out = StringOutput::new();
539 assert!(shell.exec_interactive("echo hi", &mut out, default_mode()).is_ok());
540 }
541
542 #[test]
547 fn scripted_shell_with_config_accepts_custom_viewport() {
548 let config = ShellConfig { viewport_size: 2 };
549 let shell = ScriptedShell::new()
550 .with_config(config)
551 .push(Script::new().out_line("line 1").out_line("line 2").out_line("line 3"));
552 let mut out = StringOutput::new();
553 let result = shell.run_command("task", "unused", &[], &mut out, default_mode()).unwrap();
555 assert!(result.success);
556 }
557}
558
559fn feed(s: &str, buf: &mut String, is_stderr: bool, tx: &mpsc::Sender<Line>) {
574 let mut segments = s.split('\r').peekable();
576
577 while let Some(seg) = segments.next() {
578 let is_last = segments.peek().is_none();
579
580 if is_last {
581 if is_stderr {
584 for ch in seg.chars() {
585 if ch == '\n' {
586 let line = std::mem::take(buf);
587 let _ = tx.send(Line::Stderr(line));
588 } else {
589 buf.push(ch);
590 }
591 }
592 } else {
593 for ch in seg.chars() {
594 if ch == '\n' {
595 let line = std::mem::take(buf);
596 let _ = tx.send(Line::Stdout(line));
597 } else {
598 buf.push(ch);
599 }
600 }
601 }
602 } else {
603 buf.push_str(seg);
607 let line = std::mem::take(buf);
608 if is_stderr {
609 let _ = tx.send(Line::Stderr(line));
611 } else {
612 let _ = tx.send(Line::StdoutCr(line));
613 }
614 }
615 }
616}