Skip to main content

gammaloop_api/commands/
run.rs

1use std::str::FromStr;
2
3use clap::Args;
4use gammalooprs::settings::RuntimeSettings;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use color_eyre::{eyre::eyre, Result};
9
10use crate::{
11    command_parser::{normalize_clap_args, split_command_line, split_command_list},
12    command_template::{
13        contains_placeholder, expand, is_valid_placeholder_name, CommandEnvironment,
14    },
15    state::{CommandHistory, RunHistory, State},
16    CLISettings,
17};
18
19use super::{CommandExecution, Commands};
20
21pub const MAX_RUN_DEPTH: usize = 100;
22
23#[derive(Debug, Clone)]
24pub enum PreparedCommand {
25    Plain(CommandHistory),
26    Run {
27        command: CommandHistory,
28        plan: PreparedRun,
29    },
30}
31
32#[derive(Debug, Clone)]
33pub struct PreparedCommandsBlock {
34    pub name: String,
35    pub commands: Vec<PreparedCommand>,
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct PreparedRun {
40    pub blocks: Vec<PreparedCommandsBlock>,
41    pub commands: Vec<PreparedCommand>,
42}
43
44impl PreparedRun {
45    pub fn is_empty(&self) -> bool {
46        self.blocks.is_empty() && self.commands.is_empty()
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
51pub struct RunVariable {
52    pub key: String,
53    pub value: String,
54}
55
56impl RunVariable {
57    fn assignment(&self) -> String {
58        format!("{}={}", self.key, self.value)
59    }
60}
61
62impl FromStr for RunVariable {
63    type Err = String;
64
65    fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
66        let (key, value) = raw
67            .split_once('=')
68            .ok_or_else(|| "expected KEY=VALUE".to_string())?;
69        if !is_valid_placeholder_name(key) {
70            return Err(format!(
71                "invalid variable name '{key}'; expected [A-Za-z_][A-Za-z0-9_]*"
72            ));
73        }
74        Ok(Self {
75            key: key.to_string(),
76            value: value.to_string(),
77        })
78    }
79}
80
81#[derive(Debug, Args, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
82pub struct Run {
83    /// Command block names to execute (in order)
84    #[arg(value_name = "BLOCK_NAME")]
85    pub(crate) block_names: Vec<String>,
86
87    /// Define a string variable available to selected command blocks; repeat as needed
88    #[arg(
89        short = 'D',
90        long = "define",
91        value_name = "KEY=VALUE",
92        action = clap::ArgAction::Append
93    )]
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub(crate) defines: Vec<RunVariable>,
96
97    /// Semicolon-separated inline commands executed after the named blocks.
98    #[arg(short = 'c', long)]
99    pub(crate) commands: Option<String>,
100}
101
102impl Run {
103    pub fn selected_block_names(&self) -> &[String] {
104        self.block_names.as_slice()
105    }
106
107    pub fn is_noop(&self) -> bool {
108        self.block_names.is_empty()
109            && self
110                .commands
111                .as_ref()
112                .map(|commands| commands.trim().is_empty())
113                .unwrap_or(true)
114    }
115
116    pub fn canonical_raw_string(&self) -> String {
117        let mut parts = vec!["run".to_string()];
118        parts.extend(self.block_names.iter().map(|name| shell_quote(name)));
119        for variable in &self.defines {
120            parts.push("-D".to_string());
121            parts.push(shell_quote(&variable.assignment()));
122        }
123        if let Some(commands) = self.commands.as_ref() {
124            if !commands.trim().is_empty() {
125                parts.push("-c".to_string());
126                parts.push(shell_quote(commands));
127            }
128        }
129        parts.join(" ")
130    }
131
132    pub fn parse_inline_commands(&self) -> Result<Vec<CommandHistory>> {
133        let Some(commands) = self.commands.as_deref() else {
134            return Ok(Vec::new());
135        };
136
137        split_command_list(commands)
138            .map_err(|_| {
139                eyre!("Could not parse run -c command list: unmatched quotes or trailing escape")
140            })?
141            .into_iter()
142            .enumerate()
143            .map(|(index, command)| {
144                CommandHistory::from_raw_string(&command).map_err(|err| {
145                    eyre!(
146                        "Failed to parse run -c command #{} '{}': {}",
147                        index + 1,
148                        command,
149                        err
150                    )
151                })
152            })
153            .collect()
154    }
155
156    pub fn prepare(&self, run_history: &RunHistory, depth: usize) -> Result<PreparedRun> {
157        let mut active_blocks = Vec::new();
158        self.prepare_with_context(
159            run_history,
160            depth,
161            &CommandEnvironment::new(),
162            &mut active_blocks,
163        )
164    }
165
166    fn prepare_with_context(
167        &self,
168        run_history: &RunHistory,
169        depth: usize,
170        inherited_environment: &CommandEnvironment,
171        active_blocks: &mut Vec<String>,
172    ) -> Result<PreparedRun> {
173        if depth > MAX_RUN_DEPTH {
174            return Err(eyre!(
175                "Maximum nested run depth of {} reached while preparing {}",
176                MAX_RUN_DEPTH,
177                self.canonical_raw_string()
178            ));
179        }
180
181        let environment = self.environment(inherited_environment)?;
182        let selected_block_names = self
183            .selected_block_names()
184            .iter()
185            .map(|name| expand(name, &environment))
186            .collect::<Result<Vec<_>>>()?;
187        let selected_blocks = run_history.select_command_blocks(&selected_block_names)?;
188        let commands =
189            self.prepare_inline_commands(run_history, depth + 1, &environment, active_blocks)?;
190        let mut blocks = Vec::with_capacity(selected_blocks.len());
191        for block in selected_blocks {
192            if let Some(first_recursive_index) =
193                active_blocks.iter().position(|name| name == &block.name)
194            {
195                let mut cycle = active_blocks[first_recursive_index..].to_vec();
196                cycle.push(block.name.clone());
197                return Err(eyre!(
198                    "Maximum nested run depth of {} reached while preparing {}. Command block recursion detected: {}",
199                    MAX_RUN_DEPTH,
200                    self.canonical_raw_string(),
201                    cycle.join(" -> ")
202                ));
203            }
204
205            active_blocks.push(block.name.clone());
206            let block_context = format!("command block '{}'", block.name);
207            let block_commands = prepare_command_histories_with_environment(
208                &block.commands,
209                run_history,
210                depth + 1,
211                &block_context,
212                &environment,
213                active_blocks,
214            );
215            active_blocks.pop();
216
217            blocks.push(PreparedCommandsBlock {
218                name: block.name,
219                commands: block_commands?,
220            });
221        }
222
223        Ok(PreparedRun { blocks, commands })
224    }
225
226    fn environment(&self, inherited: &CommandEnvironment) -> Result<CommandEnvironment> {
227        let mut environment = inherited.clone();
228        let mut defined_here = std::collections::BTreeSet::new();
229
230        for variable in &self.defines {
231            if !defined_here.insert(variable.key.clone()) {
232                return Err(eyre!(
233                    "Variable '{}' is defined more than once in {}",
234                    variable.key,
235                    self.canonical_raw_string()
236                ));
237            }
238            let value = expand(&variable.value, &environment)?;
239            environment.insert(variable.key.clone(), value);
240        }
241
242        Ok(environment)
243    }
244
245    fn prepare_inline_commands(
246        &self,
247        run_history: &RunHistory,
248        depth: usize,
249        environment: &CommandEnvironment,
250        active_blocks: &mut Vec<String>,
251    ) -> Result<Vec<PreparedCommand>> {
252        self.parse_inline_commands()?
253            .into_iter()
254            .enumerate()
255            .map(|(index, command)| {
256                PreparedCommand::prepare_with_environment(
257                    command,
258                    run_history,
259                    depth,
260                    environment,
261                    active_blocks,
262                )
263                .map_err(|err| eyre!("Failed to validate run -c command #{}: {}", index + 1, err))
264            })
265            .collect()
266    }
267
268    pub fn run(
269        &self,
270        state: &mut State,
271        global_settings: &mut CLISettings,
272        default_runtime_settings: &mut RuntimeSettings,
273        run_history: &mut RunHistory,
274    ) -> Result<CommandExecution> {
275        let mut session_state = crate::session::CliSessionState::default();
276        let mut session = crate::session::CliSession::new(
277            state,
278            run_history,
279            global_settings,
280            default_runtime_settings,
281            &mut session_state,
282        );
283        session.execute_command(CommandHistory::new_with_raw(
284            Commands::Run(self.clone()),
285            self.canonical_raw_string(),
286        ))
287    }
288}
289
290pub fn prepare_command_histories(
291    commands: &[CommandHistory],
292    run_history: &RunHistory,
293    depth: usize,
294) -> Result<Vec<PreparedCommand>> {
295    prepare_command_histories_with_context(commands, run_history, depth, "commands")
296}
297
298pub fn prepare_command_histories_with_context(
299    commands: &[CommandHistory],
300    run_history: &RunHistory,
301    depth: usize,
302    context: &str,
303) -> Result<Vec<PreparedCommand>> {
304    commands
305        .iter()
306        .cloned()
307        .enumerate()
308        .map(|(index, command)| {
309            PreparedCommand::prepare(command, run_history, depth).map_err(|err| {
310                eyre!(
311                    "Failed to validate {} command #{}: {}",
312                    context,
313                    index + 1,
314                    err
315                )
316            })
317        })
318        .collect()
319}
320
321fn prepare_command_histories_with_environment(
322    commands: &[CommandHistory],
323    run_history: &RunHistory,
324    depth: usize,
325    context: &str,
326    environment: &CommandEnvironment,
327    active_blocks: &mut Vec<String>,
328) -> Result<Vec<PreparedCommand>> {
329    let mut prepared_commands = Vec::with_capacity(commands.len());
330    for (index, command) in commands.iter().cloned().enumerate() {
331        let prepared = PreparedCommand::prepare_with_environment(
332            command,
333            run_history,
334            depth,
335            environment,
336            active_blocks,
337        )
338        .map_err(|err| {
339            eyre!(
340                "Failed to validate {} command #{}: {}",
341                context,
342                index + 1,
343                err
344            )
345        })?;
346        prepared_commands.push(prepared);
347    }
348    Ok(prepared_commands)
349}
350
351fn command_history_from_template(
352    raw: &str,
353    environment: &CommandEnvironment,
354) -> Result<CommandHistory> {
355    let args = split_command_line(raw)
356        .map_err(|_| {
357            eyre!("Could not parse command template: unmatched quotes or trailing escape")
358        })?
359        .into_iter()
360        .filter_map(|arg| {
361            let is_optional_placeholder = contains_placeholder(&arg);
362            match expand(&arg, environment) {
363                Ok(expanded) if is_optional_placeholder && expanded.is_empty() => None,
364                Ok(expanded) => Some(Ok(expanded)),
365                Err(err) => Some(Err(err)),
366            }
367        })
368        .collect::<Result<Vec<_>>>()?;
369    let raw_string = args
370        .iter()
371        .map(|arg| shell_quote(arg))
372        .collect::<Vec<_>>()
373        .join(" ");
374
375    CommandHistory::from_args_and_raw(normalize_clap_args(args), raw_string)
376        .map_err(|err| eyre!("Expanded command did not parse: {err}"))
377}
378
379fn expand_command_history(
380    command: CommandHistory,
381    environment: &CommandEnvironment,
382) -> Result<CommandHistory> {
383    let Some(raw) = command.raw_string() else {
384        return Ok(command);
385    };
386
387    if command.is_template() || contains_placeholder(raw) {
388        command_history_from_template(raw, environment)
389    } else {
390        Ok(command)
391    }
392}
393
394impl PreparedCommand {
395    pub fn prepare(
396        command: CommandHistory,
397        run_history: &RunHistory,
398        depth: usize,
399    ) -> Result<Self> {
400        match command.command.clone() {
401            Commands::Run(run) => Ok(Self::Run {
402                command,
403                plan: run.prepare(run_history, depth)?,
404            }),
405            _ => Ok(Self::Plain(command)),
406        }
407    }
408
409    fn prepare_with_environment(
410        command: CommandHistory,
411        run_history: &RunHistory,
412        depth: usize,
413        environment: &CommandEnvironment,
414        active_blocks: &mut Vec<String>,
415    ) -> Result<Self> {
416        if let Commands::Run(run) = command.command.clone() {
417            return Ok(Self::Run {
418                command,
419                plan: run.prepare_with_context(run_history, depth, environment, active_blocks)?,
420            });
421        }
422
423        let command = expand_command_history(command, environment)?;
424        match command.command.clone() {
425            Commands::Run(run) => Ok(Self::Run {
426                command,
427                plan: run.prepare_with_context(run_history, depth, environment, active_blocks)?,
428            }),
429            Commands::CommandTemplate(raw) => {
430                Err(eyre!("Command template '{}' could not be expanded", raw))
431            }
432            _ => Ok(Self::Plain(command)),
433        }
434    }
435}
436
437fn shell_quote(value: &str) -> String {
438    if value.is_empty() {
439        return "''".to_string();
440    }
441
442    if split_command_line(value)
443        .map(|split| split.len() == 1 && split[0] == value)
444        .unwrap_or(false)
445        && !value.contains(';')
446    {
447        return value.to_string();
448    }
449
450    format!("'{}'", value.replace('\'', "'\"'\"'"))
451}
452
453#[cfg(test)]
454mod tests {
455    use super::{command_history_from_template, CommandEnvironment};
456
457    #[test]
458    fn command_template_omits_empty_optional_placeholder_args() {
459        let command =
460            command_history_from_template("quit $(optional:) -n", &CommandEnvironment::new())
461                .unwrap();
462
463        assert_eq!(command.raw_string(), Some("quit -n"));
464    }
465}