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