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)]
30pub struct ParsedCommandLine {
31 pub tokens: Vec<String>,
33 pub stages: Vec<String>,
35}
36
37pub 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
77pub 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
218pub 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
257pub 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(¤t[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(¤t[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_can_expand_non_sensitive_config_values() {
485 let config = test_config(&[("alias.demo", "echo ${ui.format}"), ("ui.format", "json")]);
486
487 let parsed = parse_command_tokens_with_aliases(&["demo".to_string()], &config)
488 .expect("alias should expand");
489 assert_eq!(parsed.tokens, vec!["echo".to_string(), "json".to_string()]);
490 }
491
492 #[test]
493 fn alias_rejects_sensitive_config_placeholders() {
494 let config = test_config(&[]);
495
496 let err = expand_alias_template("danger", "echo ${auth.api_key}", &[], &config)
497 .expect_err("sensitive placeholder should be rejected");
498 assert!(
499 err.to_string()
500 .contains("cannot expand sensitive config placeholder")
501 );
502 }
503
504 #[test]
505 fn alias_expands_and_merges_following_stages() {
506 let config = test_config(&[("alias.demo", "orch provision --os alma 9 | P uid")]);
507
508 let parsed = parse_command_tokens_with_aliases(
509 &["demo".to_string(), "|".to_string(), "alice".to_string()],
510 &config,
511 )
512 .expect("alias should expand");
513
514 assert_eq!(
515 parsed.tokens,
516 vec![
517 "orch".to_string(),
518 "provision".to_string(),
519 "--os".to_string(),
520 "alma9".to_string()
521 ]
522 );
523 assert_eq!(
524 parsed.stages,
525 vec!["P uid".to_string(), "alice".to_string()]
526 );
527 }
528
529 #[test]
530 fn parse_command_text_with_aliases_splits_shell_words_and_dsl() {
531 let config = test_config(&[]);
532 let parsed = parse_command_text_with_aliases("ldap user \"alice smith\" | P uid", &config)
533 .expect("command text should parse");
534
535 assert_eq!(
536 parsed.tokens,
537 vec![
538 "ldap".to_string(),
539 "user".to_string(),
540 "alice smith".to_string()
541 ]
542 );
543 assert_eq!(parsed.stages, vec!["P uid".to_string()]);
544 }
545
546 #[test]
547 fn validate_cli_dsl_stages_rejects_unknown_verbs() {
548 let err =
549 validate_cli_dsl_stages(&["R uid".to_string()]).expect_err("unknown verb should fail");
550 assert!(err.to_string().contains("Unknown DSL verb"));
551 }
552
553 #[test]
554 fn alias_placeholders_support_positional_defaults_and_star_quoting() {
555 let config = test_config(&[]);
556
557 let expanded = expand_alias_template(
558 "demo",
559 "echo ${1} ${2:guest} ${*}",
560 &[
561 "alice".to_string(),
562 "two words".to_string(),
563 "O'Neil".to_string(),
564 ],
565 &config,
566 )
567 .expect("alias should expand");
568
569 assert_eq!(
570 expanded,
571 "echo alice two words alice 'two words' 'O'\"'\"'Neil'"
572 );
573 }
574
575 #[test]
576 fn alias_placeholder_syntax_errors_are_reported_cleanly() {
577 let config = test_config(&[]);
578
579 let err = expand_alias_template("demo", "echo ${}", &[], &config)
580 .expect_err("empty placeholder should fail");
581 assert!(err.to_string().contains("invalid alias placeholder syntax"));
582
583 let err = expand_alias_template("demo", "echo ${user", &[], &config)
584 .expect_err("unterminated placeholder should fail");
585 assert!(err.to_string().contains("invalid alias placeholder syntax"));
586 }
587
588 #[test]
589 fn parse_command_tokens_with_aliases_handles_empty_input() {
590 let config = test_config(&[]);
591 let parsed =
592 parse_command_tokens_with_aliases(&[], &config).expect("empty command should parse");
593
594 assert!(parsed.tokens.is_empty());
595 assert!(parsed.stages.is_empty());
596 }
597
598 #[test]
599 fn validate_cli_dsl_stages_allows_help_stage() {
600 validate_cli_dsl_stages(&["H sort".to_string()]).expect("help stage should be allowed");
601 }
602
603 #[test]
604 fn truncate_display_respects_utf8_boundaries() {
605 assert_eq!(truncate_display(" å🙂bcdef ", 3), "å🙂b... (7 chars)");
606 }
607
608 #[test]
609 fn parse_command_text_reports_pipeline_and_shell_split_errors_unit() {
610 let config = test_config(&[]);
611
612 let pipeline_err = parse_command_text_with_aliases("ldap user 'oops | P uid", &config)
613 .expect_err("invalid pipeline should fail");
614 assert!(
615 pipeline_err
616 .to_string()
617 .contains("failed to parse pipeline")
618 );
619 }
620
621 #[test]
622 fn alias_parsing_reports_expansion_and_placeholder_errors_unit() {
623 let config = test_config(&[("alias.demo", "ldap user 'oops | P uid")]);
624 let err = parse_command_tokens_with_aliases(&["demo".to_string()], &config)
625 .expect_err("broken alias command should fail");
626 assert!(
627 err.to_string()
628 .contains("failed to parse alias `demo` expansion")
629 );
630
631 let plain = test_config(&[]);
632 let err = expand_alias_template("loop", "echo ${next}", &[], &plain)
633 .expect_err("missing placeholder should fail");
634 let message = err.to_string();
635 assert!(message.contains("requires value for placeholder"));
636 assert!(message.contains("next"));
637 }
638}