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_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}