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 #[arg(value_name = "BLOCK_NAME")]
85 pub(crate) block_names: Vec<String>,
86
87 #[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 #[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}