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