Skip to main content

osp_cli/cli/
pipeline.rs

1use crate::config::ResolvedConfig;
2use crate::dsl::{
3    model::{ParsedStage, ParsedStageKind},
4    parse::pipeline::parse_stage,
5    parse_pipeline,
6};
7use miette::{IntoDiagnostic, Result, WrapErr, miette};
8
9use crate::app::is_sensitive_key;
10
11const MAX_ALIAS_EXPANSION_DEPTH: usize = 100;
12
13pub(crate) fn truncate_display(s: &str, max_len: usize) -> String {
14    let trimmed = s.trim();
15    let char_count = trimmed.chars().count();
16    if char_count <= max_len {
17        trimmed.to_string()
18    } else {
19        let end = trimmed
20            .char_indices()
21            .nth(max_len)
22            .map(|(index, _)| index)
23            .unwrap_or(trimmed.len());
24        format!("{}... ({} chars)", &trimmed[..end], char_count)
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ParsedCommandLine {
30    pub tokens: Vec<String>,
31    pub stages: Vec<String>,
32}
33
34pub fn parse_command_text_with_aliases(
35    text: &str,
36    config: &ResolvedConfig,
37) -> Result<ParsedCommandLine> {
38    let parsed = parse_pipeline(text)
39        .into_diagnostic()
40        .wrap_err_with(|| format!("failed to parse pipeline: {}", truncate_display(text, 60)))?;
41    let command_tokens = shell_words::split(&parsed.command)
42        .into_diagnostic()
43        .wrap_err_with(|| {
44            format!(
45                "failed to parse command tokens: {}",
46                truncate_display(&parsed.command, 60)
47            )
48        })?;
49    finalize_command_with_aliases(command_tokens, parsed.stages, config)
50}
51
52pub fn parse_command_tokens_with_aliases(
53    tokens: &[String],
54    config: &ResolvedConfig,
55) -> Result<ParsedCommandLine> {
56    if tokens.is_empty() {
57        return Ok(ParsedCommandLine {
58            tokens: Vec::new(),
59            stages: Vec::new(),
60        });
61    }
62
63    let split = split_command_tokens(tokens);
64    finalize_command_with_aliases(split.command_tokens, split.stages, config)
65}
66
67fn maybe_expand_alias(
68    candidate: &str,
69    positional_args: &[String],
70    config: &ResolvedConfig,
71) -> Result<Option<String>> {
72    let Some(value) = config.get_alias_entry(candidate) else {
73        return Ok(None);
74    };
75
76    let template = value.raw_value.to_string();
77    let expanded = expand_alias_template(candidate, &template, positional_args, config)
78        .wrap_err_with(|| format!("failed to expand alias `{candidate}`"))?;
79    Ok(Some(expanded))
80}
81
82fn finalize_command_with_aliases(
83    command_tokens: Vec<String>,
84    stages: Vec<String>,
85    config: &ResolvedConfig,
86) -> Result<ParsedCommandLine> {
87    if command_tokens.is_empty() {
88        return Ok(ParsedCommandLine {
89            tokens: Vec::new(),
90            stages: Vec::new(),
91        });
92    }
93
94    let alias_name = &command_tokens[0];
95    if let Some(expanded) = maybe_expand_alias(alias_name, &command_tokens[1..], config)? {
96        tracing::trace!(
97            alias = %alias_name,
98            "alias expanded"
99        );
100        let alias_parsed = parse_pipeline(&expanded)
101            .into_diagnostic()
102            .wrap_err_with(|| {
103                format!(
104                    "failed to parse alias `{alias_name}` expansion: {}",
105                    truncate_display(&expanded, 60)
106                )
107            })?;
108        let alias_tokens = shell_words::split(&alias_parsed.command)
109            .into_diagnostic()
110            .wrap_err_with(|| format!("failed to parse alias `{alias_name}` command tokens"))?;
111        if alias_tokens.is_empty() {
112            return Ok(ParsedCommandLine {
113                tokens: Vec::new(),
114                stages: Vec::new(),
115            });
116        }
117
118        let mut merged_stages = alias_parsed.stages;
119        merged_stages.extend(stages);
120        return finalize_parsed_command(alias_tokens, merged_stages);
121    }
122
123    finalize_parsed_command(command_tokens, stages)
124}
125
126fn finalize_parsed_command(tokens: Vec<String>, stages: Vec<String>) -> Result<ParsedCommandLine> {
127    validate_cli_dsl_stages(&stages)?;
128    Ok(ParsedCommandLine {
129        tokens: merge_orch_os_tokens(tokens),
130        stages,
131    })
132}
133
134fn merge_orch_os_tokens(tokens: Vec<String>) -> Vec<String> {
135    if tokens.len() < 4 || tokens.first().map(String::as_str) != Some("orch") {
136        return tokens;
137    }
138    if tokens.get(1).map(String::as_str) != Some("provision") {
139        return tokens;
140    }
141
142    let mut merged = Vec::with_capacity(tokens.len());
143    let mut index = 0usize;
144    while index < tokens.len() {
145        if tokens[index] == "--os" && index + 2 < tokens.len() {
146            let family = &tokens[index + 1];
147            let version = &tokens[index + 2];
148            if !version.is_empty() && !version.starts_with('-') {
149                merged.push("--os".to_string());
150                merged.push(format!("{family}{version}"));
151                index += 3;
152                continue;
153            }
154        }
155
156        merged.push(tokens[index].clone());
157        index += 1;
158    }
159
160    merged
161}
162
163pub fn validate_cli_dsl_stages(stages: &[String]) -> Result<()> {
164    for raw in stages {
165        let parsed = parse_stage(raw).into_diagnostic().wrap_err_with(|| {
166            format!("failed to parse DSL stage: {}", truncate_display(raw, 80))
167        })?;
168        if parsed.verb.is_empty() {
169            continue;
170        }
171        if matches!(
172            parsed.kind,
173            ParsedStageKind::Explicit | ParsedStageKind::Quick
174        ) || is_cli_help_stage(&parsed)
175        {
176            continue;
177        }
178
179        return Err(miette!(
180            "Unknown DSL verb '{}' in pipe '{}'. Use `| H <verb>` for help.",
181            parsed.verb,
182            raw.trim()
183        ));
184    }
185
186    Ok(())
187}
188
189pub fn is_cli_help_stage(parsed: &ParsedStage) -> bool {
190    matches!(parsed.kind, ParsedStageKind::UnknownExplicit) && parsed.verb.eq_ignore_ascii_case("H")
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194struct SplitCommandTokens {
195    command_tokens: Vec<String>,
196    stages: Vec<String>,
197}
198
199fn split_command_tokens(tokens: &[String]) -> SplitCommandTokens {
200    let mut segments = Vec::new();
201    let mut current = Vec::new();
202
203    for token in tokens {
204        if token == "|" {
205            if !current.is_empty() {
206                segments.push(std::mem::take(&mut current));
207            }
208            continue;
209        }
210        current.push(token.clone());
211    }
212
213    if !current.is_empty() {
214        segments.push(current);
215    }
216
217    let mut iter = segments.into_iter();
218    let command_tokens = iter.next().unwrap_or_default();
219    let stages = iter
220        .map(|segment| {
221            segment
222                .into_iter()
223                .map(|token| quote_token(&token))
224                .collect::<Vec<_>>()
225                .join(" ")
226        })
227        .collect();
228
229    SplitCommandTokens {
230        command_tokens,
231        stages,
232    }
233}
234
235fn expand_alias_template(
236    alias_name: &str,
237    template: &str,
238    positional_args: &[String],
239    config: &ResolvedConfig,
240) -> Result<String> {
241    let mut current = template.to_string();
242
243    for _ in 0..MAX_ALIAS_EXPANSION_DEPTH {
244        if !current.contains("${") {
245            return Ok(current);
246        }
247
248        let mut out = String::new();
249        let mut cursor = 0usize;
250
251        while let Some(rel_start) = current[cursor..].find("${") {
252            let start = cursor + rel_start;
253            out.push_str(&current[cursor..start]);
254
255            let after_open = start + 2;
256            let Some(rel_end) = current[after_open..].find('}') else {
257                return Err(miette!(
258                    "invalid alias placeholder syntax in alias '{alias_name}': '{template}'"
259                ));
260            };
261            let end = after_open + rel_end;
262            let placeholder = current[after_open..end].trim();
263            if placeholder.is_empty() {
264                return Err(miette!(
265                    "invalid alias placeholder syntax in alias '{alias_name}': '{template}'"
266                ));
267            }
268
269            let (key_part, default) = split_placeholder(placeholder);
270            let replacement =
271                resolve_alias_placeholder(alias_name, key_part, default, positional_args, config)?;
272            out.push_str(&replacement);
273            cursor = end + 1;
274        }
275
276        out.push_str(&current[cursor..]);
277        if out == current {
278            return Ok(out);
279        }
280        current = out;
281    }
282
283    Err(miette!(
284        "Expansion depth exceeded 100 on alias '{alias_name}'."
285    ))
286}
287
288fn split_placeholder(placeholder: &str) -> (&str, Option<&str>) {
289    if let Some((key, default)) = placeholder.split_once(':') {
290        (key.trim(), Some(default))
291    } else {
292        (placeholder.trim(), None)
293    }
294}
295
296fn resolve_alias_placeholder(
297    alias_name: &str,
298    key_part: &str,
299    default: Option<&str>,
300    positional_args: &[String],
301    config: &ResolvedConfig,
302) -> Result<String> {
303    if key_part.is_empty() {
304        return Err(miette!(
305            "invalid alias placeholder syntax in alias '{alias_name}'"
306        ));
307    }
308
309    if let Ok(index) = key_part.parse::<usize>()
310        && index > 0
311        && index <= positional_args.len()
312    {
313        return Ok(positional_args[index - 1].clone());
314    }
315
316    if key_part == "*" || key_part == "@" {
317        let joined = positional_args
318            .iter()
319            .map(|arg| quote_token(arg))
320            .collect::<Vec<String>>()
321            .join(" ");
322        return Ok(joined);
323    }
324
325    if is_sensitive_key(key_part) {
326        return Err(miette!(
327            "Alias '{alias_name}' cannot expand sensitive config placeholder '{key_part}'"
328        ));
329    }
330
331    if let Some(value) = config.get(key_part) {
332        return Ok(value.to_string());
333    }
334
335    if let Some(default_value) = default {
336        return Ok(default_value.to_string());
337    }
338
339    Err(miette!(
340        "Alias '{alias_name}' requires value for placeholder '{key_part}'"
341    ))
342}
343
344fn quote_token(token: &str) -> String {
345    if token.is_empty() {
346        return "''".to_string();
347    }
348    let needs_quotes = token.chars().any(|ch| {
349        ch.is_whitespace()
350            || matches!(
351                ch,
352                '\'' | '"'
353                    | '\\'
354                    | '$'
355                    | '`'
356                    | '|'
357                    | '&'
358                    | ';'
359                    | '<'
360                    | '>'
361                    | '('
362                    | ')'
363                    | '{'
364                    | '}'
365                    | '*'
366                    | '?'
367                    | '['
368                    | ']'
369                    | '!'
370            )
371    });
372    if !needs_quotes {
373        return token.to_string();
374    }
375
376    if !token.contains('\'') {
377        return format!("'{token}'");
378    }
379
380    let mut out = String::new();
381    out.push('\'');
382    for ch in token.chars() {
383        if ch == '\'' {
384            out.push_str("'\"'\"'");
385        } else {
386            out.push(ch);
387        }
388    }
389    out.push('\'');
390    out
391}
392
393#[cfg(test)]
394mod tests {
395    use super::{
396        expand_alias_template, parse_command_text_with_aliases, parse_command_tokens_with_aliases,
397        truncate_display, validate_cli_dsl_stages,
398    };
399    use crate::config::{ConfigLayer, ConfigResolver, ResolveOptions};
400
401    fn test_config(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
402        let mut defaults = ConfigLayer::default();
403        defaults.set("profile.default", "default");
404        for (key, value) in entries {
405            defaults.set(*key, *value);
406        }
407        let mut resolver = ConfigResolver::default();
408        resolver.set_defaults(defaults);
409        resolver
410            .resolve(ResolveOptions::default())
411            .expect("test config should resolve")
412    }
413
414    #[test]
415    fn alias_can_expand_non_sensitive_config_values() {
416        let config = test_config(&[("alias.demo", "echo ${ui.format}"), ("ui.format", "json")]);
417
418        let parsed = parse_command_tokens_with_aliases(&["demo".to_string()], &config)
419            .expect("alias should expand");
420        assert_eq!(parsed.tokens, vec!["echo".to_string(), "json".to_string()]);
421    }
422
423    #[test]
424    fn alias_rejects_sensitive_config_placeholders() {
425        let config = test_config(&[]);
426
427        let err = expand_alias_template("danger", "echo ${auth.api_key}", &[], &config)
428            .expect_err("sensitive placeholder should be rejected");
429        assert!(
430            err.to_string()
431                .contains("cannot expand sensitive config placeholder")
432        );
433    }
434
435    #[test]
436    fn alias_expands_and_merges_following_stages() {
437        let config = test_config(&[("alias.demo", "orch provision --os alma 9 | P uid")]);
438
439        let parsed = parse_command_tokens_with_aliases(
440            &["demo".to_string(), "|".to_string(), "alice".to_string()],
441            &config,
442        )
443        .expect("alias should expand");
444
445        assert_eq!(
446            parsed.tokens,
447            vec![
448                "orch".to_string(),
449                "provision".to_string(),
450                "--os".to_string(),
451                "alma9".to_string()
452            ]
453        );
454        assert_eq!(
455            parsed.stages,
456            vec!["P uid".to_string(), "alice".to_string()]
457        );
458    }
459
460    #[test]
461    fn parse_command_text_with_aliases_splits_shell_words_and_dsl() {
462        let config = test_config(&[]);
463        let parsed = parse_command_text_with_aliases("ldap user \"alice smith\" | P uid", &config)
464            .expect("command text should parse");
465
466        assert_eq!(
467            parsed.tokens,
468            vec![
469                "ldap".to_string(),
470                "user".to_string(),
471                "alice smith".to_string()
472            ]
473        );
474        assert_eq!(parsed.stages, vec!["P uid".to_string()]);
475    }
476
477    #[test]
478    fn validate_cli_dsl_stages_rejects_unknown_verbs() {
479        let err =
480            validate_cli_dsl_stages(&["R uid".to_string()]).expect_err("unknown verb should fail");
481        assert!(err.to_string().contains("Unknown DSL verb"));
482    }
483
484    #[test]
485    fn alias_placeholders_support_positional_defaults_and_star_quoting() {
486        let config = test_config(&[]);
487
488        let expanded = expand_alias_template(
489            "demo",
490            "echo ${1} ${2:guest} ${*}",
491            &[
492                "alice".to_string(),
493                "two words".to_string(),
494                "O'Neil".to_string(),
495            ],
496            &config,
497        )
498        .expect("alias should expand");
499
500        assert_eq!(
501            expanded,
502            "echo alice two words alice 'two words' 'O'\"'\"'Neil'"
503        );
504    }
505
506    #[test]
507    fn alias_placeholder_syntax_errors_are_reported_cleanly() {
508        let config = test_config(&[]);
509
510        let err = expand_alias_template("demo", "echo ${}", &[], &config)
511            .expect_err("empty placeholder should fail");
512        assert!(err.to_string().contains("invalid alias placeholder syntax"));
513
514        let err = expand_alias_template("demo", "echo ${user", &[], &config)
515            .expect_err("unterminated placeholder should fail");
516        assert!(err.to_string().contains("invalid alias placeholder syntax"));
517    }
518
519    #[test]
520    fn parse_command_tokens_with_aliases_handles_empty_input() {
521        let config = test_config(&[]);
522        let parsed =
523            parse_command_tokens_with_aliases(&[], &config).expect("empty command should parse");
524
525        assert!(parsed.tokens.is_empty());
526        assert!(parsed.stages.is_empty());
527    }
528
529    #[test]
530    fn validate_cli_dsl_stages_allows_help_stage() {
531        validate_cli_dsl_stages(&["H sort".to_string()]).expect("help stage should be allowed");
532    }
533
534    #[test]
535    fn truncate_display_respects_utf8_boundaries() {
536        assert_eq!(truncate_display("  å🙂bcdef  ", 3), "å🙂b... (7 chars)");
537    }
538
539    #[test]
540    fn parse_command_text_reports_pipeline_and_shell_split_errors_unit() {
541        let config = test_config(&[]);
542
543        let pipeline_err = parse_command_text_with_aliases("ldap user 'oops | P uid", &config)
544            .expect_err("invalid pipeline should fail");
545        assert!(
546            pipeline_err
547                .to_string()
548                .contains("failed to parse pipeline")
549        );
550    }
551
552    #[test]
553    fn alias_parsing_reports_expansion_and_placeholder_errors_unit() {
554        let config = test_config(&[("alias.demo", "ldap user 'oops | P uid")]);
555        let err = parse_command_tokens_with_aliases(&["demo".to_string()], &config)
556            .expect_err("broken alias command should fail");
557        assert!(
558            err.to_string()
559                .contains("failed to parse alias `demo` expansion")
560        );
561
562        let plain = test_config(&[]);
563        let err = expand_alias_template("loop", "echo ${next}", &[], &plain)
564            .expect_err("missing placeholder should fail");
565        let message = err.to_string();
566        assert!(message.contains("requires value for placeholder"));
567        assert!(message.contains("next"));
568    }
569}