Skip to main content

crok_lib/
script.rs

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