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(¤t[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(¤t[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}