Skip to main content

clitest_lib/
script.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    path::Path,
4    process::ExitStatus,
5    sync::{Arc, Mutex, atomic::AtomicBool},
6    thread::ScopedJoinHandle,
7    time::{Duration, Instant},
8};
9
10use grok::Grok;
11use keepcalm::SharedMut;
12use serde::{Serialize, ser::SerializeMap};
13use termcolor::{Color, ColorChoice, WriteColor};
14
15use crate::{
16    command::{CommandLine, CommandResult},
17    util::{NicePathBuf, NiceTempDir},
18};
19use crate::{cwrite, cwriteln, cwriteln_rule};
20use crate::{output::*, util::ShellBit};
21
22const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25pub struct ScriptLocation {
26    pub file: ScriptFile,
27    pub line: usize,
28}
29
30impl ScriptLocation {
31    pub fn new(file: ScriptFile, line: usize) -> Self {
32        Self { file, line }
33    }
34}
35
36impl std::fmt::Display for ScriptLocation {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "{}:{}", self.file, self.line)
39    }
40}
41
42#[derive(
43    derive_more::Debug, derive_more::Display, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash,
44)]
45#[display("{}", file)]
46pub struct ScriptFile {
47    pub file: Arc<NicePathBuf>,
48}
49
50impl ScriptFile {
51    pub fn new(file: impl AsRef<Path>) -> Self {
52        Self {
53            file: Arc::new(NicePathBuf::new(file)),
54        }
55    }
56}
57
58impl<T: AsRef<Path>> From<T> for ScriptFile {
59    fn from(file: T) -> Self {
60        Self::new(file)
61    }
62}
63
64#[derive(Clone, derive_more::Debug, Serialize)]
65pub struct Script {
66    pub commands: Arc<Vec<ScriptBlock>>,
67    pub includes: Arc<HashMap<String, Script>>,
68    pub file: ScriptFile,
69}
70
71#[derive(Debug, Clone, Default)]
72pub struct ScriptRunArgs {
73    pub delay_steps: Option<u64>,
74    pub ignore_exit_codes: bool,
75    pub ignore_matches: bool,
76    pub simplified_output: bool,
77    pub show_line_numbers: bool,
78    pub runner: Option<String>,
79    pub quiet: bool,
80    pub verbose: bool,
81    pub global_timeout: Option<Duration>,
82    pub no_color: bool,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct ScriptEnv {
87    env_vars: HashMap<String, String>,
88}
89
90impl ScriptEnv {
91    pub fn set_defaults(&mut self, pwd: impl AsRef<Path>) {
92        macro_rules! target {
93            ($env:ident, $var:ident, [$($vals:expr),*]) => {
94                $(
95                if cfg!($var = $vals) {
96                    self.env_vars.insert(stringify!($env).to_string(), $vals.to_string());
97                }
98                )*
99            };
100        }
101
102        target!(
103            TARGET_OS,
104            target_os,
105            ["windows", "linux", "macos", "ios", "android"]
106        );
107        target!(TARGET_FAMILY, target_family, ["windows", "unix", "wasm"]);
108        target!(
109            TARGET_ARCH,
110            target_arch,
111            ["x86", "x86_64", "arm", "aarch64"]
112        );
113
114        // Set the current working directory as a special variable "PWD"
115        self.env_vars.insert(
116            "PWD".to_string(),
117            NicePathBuf::from(pwd.as_ref()).env_string(),
118        );
119        // Save the initial PWD as INITIAL_PWD so it can easily be restored
120        self.env_vars
121            .insert("INITIAL_PWD".to_string(), self.env_vars["PWD"].clone());
122    }
123
124    pub fn pwd(&self) -> NicePathBuf {
125        self.env_vars
126            .get("PWD")
127            .cloned()
128            .map(NicePathBuf::from)
129            .unwrap_or_else(NicePathBuf::cwd)
130    }
131
132    pub fn get_env(&self, name: &str) -> Option<&str> {
133        self.env_vars.get(name).map(|s| s.as_str())
134    }
135
136    pub fn set_env(&mut self, name: impl Into<String>, value: impl Into<String>) {
137        let name = name.into();
138        if name == "PWD" {
139            self.set_pwd(value.into());
140        } else {
141            self.env_vars.insert(name, value.into());
142        }
143    }
144
145    pub fn set_pwd(&mut self, pwd: impl Into<NicePathBuf>) {
146        let pwd = pwd.into().env_string();
147        self.env_vars.insert("PWD".to_string(), pwd);
148    }
149
150    pub fn expand(&self, value: &ShellBit) -> Result<String, ScriptRunError> {
151        match value {
152            ShellBit::Literal(s) => Ok(s.clone()),
153            ShellBit::Quoted(s) => self.expand_str(s),
154        }
155    }
156
157    /// Perform shell expansion on a string.
158    pub fn expand_str(&self, value: impl AsRef<str>) -> Result<String, ScriptRunError> {
159        enum State {
160            Normal,
161            EscapeNext,
162            InCurly,
163            Dollar,
164            InDollar,
165        }
166
167        let value = value.as_ref();
168
169        // "\" triggers escaping
170        // ${A} expands to the value of A
171        // $A expands to the value of A (variable ends on first non-alphanumeric character)
172
173        let mut state = State::Normal;
174        let mut variable = String::new();
175        let mut expanded = String::new();
176
177        for c in value.chars() {
178            match state {
179                State::Normal => {
180                    if c == '$' {
181                        state = State::Dollar;
182                        continue;
183                    }
184                    if c == '\\' {
185                        state = State::EscapeNext;
186                        continue;
187                    }
188                    expanded.push(c);
189                }
190                State::EscapeNext => {
191                    expanded.push(c);
192                    state = State::Normal;
193                }
194                State::InCurly => {
195                    if c == '}' {
196                        if let Some(value) = self.get_env(&std::mem::take(&mut variable)) {
197                            expanded.push_str(value);
198                        } else {
199                            return Err(ScriptRunError::ExpansionError(format!(
200                                "undefined variable in ${{...}}: {variable:?} (in {value:?})"
201                            )));
202                        }
203                        state = State::Normal;
204                    } else {
205                        variable.push(c);
206                    }
207                }
208                State::Dollar => {
209                    if c.is_alphanumeric() || c == '_' {
210                        state = State::InDollar;
211                        variable.push(c);
212                    } else if c == '{' {
213                        state = State::InCurly;
214                    } else {
215                        return Err(ScriptRunError::ExpansionError(format!(
216                            "invalid variable: {c:?} (in {value:?})"
217                        )));
218                    }
219                }
220                State::InDollar => {
221                    if c.is_alphanumeric() || c == '_' {
222                        variable.push(c);
223                    } else {
224                        if let Some(value) = self.get_env(&std::mem::take(&mut variable)) {
225                            expanded.push_str(value);
226                        } else {
227                            return Err(ScriptRunError::ExpansionError(format!(
228                                "undefined variable in $...: {variable:?} (in {value:?})"
229                            )));
230                        }
231                        expanded.push(c);
232                        state = State::Normal;
233                    }
234                }
235            }
236        }
237        match state {
238            State::InDollar => {
239                if let Some(value) = self.get_env(&variable) {
240                    expanded.push_str(value);
241                } else {
242                    return Err(ScriptRunError::ExpansionError(format!(
243                        "undefined variable: {variable}"
244                    )));
245                }
246            }
247            State::Dollar => {
248                return Err(ScriptRunError::ExpansionError(
249                    "incomplete variable".to_string(),
250                ));
251            }
252            State::InCurly => {
253                return Err(ScriptRunError::ExpansionError(format!(
254                    "unclosed variable: {variable}"
255                )));
256            }
257            State::Normal => {}
258            State::EscapeNext => {
259                return Err(ScriptRunError::ExpansionError(
260                    "unclosed backslash".to_string(),
261                ));
262            }
263        }
264        Ok(expanded)
265    }
266
267    pub fn env_vars(&self) -> &HashMap<String, String> {
268        &self.env_vars
269    }
270}
271
272#[derive(derive_more::Debug, Clone)]
273pub struct ScriptOutput {
274    #[debug(skip)]
275    stream: SharedMut<Box<dyn WriteColorAny>>,
276}
277
278trait WriteColorAny: WriteColor + Send + Sync + std::any::Any + 'static + std::fmt::Debug {
279    /// Workaround for lack of upcasting
280    fn take_buffer(self: Box<Self>) -> Result<termcolor::Buffer, String>;
281    fn clone_buffer(&self) -> Result<termcolor::Buffer, String>;
282}
283
284impl WriteColorAny for termcolor::StandardStream {
285    fn take_buffer(self: Box<Self>) -> Result<termcolor::Buffer, String> {
286        Err("not a buffer".to_string())
287    }
288    fn clone_buffer(&self) -> Result<termcolor::Buffer, String> {
289        Err("not a buffer".to_string())
290    }
291}
292
293impl WriteColorAny for termcolor::Buffer {
294    fn take_buffer(self: Box<Self>) -> Result<termcolor::Buffer, String> {
295        Ok(*self)
296    }
297    fn clone_buffer(&self) -> Result<termcolor::Buffer, String> {
298        Ok(self.clone())
299    }
300}
301
302impl ScriptOutput {
303    pub fn no_color() -> Self {
304        let stm = termcolor::StandardStream::stdout(ColorChoice::Never);
305        Self {
306            stream: SharedMut::new(Box::new(stm) as _),
307        }
308    }
309
310    pub fn quiet(no_color: bool) -> Self {
311        let stm = if no_color {
312            termcolor::Buffer::no_color()
313        } else {
314            termcolor::Buffer::ansi()
315        };
316        Self {
317            stream: SharedMut::new(Box::new(stm) as _),
318        }
319    }
320
321    pub fn take_buffer(self) -> String {
322        let stream = match SharedMut::try_unwrap(self.stream) {
323            Ok(stream) => stream.take_buffer().expect("wrong stream type"),
324            Err(shared) => shared.read().clone_buffer().expect("wrong stream type"),
325        };
326        String::from_utf8_lossy(&stream.into_inner()).to_string()
327    }
328}
329
330impl Default for ScriptOutput {
331    fn default() -> Self {
332        let stm = termcolor::StandardStream::stdout(ColorChoice::Auto);
333        Self {
334            stream: SharedMut::new(Box::new(stm) as _),
335        }
336    }
337}
338
339impl std::io::Write for ScriptOutputLock<'_> {
340    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
341        self.stream.write(buf)
342    }
343    fn flush(&mut self) -> std::io::Result<()> {
344        self.stream.flush()
345    }
346}
347
348impl termcolor::WriteColor for ScriptOutputLock<'_> {
349    fn supports_color(&self) -> bool {
350        self.stream.supports_color()
351    }
352    fn set_color(&mut self, spec: &termcolor::ColorSpec) -> std::io::Result<()> {
353        self.stream.set_color(spec)
354    }
355    fn reset(&mut self) -> std::io::Result<()> {
356        self.stream.reset()
357    }
358    fn is_synchronous(&self) -> bool {
359        self.stream.is_synchronous()
360    }
361    fn set_hyperlink(&mut self, _link: &termcolor::HyperlinkSpec) -> std::io::Result<()> {
362        self.stream.set_hyperlink(_link)
363    }
364    fn supports_hyperlinks(&self) -> bool {
365        self.stream.supports_hyperlinks()
366    }
367}
368
369struct ScriptOutputLock<'a> {
370    stream: keepcalm::SharedWriteLock<'a, Box<dyn WriteColorAny>>,
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374enum ScriptMode {
375    Normal,
376    Deferred,
377    Background,
378}
379
380#[derive(derive_more::Debug)]
381pub struct ScriptRunContext {
382    pub args: ScriptRunArgs,
383    pub grok: Grok,
384    timeout: Duration,
385    env: ScriptEnv,
386    includes: Arc<HashMap<String, Script>>,
387    background: ScriptMode,
388    #[debug(skip)]
389    kill: ScriptKillReceiver,
390    #[debug(skip)]
391    kill_sender: ScriptKillSender,
392    output: ScriptOutput,
393
394    global_ignore: OutputPatterns,
395    global_reject: OutputPatterns,
396}
397
398impl Default for ScriptRunContext {
399    fn default() -> Self {
400        let kill = Arc::new(AtomicBool::new(false));
401        Self {
402            args: ScriptRunArgs::default(),
403            grok: Grok::with_default_patterns(),
404            timeout: DEFAULT_TIMEOUT,
405            env: ScriptEnv::default(),
406            background: ScriptMode::Normal,
407            includes: Arc::new(HashMap::new()),
408            kill: ScriptKillReceiver::new(kill.clone()),
409            kill_sender: ScriptKillSender::new(kill.clone()),
410            output: ScriptOutput::default(),
411            global_ignore: OutputPatterns::default(),
412            global_reject: OutputPatterns::default(),
413        }
414    }
415}
416
417impl ScriptRunContext {
418    pub fn new_background(&self) -> Self {
419        let kill = Arc::new(AtomicBool::new(false));
420        Self {
421            args: self.args.clone(),
422            grok: self.grok.clone(),
423            // Background processes are not subject to timeouts
424            timeout: Duration::MAX,
425            env: self.env.clone(),
426            background: ScriptMode::Background,
427            kill: ScriptKillReceiver::new(kill.clone()),
428            kill_sender: ScriptKillSender::new(kill.clone()),
429            includes: self.includes.clone(),
430            output: if self.args.verbose {
431                self.output.clone()
432            } else {
433                ScriptOutput::quiet(self.args.no_color)
434            },
435            global_ignore: self.global_ignore.clone(),
436            global_reject: self.global_reject.clone(),
437        }
438    }
439
440    pub fn new_deferred(&self) -> Self {
441        Self {
442            args: self.args.clone(),
443            grok: self.grok.clone(),
444            timeout: self.timeout,
445            env: self.env.clone(),
446            background: ScriptMode::Deferred,
447            kill: self.kill.clone(),
448            kill_sender: self.kill_sender.clone(),
449            includes: self.includes.clone(),
450            output: self.output.clone(),
451            global_ignore: self.global_ignore.clone(),
452            global_reject: self.global_reject.clone(),
453        }
454    }
455
456    pub fn pwd(&self) -> NicePathBuf {
457        self.env.pwd()
458    }
459
460    pub fn get_env(&self, name: &str) -> Option<&str> {
461        self.env.get_env(name)
462    }
463
464    pub fn set_env(&mut self, name: impl Into<String>, value: impl Into<String>) {
465        self.env.set_env(name, value);
466    }
467
468    pub fn set_pwd(&mut self, pwd: impl Into<NicePathBuf>) {
469        self.env.set_pwd(pwd);
470    }
471
472    pub fn take_output(self) -> String {
473        self.output.take_buffer()
474    }
475
476    fn expand(&self, value: &ShellBit) -> Result<String, ScriptRunError> {
477        self.env.expand(value)
478    }
479
480    /// Get a mutable reference to the output stream.
481    pub fn stream(&self) -> impl termcolor::WriteColor + use<'_> {
482        ScriptOutputLock {
483            stream: self.output.stream.write(),
484        }
485    }
486}
487
488#[derive(Clone)]
489pub struct ScriptKillReceiver {
490    kill_receiver: Arc<AtomicBool>,
491}
492
493impl ScriptKillReceiver {
494    pub fn new(kill_receiver: Arc<AtomicBool>) -> Self {
495        Self { kill_receiver }
496    }
497
498    pub fn is_killed(&self) -> bool {
499        self.kill_receiver.load(std::sync::atomic::Ordering::SeqCst)
500    }
501
502    pub fn run_with<T>(&self, kill: impl FnOnce() + Send, wait: impl FnOnce() -> T) -> T {
503        std::thread::scope(|s| {
504            let done = Arc::new(AtomicBool::new(false));
505            let done_clone = done.clone();
506            let t = s.spawn(move || {
507                while !done_clone.load(std::sync::atomic::Ordering::SeqCst) {
508                    if self.is_killed() {
509                        kill();
510                        break;
511                    }
512                    std::thread::sleep(Duration::from_millis(10));
513                }
514            });
515            let res = wait();
516            done.store(true, std::sync::atomic::Ordering::SeqCst);
517            t.join().unwrap();
518            res
519        })
520    }
521
522    #[cfg(windows)]
523    pub fn run_cmd(
524        &self,
525        output: std::process::Child,
526        warn_time: Duration,
527    ) -> std::io::Result<ExitStatus> {
528        use std::os::windows::io::AsRawHandle;
529        use win32job::Job;
530
531        fn map_job_error(e: win32job::JobError) -> std::io::Error {
532            match e {
533                win32job::JobError::AssignFailed(e) => e,
534                win32job::JobError::CreateFailed(e) => e,
535                win32job::JobError::GetInfoFailed(e) => e,
536                win32job::JobError::SetInfoFailed(e) => e,
537                _ => std::io::Error::new(std::io::ErrorKind::Other, "Unknown error"),
538            }
539        }
540
541        // Create a new Job object
542        let job = Job::create().map_err(map_job_error)?;
543
544        // Configure the job to terminate all child processes when the job is closed
545        let mut info = job.query_extended_limit_info().map_err(map_job_error)?;
546        info.limit_kill_on_job_close();
547        job.set_extended_limit_info(&info).map_err(map_job_error)?;
548        job.assign_process(output.as_raw_handle() as _)?;
549
550        // Resume the main thread for the process
551        let id = output.id();
552        for thread_entry in tlhelp32::Snapshot::new_thread()? {
553            if thread_entry.owner_process_id == id {
554                use windows_sys::Win32::Foundation::CloseHandle;
555                use windows_sys::Win32::System::Threading::*;
556
557                unsafe {
558                    let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, thread_entry.thread_id);
559                    if thread.is_null() {
560                        return Err(std::io::Error::last_os_error().into());
561                    }
562                    ResumeThread(thread);
563                    CloseHandle(thread);
564                }
565            }
566        }
567
568        let job = Mutex::new(Some(job));
569        let output = Mutex::new(output);
570        self.run_with(
571            || {
572                _ = job.lock().unwrap().take();
573                _ = output.lock().unwrap().kill();
574            },
575            || {
576                let start = std::time::Instant::now();
577                let mut warned = false;
578                loop {
579                    let res = output.lock().unwrap().try_wait()?;
580                    if let Some(status) = res {
581                        return Ok::<_, std::io::Error>(status);
582                    }
583                    if start.elapsed() > warn_time {
584                        if !warned {
585                            let child = output.lock().unwrap().id();
586                            eprintln!("Process #{child} taking too long to finish.");
587                            warned = true;
588                        }
589                    }
590                    std::thread::sleep(Duration::from_millis(10));
591                }
592            },
593        )
594    }
595
596    #[cfg(unix)]
597    pub fn run_cmd(
598        &self,
599        output: std::process::Child,
600        warn_time: Duration,
601    ) -> std::io::Result<ExitStatus> {
602        let output = Mutex::new(output);
603        self.run_with(
604            || {
605                use signal_child::{signal, signal::*};
606                let id = output.lock().unwrap().id() as i32;
607                _ = signal(-id, SIGINT);
608                std::thread::sleep(Duration::from_millis(10));
609                _ = signal(-id, SIGTERM);
610            },
611            || {
612                let start = std::time::Instant::now();
613                let mut warned = false;
614                loop {
615                    let res = output.lock().unwrap().try_wait()?;
616                    if let Some(status) = res {
617                        return Ok::<_, std::io::Error>(status);
618                    }
619                    if start.elapsed() > warn_time && !warned {
620                        let child = output.lock().unwrap().id();
621                        eprintln!("Process #{child} taking too long to finish.");
622                        warned = true;
623                    }
624                    std::thread::sleep(Duration::from_millis(10));
625                }
626            },
627        )
628    }
629}
630
631#[derive(Clone)]
632pub struct ScriptKillSender {
633    kill_sender: Arc<AtomicBool>,
634}
635
636impl ScriptKillSender {
637    pub fn new(kill_sender: Arc<AtomicBool>) -> Self {
638        Self { kill_sender }
639    }
640
641    pub fn kill(&self) {
642        self.kill_sender
643            .store(true, std::sync::atomic::Ordering::SeqCst);
644    }
645}
646
647impl ScriptRunContext {
648    pub fn new(args: ScriptRunArgs, script_path: impl AsRef<Path>, output: ScriptOutput) -> Self {
649        let mut env = ScriptEnv::default();
650        env.set_defaults(script_path.as_ref().parent().unwrap());
651
652        let kill = Arc::new(AtomicBool::new(false));
653
654        Self {
655            timeout: args.global_timeout.unwrap_or(DEFAULT_TIMEOUT),
656            args,
657            env,
658            grok: Grok::with_default_patterns(),
659            includes: Arc::new(HashMap::new()),
660            background: ScriptMode::Normal,
661            kill: ScriptKillReceiver::new(kill.clone()),
662            kill_sender: ScriptKillSender::new(kill.clone()),
663            output,
664            global_ignore: OutputPatterns::default(),
665            global_reject: OutputPatterns::default(),
666        }
667    }
668}
669
670#[derive(Clone, Debug, PartialEq, Eq)]
671pub struct ScriptLine {
672    pub location: ScriptLocation,
673    text: String,
674}
675
676impl ScriptLine {
677    pub fn new(file: ScriptFile, line: usize, text: impl AsRef<str>) -> Self {
678        Self {
679            location: ScriptLocation::new(file, line),
680            text: text.as_ref().to_string(),
681        }
682    }
683
684    pub fn parse(file: ScriptFile, text: impl AsRef<str>) -> Vec<Self> {
685        text.as_ref()
686            .lines()
687            .enumerate()
688            .map(|(line, text)| Self {
689                location: ScriptLocation::new(file.clone(), line + 1),
690                text: text.to_string(),
691            })
692            .collect()
693    }
694
695    pub fn starts_with(&self, text: &str) -> bool {
696        self.text.trim().starts_with(text)
697    }
698
699    pub fn first_char(&self) -> Option<char> {
700        self.text.trim().chars().next()
701    }
702
703    pub fn text(&self) -> &str {
704        self.text.trim()
705    }
706
707    pub fn text_untrimmed(&self) -> &str {
708        &self.text
709    }
710
711    pub fn is_empty(&self) -> bool {
712        self.text.trim().is_empty()
713    }
714
715    pub fn strip_prefix(&self, prefix: &str) -> Option<&str> {
716        self.text.strip_prefix(prefix)
717    }
718}
719
720#[derive(Debug, thiserror::Error, derive_more::Display)]
721#[display("{error} at {location}{}", associated_data.as_deref().map_or("".to_string(), |d| format!(": {d}")))]
722pub struct ScriptError {
723    pub error: ScriptErrorType,
724    pub location: ScriptLocation,
725    pub associated_data: Option<String>,
726}
727
728impl ScriptError {
729    pub fn new(error: ScriptErrorType, location: ScriptLocation) -> Self {
730        if std::env::var("PANIC_ON_ERROR").is_ok() {
731            panic!("ScriptError: {error} at {location}");
732        }
733        Self {
734            error,
735            location,
736            associated_data: None,
737        }
738    }
739
740    pub fn new_with_data(
741        error: ScriptErrorType,
742        location: ScriptLocation,
743        associated_data: String,
744    ) -> Self {
745        if std::env::var("PANIC_ON_ERROR").is_ok() {
746            panic!("ScriptError: {error} at {location}: {associated_data}");
747        }
748        Self {
749            error,
750            location,
751            associated_data: Some(associated_data),
752        }
753    }
754}
755
756#[derive(Debug, thiserror::Error, Eq, PartialEq)]
757pub enum ScriptErrorType {
758    #[error("background process not allowed")]
759    BackgroundProcessNotAllowed,
760    #[error("unclosed quote")]
761    UnclosedQuote,
762    #[error("unclosed backslash")]
763    UnclosedBackslash,
764    #[error("illegal shell command format")]
765    IllegalShellCommand,
766    #[error("unsupported redirection")]
767    UnsupportedRedirection,
768    #[error("invalid pattern definition")]
769    InvalidPatternDefinition,
770    #[error("invalid pattern")]
771    InvalidPattern,
772    #[error("invalid meta command")]
773    InvalidMetaCommand,
774    #[error("invalid pattern at global level (only reject or ignore allowed here)")]
775    InvalidGlobalPattern,
776    #[error("invalid block type")]
777    InvalidBlockType,
778    #[error("invalid block arguments")]
779    InvalidBlockArgs,
780    #[error("unsupported command position")]
781    UnsupportedCommandPosition,
782    #[error("invalid trailing pattern after *")]
783    InvalidAnyPattern,
784    #[error("invalid exit status")]
785    InvalidExitStatus,
786    #[error("invalid set variable")]
787    InvalidSetVariable,
788    #[error("invalid version header, expected `#!/usr/bin/env clitest --v0`")]
789    InvalidVersion,
790    #[error("invalid internal command")]
791    InvalidInternalCommand,
792    #[error("missing command lines")]
793    MissingCommandLines,
794    #[error(
795        "block end without matching block start, too many closing braces or braces not properly nested"
796    )]
797    InvalidBlockEnd,
798    #[error("invalid if condition")]
799    InvalidIfCondition,
800    #[error("expected block or semi-colon (did you forget to add ';' at the end of this line?)")]
801    ExpectedBlockOrSemi,
802}
803
804#[derive(Debug, thiserror::Error)]
805pub enum ScriptRunError {
806    #[error("{0}")]
807    Pattern(#[from] OutputPatternMatchFailure),
808    #[error("{0}")]
809    PatternPrepareError(#[from] OutputPatternPrepareError),
810    #[error("{0} at line {1}")]
811    Exit(CommandResult, ScriptLocation),
812    #[error("included file not found: {0}")]
813    IncludedFileNotFound(String),
814    #[error("expected failure, but passed at line {0}")]
815    ExpectedFailure(ScriptLocation),
816    #[error("{0}")]
817    ExpansionError(String),
818    #[error("{0}")]
819    IO(#[from] std::io::Error),
820    #[error("killed")]
821    Killed,
822    #[error("background process took too long to finish")]
823    BackgroundProcessTookTooLong,
824    #[error("retry took too long to finish")]
825    RetryTookTooLong,
826    /// Internal flow control: exit the script
827    #[error("exiting script")]
828    ExitScript,
829}
830
831impl ScriptRunError {
832    #[expect(unused)]
833    pub fn short(&self) -> String {
834        match self {
835            Self::Pattern(_) => "Pattern".to_string(),
836            Self::PatternPrepareError(e) => format!("PatternPrepareError({e:?})"),
837            Self::Exit(status, _) => format!("Exit({status})"),
838            Self::ExpectedFailure(_) => "ExpectedFailure".to_string(),
839            Self::IO(e) => format!("IO({:?})", e.kind()),
840            Self::Killed => "Killed".to_string(),
841            Self::BackgroundProcessTookTooLong => "BackgroundProcessTookTooLong".to_string(),
842            Self::ExpansionError(e) => "ExpansionError".to_string(),
843            Self::RetryTookTooLong => "RetryTookTooLong".to_string(),
844            Self::ExitScript => unreachable!(),
845            Self::IncludedFileNotFound(path) => format!("IncludedFileNotFound({path})"),
846        }
847    }
848}
849
850impl Script {
851    pub fn new(file: ScriptFile) -> Self {
852        Self {
853            commands: Arc::new(vec![]),
854            includes: Arc::new(HashMap::new()),
855            file,
856        }
857    }
858
859    /// Collect all included script paths from the script.
860    pub fn includes(&self) -> Vec<(ScriptLocation, String)> {
861        self.commands
862            .iter()
863            .flat_map(|block| block.includes())
864            .collect()
865    }
866
867    pub fn run(&self, context: &mut ScriptRunContext) -> Result<(), ScriptRunError> {
868        let old_includes = context.includes.clone();
869        context.includes = self.includes.clone();
870        let res = ScriptBlock::run_blocks(context, &self.commands);
871        context.includes = old_includes;
872        let v = match res {
873            Ok(v) => v,
874            // Bypass normal script processing and exit successfully
875            Err(ScriptRunError::ExitScript) => return Ok(()),
876            Err(e) => return Err(e),
877        };
878        assert!(v.is_empty(), "script did not run to completion: {v:?}");
879        Ok(())
880    }
881
882    pub fn run_with_args(
883        &self,
884        args: ScriptRunArgs,
885        output: ScriptOutput,
886    ) -> Result<(), ScriptRunError> {
887        let start = Instant::now();
888        let script_path = &*self.file.file;
889        let mut context = ScriptRunContext::new(args, script_path, output);
890
891        // Write "Running..." message with colors
892        cwrite!(context.stream(), "Running ");
893        cwrite!(context.stream(), fg = Color::Cyan, "{}", script_path);
894        cwriteln!(context.stream(), " ...");
895        cwriteln!(context.stream());
896
897        let result = self.run(&mut context);
898
899        // Handle success and error output
900        if let Err(ref e) = result {
901            cwrite!(context.stream(), fg = Color::Cyan, "{} ", script_path);
902            cwrite!(context.stream(), fg = Color::Red, "FAILED");
903            if !context.args.simplified_output {
904                cwriteln!(context.stream(), " ({:.2}s)", start.elapsed().as_secs_f32());
905            } else {
906                cwriteln!(context.stream());
907            }
908            cwrite!(context.stream(), fg = Color::Red, "Error: ");
909            cwriteln!(context.stream(), "{}", e);
910            cwriteln!(context.stream());
911        } else {
912            cwrite!(context.stream(), fg = Color::Cyan, "{} ", script_path);
913            cwrite!(context.stream(), fg = Color::Green, "PASSED");
914            if !context.args.simplified_output {
915                cwriteln!(context.stream(), " ({:.2}s)", start.elapsed().as_secs_f32());
916            } else {
917                cwriteln!(context.stream());
918            }
919        }
920
921        result
922    }
923}
924
925#[derive(Debug, Default, Serialize)]
926pub enum CommandExit {
927    #[default]
928    Success,
929    Failure(i32),
930    Timeout,
931    Any,
932    AnyFailure,
933}
934
935impl CommandExit {
936    pub fn matches(&self, status: CommandResult) -> bool {
937        match (self, status) {
938            (CommandExit::Success, CommandResult::Exit(status)) => status.success(),
939            (CommandExit::Failure(code), CommandResult::Exit(status)) => {
940                *code == status.code().unwrap_or(-1)
941            }
942            (CommandExit::Timeout, CommandResult::TimedOut) => true,
943            (CommandExit::Any, _) => true,
944            (CommandExit::AnyFailure, CommandResult::Exit(status)) => !status.success(),
945            (CommandExit::AnyFailure, _) => true,
946            _ => false,
947        }
948    }
949
950    pub fn is_success(&self) -> bool {
951        matches!(self, CommandExit::Success)
952    }
953}
954
955#[derive(derive_more::Debug)]
956#[allow(clippy::large_enum_variant)]
957pub enum ScriptBlock {
958    Command(ScriptCommand),
959    InternalCommand(ScriptLocation, InternalCommand),
960    Background(Vec<ScriptBlock>),
961    Defer(Vec<ScriptBlock>),
962    If(IfCondition, Vec<ScriptBlock>),
963    For(ForCondition, Vec<ScriptBlock>),
964    Retry(Vec<ScriptBlock>),
965    GlobalIgnore(OutputPatterns),
966    GlobalReject(OutputPatterns),
967}
968
969impl ScriptBlock {
970    pub fn includes(&self) -> Vec<(ScriptLocation, String)> {
971        match self {
972            ScriptBlock::Command(..) => vec![],
973            ScriptBlock::InternalCommand(location, InternalCommand::Include(path)) => {
974                vec![(location.clone(), path.clone())]
975            }
976            ScriptBlock::InternalCommand(..) => vec![],
977            ScriptBlock::Background(blocks) => blocks.iter().flat_map(|b| b.includes()).collect(),
978            ScriptBlock::Defer(blocks) => blocks.iter().flat_map(|b| b.includes()).collect(),
979            ScriptBlock::If(_, blocks) => blocks.iter().flat_map(|b| b.includes()).collect(),
980            ScriptBlock::For(_, blocks) => blocks.iter().flat_map(|b| b.includes()).collect(),
981            ScriptBlock::Retry(blocks) => blocks.iter().flat_map(|b| b.includes()).collect(),
982            ScriptBlock::GlobalIgnore(_) => vec![],
983            ScriptBlock::GlobalReject(_) => vec![],
984        }
985    }
986
987    #[allow(clippy::type_complexity)]
988    pub fn run_blocks(
989        context: &mut ScriptRunContext,
990        blocks: &[ScriptBlock],
991    ) -> Result<Vec<ScriptResult>, ScriptRunError> {
992        enum Deferred<'a> {
993            Scripts(&'a [ScriptBlock]),
994            Internal(
995                Box<
996                    dyn FnOnce(&mut ScriptRunContext) -> Result<(), ScriptRunError>
997                        + Send
998                        + Sync
999                        + 'a,
1000                >,
1001            ),
1002            Background(
1003                ScopedJoinHandle<'a, Result<Vec<ScriptResult>, ScriptRunError>>,
1004                ScriptKillSender,
1005            ),
1006        }
1007
1008        let mut results = Vec::new();
1009        std::thread::scope(|s| {
1010            let mut defer_blocks = VecDeque::new();
1011            let mut pending_error = None;
1012            for block in blocks {
1013                if context.kill.is_killed() {
1014                    return Err(ScriptRunError::Killed);
1015                }
1016                match block {
1017                    ScriptBlock::Background(blocks) => {
1018                        let mut context = context.new_background();
1019                        let kill_sender = context.kill_sender.clone();
1020                        let handle = s.spawn(move || Self::run_blocks(&mut context, blocks));
1021                        defer_blocks.push_front(Deferred::Background(handle, kill_sender));
1022                    }
1023                    ScriptBlock::Defer(blocks) => {
1024                        // Insert at the front of the queue by extending and
1025                        // then rotating
1026                        defer_blocks.push_front(Deferred::Scripts(blocks));
1027                    }
1028                    ScriptBlock::InternalCommand(_, command) => {
1029                        if context.background == ScriptMode::Deferred {
1030                            cwrite!(context.stream(), dimmed = true, "(deferred) ");
1031                        }
1032                        if let Some(f) = command.run(context)? {
1033                            defer_blocks.push_front(Deferred::Internal(f));
1034                        }
1035                    }
1036                    _ => match block.run(context) {
1037                        Ok(res) => results.extend(res),
1038                        Err(e) => {
1039                            pending_error = Some(e);
1040                            break;
1041                        }
1042                    },
1043                }
1044            }
1045            for block in defer_blocks {
1046                match block {
1047                    Deferred::Scripts(blocks) => {
1048                        let mut context = context.new_deferred();
1049                        ScriptBlock::run_blocks(&mut context, blocks)?;
1050                    }
1051                    Deferred::Internal(block) => {
1052                        cwrite!(context.stream(), dimmed = true, "(cleanup) ");
1053                        block(context)?;
1054                    }
1055                    Deferred::Background(handle, kill_sender) => {
1056                        kill_sender.kill();
1057                        let start = std::time::Instant::now();
1058                        let mut warned = false;
1059
1060                        let timeout = context.timeout;
1061                        let warn_at = timeout * 8 / 10;
1062
1063                        let results = loop {
1064                            if handle.is_finished() {
1065                                break handle.join().unwrap()?;
1066                            }
1067                            std::thread::sleep(std::time::Duration::from_millis(10));
1068                            if !warned && start.elapsed() > warn_at {
1069                                cwriteln!(
1070                                    context.stream(),
1071                                    fg = Color::Yellow,
1072                                    "Background process is taking too long to finish."
1073                                );
1074                                warned = true;
1075                            }
1076                            if start.elapsed() > timeout {
1077                                cwriteln!(
1078                                    context.stream(),
1079                                    fg = Color::Red,
1080                                    "Background process took too long to finish."
1081                                );
1082                                return Err(ScriptRunError::BackgroundProcessTookTooLong);
1083                            }
1084                        };
1085                        for result in results {
1086                            cwrite!(context.stream(), dimmed = true, "(background) ");
1087                            for line in result.command.command.split('\n') {
1088                                cwriteln!(context.stream(), fg = Color::Green, "{}", line);
1089                            }
1090                            if context.args.simplified_output {
1091                                cwriteln!(context.stream(), dimmed = true, "---");
1092                            } else {
1093                                cwriteln_rule!(
1094                                    context.stream(),
1095                                    fg = Color::Cyan,
1096                                    "{}",
1097                                    result.command.location
1098                                );
1099                            }
1100                            for line in &result.output {
1101                                cwriteln!(context.stream(), "{}", line);
1102                            }
1103                            if result.output.is_empty() {
1104                                cwriteln!(context.stream(), dimmed = true, "(no output)");
1105                            }
1106                            if context.args.simplified_output {
1107                                cwriteln!(context.stream(), dimmed = true, "---");
1108                            } else {
1109                                cwriteln_rule!(context.stream());
1110                            }
1111                            result.evaluate(context)?;
1112                        }
1113                    }
1114                }
1115            }
1116            if let Some(error) = pending_error {
1117                return Err(error);
1118            }
1119            Ok(results)
1120        })
1121    }
1122
1123    pub fn run(&self, context: &mut ScriptRunContext) -> Result<Vec<ScriptResult>, ScriptRunError> {
1124        let pwd = context.pwd();
1125        let res = pwd.exists();
1126        if !matches!(res, Ok(true)) {
1127            cwriteln!(
1128                context.stream(),
1129                fg = Color::Red,
1130                "$PWD {pwd:?} doesn't exist. Run `cd $INITIAL_PWD` to fix.",
1131            );
1132            return Err(ScriptRunError::IO(std::io::Error::new(
1133                std::io::ErrorKind::NotFound,
1134                format!("PWD does not exist: {pwd:?}"),
1135            )));
1136        }
1137
1138        match self {
1139            ScriptBlock::Command(command) => {
1140                if context.background == ScriptMode::Deferred {
1141                    cwrite!(context.stream(), dimmed = true, "(deferred) ");
1142                }
1143                let result = command.run(context)?;
1144                if context.background != ScriptMode::Background {
1145                    result.evaluate(context)?;
1146                    Ok(vec![])
1147                } else {
1148                    Ok(vec![result])
1149                }
1150            }
1151            ScriptBlock::If(condition, blocks) => {
1152                let condition = condition.expand(context)?;
1153                if condition.matches(context) {
1154                    Self::run_blocks(context, blocks)
1155                } else {
1156                    Ok(vec![])
1157                }
1158            }
1159            ScriptBlock::For(ForCondition::Env(env, values), blocks) => {
1160                let mut results = Vec::new();
1161                for value in values {
1162                    context.set_env(env, context.expand(value)?);
1163                    results.extend(Self::run_blocks(context, blocks)?);
1164                }
1165                Ok(results)
1166            }
1167            ScriptBlock::Retry(blocks) => {
1168                let start = Instant::now();
1169                let mut backoff = Duration::from_millis(100);
1170
1171                cwrite!(context.stream(), fg = Color::Green, "retry: ");
1172                cwriteln!(context.stream(), "running...");
1173
1174                loop {
1175                    let mut nested_context = context.new_background();
1176                    if let Ok(results) = Self::run_blocks(&mut nested_context, blocks) {
1177                        let mut all_ok = true;
1178                        for result in results {
1179                            if result.evaluate(&mut nested_context).is_err() {
1180                                all_ok = false;
1181                                break;
1182                            }
1183                        }
1184                        if all_ok {
1185                            let output = nested_context.take_output();
1186                            cwrite!(context.stream(), fg = Color::Green, "retry: ");
1187                            cwriteln!(context.stream(), "success");
1188                            cwriteln!(context.stream());
1189                            cwriteln!(context.stream(), "{output}");
1190                            return Ok(vec![]);
1191                        }
1192                    }
1193
1194                    if start.elapsed() > context.timeout {
1195                        let output = nested_context.take_output();
1196                        cwrite!(context.stream(), fg = Color::Green, "retry: ");
1197                        cwriteln!(context.stream(), fg = Color::Red, "timed out");
1198                        cwriteln!(context.stream());
1199                        cwriteln!(context.stream(), "{output}");
1200                        cwriteln_rule!(context.stream());
1201                        return Err(ScriptRunError::RetryTookTooLong);
1202                    }
1203                    std::thread::sleep(backoff);
1204                    backoff *= 2;
1205                }
1206            }
1207            ScriptBlock::GlobalIgnore(patterns) => {
1208                for pattern in patterns.iter() {
1209                    pattern.prepare(&context.grok)?;
1210                }
1211                context.global_ignore.extend(patterns);
1212                Ok(vec![])
1213            }
1214            ScriptBlock::GlobalReject(patterns) => {
1215                for pattern in patterns.iter() {
1216                    pattern.prepare(&context.grok)?;
1217                }
1218                context.global_reject.extend(patterns);
1219                Ok(vec![])
1220            }
1221            _ => unreachable!("Unexpected block type: {self:?}"),
1222        }
1223    }
1224}
1225
1226impl Serialize for ScriptBlock {
1227    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1228    where
1229        S: serde::Serializer,
1230    {
1231        match self {
1232            ScriptBlock::Command(command) => command.serialize(serializer),
1233            ScriptBlock::InternalCommand(_, command) => command.serialize(serializer),
1234            ScriptBlock::Background(blocks) => {
1235                let mut ser = serializer.serialize_map(Some(1))?;
1236                ser.serialize_entry("background", blocks)?;
1237                ser.end()
1238            }
1239            ScriptBlock::Defer(blocks) => {
1240                let mut ser = serializer.serialize_map(Some(1))?;
1241                ser.serialize_entry("defer", blocks)?;
1242                ser.end()
1243            }
1244            ScriptBlock::If(condition, blocks) => {
1245                let mut ser = serializer.serialize_map(Some(2))?;
1246                ser.serialize_entry("if", condition)?;
1247                ser.serialize_entry("blocks", blocks)?;
1248                ser.end()
1249            }
1250            ScriptBlock::For(condition, blocks) => {
1251                let mut ser = serializer.serialize_map(Some(2))?;
1252                ser.serialize_entry("for", condition)?;
1253                ser.serialize_entry("blocks", blocks)?;
1254                ser.end()
1255            }
1256            ScriptBlock::Retry(blocks) => {
1257                let mut ser = serializer.serialize_map(Some(1))?;
1258                ser.serialize_entry("retry", blocks)?;
1259                ser.end()
1260            }
1261            ScriptBlock::GlobalIgnore(patterns) => {
1262                let mut ser = serializer.serialize_map(Some(1))?;
1263                ser.serialize_entry("ignore", patterns)?;
1264                ser.end()
1265            }
1266            ScriptBlock::GlobalReject(patterns) => {
1267                let mut ser = serializer.serialize_map(Some(1))?;
1268                ser.serialize_entry("reject", patterns)?;
1269                ser.end()
1270            }
1271        }
1272    }
1273}
1274
1275#[derive(Debug, Clone, Serialize)]
1276pub enum InternalCommand {
1277    UsingTempdir,
1278    UsingDir(ShellBit, bool),
1279    ChangeDir(ShellBit),
1280    Set(String, ShellBit),
1281    Include(String),
1282    ExitScript,
1283    Pattern(String, String),
1284}
1285
1286impl InternalCommand {
1287    #[allow(clippy::type_complexity)]
1288    pub fn run(
1289        &self,
1290        context: &mut ScriptRunContext,
1291    ) -> Result<
1292        Option<Box<dyn FnOnce(&mut ScriptRunContext) -> Result<(), ScriptRunError> + Send + Sync>>,
1293        ScriptRunError,
1294    > {
1295        match self.clone() {
1296            InternalCommand::Include(path) => {
1297                let Some(script) = context.includes.get(&path) else {
1298                    return Err(ScriptRunError::IncludedFileNotFound(path));
1299                };
1300                script.clone().run(context)?;
1301                Ok(None)
1302            }
1303            InternalCommand::Pattern(name, pattern) => {
1304                context.grok.add_pattern(name, pattern);
1305                Ok(None)
1306            }
1307            InternalCommand::UsingTempdir => {
1308                let current_pwd = context.pwd();
1309                let tempdir = NiceTempDir::new();
1310                cwrite!(context.stream(), fg = Color::Yellow, "using tempdir: ");
1311                cwriteln!(context.stream(), "{}", tempdir);
1312                cwriteln!(context.stream());
1313                context.set_pwd(&tempdir);
1314                let pwd = context.pwd();
1315                if !pwd.exists()? {
1316                    return Err(ScriptRunError::IO(std::io::Error::new(
1317                        std::io::ErrorKind::NotFound,
1318                        format!("newly created tempdir does not exist: {pwd:?}"),
1319                    )));
1320                }
1321                Ok(Some(Box::new(move |context: &mut ScriptRunContext| {
1322                    cwriteln!(
1323                        context.stream(),
1324                        fg = Color::Yellow,
1325                        "removing {} && cd {}",
1326                        tempdir,
1327                        current_pwd
1328                    );
1329                    cwriteln!(context.stream());
1330                    if !tempdir.exists()? {
1331                        cwriteln!(
1332                            context.stream(),
1333                            fg = Color::Red,
1334                            "tempdir does not exist: {tempdir}"
1335                        );
1336                    }
1337                    if let Err(e) = tempdir.remove_dir_all() {
1338                        cwriteln!(
1339                            context.stream(),
1340                            fg = Color::Red,
1341                            "error removing tempdir: {e:?}"
1342                        );
1343                    }
1344                    Ok::<_, ScriptRunError>(())
1345                })))
1346            }
1347            InternalCommand::UsingDir(dir, new) => {
1348                let current_pwd = context.pwd();
1349                let dir = context.expand(&dir)?;
1350                let new_pwd = current_pwd.join(dir);
1351                if new {
1352                    cwrite!(context.stream(), fg = Color::Yellow, "using new dir: ");
1353                } else {
1354                    cwrite!(context.stream(), fg = Color::Yellow, "using dir: ");
1355                }
1356                cwriteln!(context.stream(), "{}", new_pwd);
1357                cwriteln!(context.stream());
1358
1359                if new {
1360                    new_pwd.create_dir_all()?;
1361                } else if !new_pwd.exists()? {
1362                    return Err(ScriptRunError::IO(std::io::Error::new(
1363                        std::io::ErrorKind::NotFound,
1364                        "directory does not exist",
1365                    )));
1366                }
1367                context.set_pwd(&new_pwd);
1368                Ok(Some(Box::new(move |context: &mut ScriptRunContext| {
1369                    if new {
1370                        cwriteln!(
1371                            context.stream(),
1372                            fg = Color::Yellow,
1373                            "removing {} && cd {}",
1374                            new_pwd,
1375                            current_pwd
1376                        );
1377                        cwriteln!(context.stream());
1378                    } else {
1379                        cwriteln!(context.stream(), fg = Color::Yellow, "cd {}", current_pwd);
1380                        cwriteln!(context.stream());
1381                    }
1382                    if new {
1383                        new_pwd.remove_dir_all()?;
1384                    }
1385                    context.set_pwd(current_pwd);
1386                    Ok::<_, ScriptRunError>(())
1387                })))
1388            }
1389            InternalCommand::ChangeDir(dir) => {
1390                let dir = context.expand(&dir)?;
1391
1392                cwriteln!(context.stream(), fg = Color::Yellow, "cd {dir}");
1393                cwriteln!(context.stream());
1394                let current_pwd = context.pwd();
1395                let new_pwd = current_pwd.join(dir);
1396                context.set_pwd(new_pwd);
1397                Ok(None)
1398            }
1399            InternalCommand::Set(name, value) => {
1400                let value = context.expand(&value)?;
1401
1402                context.set_env(&name, &value);
1403                let new_value = context.get_env(&name).unwrap_or_default();
1404                if new_value != value {
1405                    cwriteln!(
1406                        context.stream(),
1407                        fg = Color::Yellow,
1408                        "set {name} {value} (-> {new_value})"
1409                    );
1410                } else {
1411                    cwriteln!(context.stream(), fg = Color::Yellow, "set {name} {value}");
1412                }
1413                cwriteln!(context.stream());
1414
1415                Ok(None)
1416            }
1417            InternalCommand::ExitScript => {
1418                cwriteln!(context.stream(), fg = Color::Yellow, "exiting script");
1419                cwriteln!(context.stream());
1420                Err(ScriptRunError::ExitScript)
1421            }
1422        }
1423    }
1424}
1425
1426#[derive(Debug, Clone)]
1427pub enum IfCondition {
1428    True,
1429    False,
1430    EnvEq(bool, String, ShellBit),
1431}
1432
1433impl IfCondition {
1434    pub fn matches(&self, context: &ScriptRunContext) -> bool {
1435        match self {
1436            IfCondition::True => true,
1437            IfCondition::False => false,
1438            IfCondition::EnvEq(negated, name, expected) => {
1439                let value = context.get_env(name).unwrap_or_default();
1440                (expected == value) ^ negated
1441            }
1442        }
1443    }
1444
1445    pub fn expand(&self, context: &ScriptRunContext) -> Result<IfCondition, ScriptRunError> {
1446        match self {
1447            IfCondition::True => Ok(IfCondition::True),
1448            IfCondition::False => Ok(IfCondition::False),
1449            IfCondition::EnvEq(negated, name, expected) => {
1450                let value = context.expand(expected)?;
1451                Ok(IfCondition::EnvEq(
1452                    *negated,
1453                    name.clone(),
1454                    ShellBit::Literal(value),
1455                ))
1456            }
1457        }
1458    }
1459}
1460
1461impl Serialize for IfCondition {
1462    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1463    where
1464        S: serde::Serializer,
1465    {
1466        match self {
1467            IfCondition::True => "true".serialize(serializer),
1468            IfCondition::False => "false".serialize(serializer),
1469            IfCondition::EnvEq(negated, name, value) => {
1470                let mut ser = serializer.serialize_map(Some(3))?;
1471                ser.serialize_entry("op", if *negated { "!=" } else { "==" })?;
1472                ser.serialize_entry("env", name)?;
1473                ser.serialize_entry("value", value)?;
1474                ser.end()
1475            }
1476        }
1477    }
1478}
1479
1480#[derive(Debug)]
1481pub enum ForCondition {
1482    Env(String, Vec<ShellBit>),
1483}
1484
1485impl Serialize for ForCondition {
1486    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1487    where
1488        S: serde::Serializer,
1489    {
1490        match self {
1491            ForCondition::Env(name, values) => {
1492                let mut ser = serializer.serialize_map(Some(2))?;
1493                ser.serialize_entry("env", name)?;
1494                ser.serialize_entry("values", values)?;
1495                ser.end()
1496            }
1497        }
1498    }
1499}
1500
1501fn is_bool_false(b: &bool) -> bool {
1502    !b
1503}
1504
1505#[derive(Debug, Serialize)]
1506pub struct ScriptCommand {
1507    pub command: CommandLine,
1508    pub pattern: OutputPattern,
1509
1510    #[serde(skip_serializing_if = "CommandExit::is_success")]
1511    pub exit: CommandExit,
1512
1513    #[serde(skip_serializing_if = "is_bool_false")]
1514    pub expect_failure: bool,
1515
1516    /// Single set variable (entire command output trimmed)
1517    #[serde(skip_serializing_if = "Option::is_none")]
1518    pub set_var: Option<String>,
1519
1520    /// Specific set variables
1521    pub set_vars: HashMap<String, ShellBit>,
1522
1523    /// Specific command timeout
1524    #[serde(skip_serializing_if = "Option::is_none")]
1525    pub timeout: Option<Duration>,
1526
1527    /// Input grok expectations
1528    pub expect: HashMap<String, ShellBit>,
1529}
1530
1531impl ScriptCommand {
1532    pub fn new(command: CommandLine) -> Self {
1533        let location = command.location.clone();
1534        Self {
1535            command,
1536            pattern: OutputPattern {
1537                pattern: OutputPatternType::None,
1538                ignore: Default::default(),
1539                reject: Default::default(),
1540                location,
1541            },
1542            exit: Default::default(),
1543            timeout: None,
1544            expect_failure: false,
1545            set_var: None,
1546            set_vars: Default::default(),
1547            expect: Default::default(),
1548        }
1549    }
1550
1551    pub fn run(&self, context: &mut ScriptRunContext) -> Result<ScriptResult, ScriptRunError> {
1552        let command = &self.command;
1553        let args = &context.args;
1554        let start = Instant::now();
1555
1556        if let Some(delay) = args.delay_steps {
1557            std::thread::sleep(std::time::Duration::from_millis(delay));
1558        }
1559
1560        for line in command.command.split('\n') {
1561            cwriteln!(context.stream(), fg = Color::Green, "{}", line);
1562        }
1563        if args.simplified_output {
1564            cwriteln!(context.stream(), dimmed = true, "---");
1565        } else {
1566            cwriteln_rule!(context.stream(), fg = Color::Cyan, "{}", command.location);
1567        }
1568        let (output, status) = command.run(
1569            &mut context.stream(),
1570            context.args.show_line_numbers,
1571            context.args.runner.clone(),
1572            self.timeout.unwrap_or(context.timeout),
1573            context.env.env_vars(),
1574            &context.kill,
1575            &context.kill_sender,
1576        )?;
1577
1578        let exit_result = if !self.exit.matches(status) {
1579            ExitResult::Mismatch(status)
1580        } else {
1581            ExitResult::Matches(status)
1582        };
1583
1584        // Side-effects
1585        if let Some(set_var) = &self.set_var {
1586            context.set_env(set_var, output.to_string().trim());
1587        }
1588
1589        let match_context = OutputMatchContext::new(context);
1590        for (key, value) in &self.expect {
1591            match_context.expect(key, context.expand(value)?);
1592        }
1593        self.pattern.prepare(&context.grok)?;
1594        let prepared_output = output
1595            .with_ignore(&context.global_ignore)
1596            .with_reject(&context.global_reject);
1597        let pattern_result = match self.pattern.matches(match_context.clone(), prepared_output) {
1598            Ok(_) => {
1599                let mut env = context.env.clone();
1600                for (key, value) in match_context.expects() {
1601                    env.set_env(key, value);
1602                }
1603                for (key, value) in &self.set_vars {
1604                    context.set_env(key, env.expand(value)?);
1605                }
1606
1607                if self.expect_failure {
1608                    PatternResult::ExpectedFailure
1609                } else {
1610                    PatternResult::Matches
1611                }
1612            }
1613            Err(e) => {
1614                if self.expect_failure {
1615                    PatternResult::MatchesFailure
1616                } else {
1617                    let mut trace = String::new();
1618                    for line in match_context.traces() {
1619                        trace.push_str(&format!("{line}\n"));
1620                    }
1621                    PatternResult::Mismatch(e, trace)
1622                }
1623            }
1624        };
1625
1626        if output.is_empty() {
1627            cwriteln!(context.stream(), dimmed = true, "(no output)");
1628        }
1629
1630        if context.args.simplified_output {
1631            cwriteln!(context.stream(), dimmed = true, "---");
1632        } else {
1633            cwriteln_rule!(context.stream());
1634        }
1635
1636        Ok(ScriptResult {
1637            command: command.clone(),
1638            pattern: pattern_result,
1639            exit: exit_result,
1640            elapsed: start.elapsed(),
1641            output,
1642        })
1643    }
1644}
1645
1646#[derive(derive_more::Debug)]
1647pub struct ScriptResult {
1648    pub command: CommandLine,
1649    pub pattern: PatternResult,
1650    pub exit: ExitResult,
1651    pub elapsed: Duration,
1652    #[debug(skip)]
1653    pub output: Lines,
1654}
1655
1656impl ScriptResult {
1657    pub fn evaluate(&self, context: &mut ScriptRunContext) -> Result<(), ScriptRunError> {
1658        let args = &context.args;
1659        let (success, failure, warning, arrow) = if *crate::term::IS_UTF8 {
1660            ("✅", "❌", "⚠️", "→")
1661        } else {
1662            ("[*]", "[X]", "[!]", "->")
1663        };
1664
1665        if let ExitResult::Mismatch(status) = self.exit {
1666            if args.ignore_exit_codes {
1667                cwriteln!(
1668                    context.stream(),
1669                    fg = Color::Yellow,
1670                    "{warning} Ignored incorrect exit code: {status}"
1671                );
1672                cwriteln!(context.stream());
1673            } else {
1674                cwriteln!(
1675                    context.stream(),
1676                    fg = Color::Red,
1677                    "{failure} FAIL: {status}"
1678                );
1679                cwriteln!(
1680                    context.stream(),
1681                    dimmed = true,
1682                    " {arrow} {}",
1683                    self.command.command
1684                );
1685                cwriteln!(context.stream());
1686                return Err(ScriptRunError::Exit(status, self.command.location.clone()));
1687            }
1688        }
1689
1690        if let PatternResult::Mismatch(e, trace) = &self.pattern {
1691            if args.ignore_matches {
1692                cwriteln!(
1693                    context.stream(),
1694                    fg = Color::Yellow,
1695                    "{warning} Ignored error: {e} (ignoring mismatches)"
1696                );
1697                cwriteln!(context.stream());
1698            } else {
1699                cwriteln!(context.stream(), fg = Color::Red, "ERROR: {e}");
1700                cwriteln!(context.stream(), dimmed = true, "{trace}");
1701                cwriteln!(context.stream(), fg = Color::Red, "{failure} FAIL");
1702                cwriteln!(context.stream());
1703                return Err(ScriptRunError::Pattern(e.clone()));
1704            }
1705        }
1706
1707        if let PatternResult::ExpectedFailure = self.pattern {
1708            if args.ignore_matches {
1709                cwriteln!(
1710                    context.stream(),
1711                    fg = Color::Yellow,
1712                    "{warning} Should not have matched! (ignoring mismatches)"
1713                );
1714                cwriteln!(context.stream());
1715            } else {
1716                cwriteln!(
1717                    context.stream(),
1718                    fg = Color::Red,
1719                    "{failure} FAIL (output shouldn't match)"
1720                );
1721                cwriteln!(
1722                    context.stream(),
1723                    dimmed = true,
1724                    " {arrow} {}",
1725                    self.command.command
1726                );
1727                cwriteln!(context.stream());
1728                return Err(ScriptRunError::ExpectedFailure(
1729                    self.command.location.clone(),
1730                ));
1731            }
1732        }
1733
1734        if let ExitResult::Matches(status) = self.exit {
1735            if status.success() {
1736                cwrite!(context.stream(), fg = Color::Green, "{success} OK");
1737                if !context.args.simplified_output {
1738                    cwriteln!(
1739                        context.stream(),
1740                        dimmed = true,
1741                        " ({:.2}s)",
1742                        self.elapsed.as_secs_f32()
1743                    );
1744                } else {
1745                    cwriteln!(context.stream());
1746                }
1747            } else {
1748                cwrite!(
1749                    context.stream(),
1750                    fg = Color::Green,
1751                    "{success} OK ({status})"
1752                );
1753                if !context.args.simplified_output {
1754                    cwriteln!(
1755                        context.stream(),
1756                        dimmed = true,
1757                        " ({:.2}s)",
1758                        self.elapsed.as_secs_f32()
1759                    );
1760                } else {
1761                    cwriteln!(context.stream());
1762                }
1763            }
1764            cwriteln!(context.stream());
1765        }
1766
1767        Ok(())
1768    }
1769}
1770
1771#[derive(Debug)]
1772pub enum PatternResult {
1773    Matches,
1774    MatchesFailure,
1775    ExpectedFailure,
1776    Mismatch(OutputPatternMatchFailure, String),
1777}
1778
1779#[derive(Debug)]
1780pub enum ExitResult {
1781    Matches(CommandResult),
1782    Mismatch(CommandResult),
1783    TimedOut,
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use crate::parser::v0::parse_script;
1789
1790    use super::*;
1791    use std::error::Error;
1792
1793    #[test]
1794    fn test_script() -> Result<(), Box<dyn Error>> {
1795        let script = r#"
1796pattern VERSION \d+\.\d+\.\d+;
1797
1798$ something --version || echo 1
1799? Something %{VERSION}
1800
1801$ something --help
1802? Usage: something [OPTIONS]
1803repeat {
1804    choice {
1805? %{DATA} %{GREEDYDATA}
1806? %{DATA}=%{DATA} %{GREEDYDATA}
1807    }
1808}
1809"#;
1810
1811        let script = parse_script(ScriptFile::new("test.cli"), script)?;
1812        assert_eq!(script.commands.len(), 3);
1813        eprintln!("{script:?}");
1814        Ok(())
1815    }
1816
1817    #[test]
1818    fn test_bad_script() -> Result<(), Box<dyn Error>> {
1819        let script = r#"
1820$ (cmd; cmd)
1821$ cmd &
1822    "#;
1823
1824        assert!(matches!(
1825            parse_script(ScriptFile::new("test.cli"), script),
1826            Err(ScriptError {
1827                error: ScriptErrorType::BackgroundProcessNotAllowed,
1828                ..
1829            })
1830        ));
1831        Ok(())
1832    }
1833
1834    #[test]
1835    fn test_script_run_context_expand() {
1836        let mut context = ScriptEnv::default();
1837        context.set_env("A", "1");
1838        context.set_env("B", "2");
1839        context.set_env("C", "3");
1840        assert_eq!(context.expand_str("$A").unwrap(), "1".to_string());
1841        assert_eq!(context.expand_str("$A $B ").unwrap(), "1 2 ".to_string());
1842        assert_eq!(
1843            context.expand_str("${A} ${B} ").unwrap(),
1844            "1 2 ".to_string()
1845        );
1846        assert_eq!(context.expand_str(r#"\$A"#).unwrap(), "$A".to_string());
1847        assert_eq!(context.expand_str(r#"\${A}"#).unwrap(), "${A}".to_string());
1848        assert_eq!(context.expand_str(r#"\\$A"#).unwrap(), r#"\1"#);
1849        assert_eq!(context.expand_str(r#"\\${A}"#).unwrap(), r#"\1"#);
1850        context.set_env("TEMP_DIR", "/tmp");
1851        assert_eq!(context.expand_str("$TEMP_DIR").unwrap(), "/tmp".to_string());
1852        assert_eq!(
1853            context.expand_str("${TEMP_DIR}").unwrap(),
1854            "/tmp".to_string()
1855        );
1856    }
1857}