1use rable::{Node, NodeKind};
8
9use crate::ast;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum WordResolution {
14 Literal(String),
16 Multiple(Vec<String>),
18 Unresolvable {
20 reason: String,
22 },
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ResolvedArgs {
28 pub args: Option<Vec<String>>,
30 pub command_position_dynamic: bool,
33 pub failure_reason: Option<String>,
35}
36
37pub trait VarLookup: Send + Sync {
40 fn lookup(&self, name: &str) -> Option<String>;
42}
43
44pub struct EnvLookup;
50
51impl VarLookup for EnvLookup {
52 fn lookup(&self, name: &str) -> Option<String> {
53 std::env::var(name).ok()
54 }
55}
56
57#[must_use]
59pub fn resolve_word(node: &Node, vars: &dyn VarLookup) -> WordResolution {
60 resolve_word_kind(&node.kind, vars)
61}
62
63fn resolve_word_kind(kind: &NodeKind, vars: &dyn VarLookup) -> WordResolution {
64 match kind {
65 NodeKind::Word { value, parts, .. } => resolve_word_node(value, parts, vars),
66 NodeKind::WordLiteral { value } => WordResolution::Literal(value.clone()),
67 NodeKind::AnsiCQuote { decoded, .. } => WordResolution::Literal(decoded.clone()),
68 NodeKind::LocaleString { inner, .. } => WordResolution::Literal(inner.clone()),
69 NodeKind::ParamExpansion { param, op, arg } => {
70 resolve_param_expansion(param, op.as_deref(), arg.as_deref(), vars)
71 }
72 NodeKind::ParamLength { param } => WordResolution::Unresolvable {
73 reason: format!("${{#{param}}} length expansion is not supported"),
74 },
75 NodeKind::ParamIndirect { param, .. } => WordResolution::Unresolvable {
76 reason: format!("${{!{param}}} indirect expansion is not supported"),
77 },
78 NodeKind::ArithmeticExpansion { expression } => resolve_arithmetic(expression.as_deref()),
79 NodeKind::BraceExpansion { content } => expand_brace(content).map_or_else(
80 || WordResolution::Unresolvable {
81 reason: format!("brace expansion {content} could not be expanded"),
82 },
83 WordResolution::Multiple,
84 ),
85 NodeKind::CommandSubstitution { command, .. }
86 if ast::is_safe_heredoc_substitution(command) =>
87 {
88 resolve_safe_heredoc_content(command)
89 }
90 NodeKind::CommandSubstitution { .. } => WordResolution::Unresolvable {
91 reason: "command substitution requires execution".to_string(),
92 },
93 NodeKind::ProcessSubstitution { .. } => WordResolution::Unresolvable {
94 reason: "process substitution requires execution".to_string(),
95 },
96 _ => WordResolution::Unresolvable {
97 reason: "non-word node".to_string(),
98 },
99 }
100}
101
102fn resolve_safe_heredoc_content(command: &Node) -> WordResolution {
105 let NodeKind::Command { redirects, .. } = &command.kind else {
106 return WordResolution::Unresolvable {
107 reason: "expected Command node".to_string(),
108 };
109 };
110 let mut content = String::new();
111 for redir in redirects {
112 if let NodeKind::HereDoc {
113 content: body,
114 quoted,
115 ..
116 } = &redir.kind
117 {
118 if !quoted {
119 return WordResolution::Unresolvable {
120 reason: "unquoted heredoc".to_string(),
121 };
122 }
123 content.push_str(body);
124 }
125 }
126 WordResolution::Literal(content)
127}
128
129fn resolve_word_node(value: &str, parts: &[Node], vars: &dyn VarLookup) -> WordResolution {
130 if parts.is_empty() {
131 return WordResolution::Literal(strip_outer_quotes(value));
132 }
133 let mut resolved_parts: Vec<WordResolution> = Vec::with_capacity(parts.len());
134 for part in parts {
135 let r = resolve_word(part, vars);
136 if let WordResolution::Unresolvable { reason } = r {
137 return WordResolution::Unresolvable { reason };
138 }
139 resolved_parts.push(r);
140 }
141 combine_parts(&resolved_parts)
142}
143
144fn combine_parts(parts: &[WordResolution]) -> WordResolution {
151 let mut variants: Vec<String> = vec![String::new()];
152 for part in parts {
153 match part {
154 WordResolution::Literal(s) => {
155 for v in &mut variants {
156 v.push_str(s);
157 }
158 }
159 WordResolution::Multiple(items) => {
160 let projected = variants.len().saturating_mul(items.len());
161 if projected > MAX_BRACE_EXPANSION {
162 return WordResolution::Unresolvable {
163 reason: format!(
164 "brace expansion would produce {projected} items (cap: {MAX_BRACE_EXPANSION})"
165 ),
166 };
167 }
168 let mut next = Vec::with_capacity(projected);
169 for v in &variants {
170 for item in items {
171 let mut combined = v.clone();
172 combined.push_str(item);
173 next.push(combined);
174 }
175 }
176 variants = next;
177 }
178 WordResolution::Unresolvable { .. } => unreachable!("filtered above"),
179 }
180 }
181 if variants.len() == 1 {
182 WordResolution::Literal(variants.into_iter().next().unwrap_or_default())
183 } else {
184 WordResolution::Multiple(variants)
185 }
186}
187
188fn resolve_param_expansion(
189 param: &str,
190 op: Option<&str>,
191 arg: Option<&str>,
192 vars: &dyn VarLookup,
193) -> WordResolution {
194 let value = vars.lookup(param);
195 match (op, arg, value) {
196 (None | Some(":-" | "-"), _, Some(v)) => WordResolution::Literal(v),
199 (None, _, None) => WordResolution::Unresolvable {
201 reason: format!("${param} is not set"),
202 },
203 (Some(":-" | "-"), Some(default), None) => WordResolution::Literal(default.to_string()),
205 (Some(":+"), Some(value), Some(_)) => WordResolution::Literal(value.to_string()),
207 (Some(":+"), _, None) => WordResolution::Literal(String::new()),
208 (Some(op), _, _) => WordResolution::Unresolvable {
210 reason: format!("${{{param}{op}...}} operator not supported"),
211 },
212 }
213}
214
215fn resolve_arithmetic(expression: Option<&Node>) -> WordResolution {
216 expression.and_then(eval_arithmetic).map_or_else(
217 || WordResolution::Unresolvable {
218 reason: "arithmetic expression could not be evaluated".to_string(),
219 },
220 |n| WordResolution::Literal(n.to_string()),
221 )
222}
223
224fn eval_arithmetic(expr: &Node) -> Option<i64> {
226 match &expr.kind {
227 NodeKind::ArithNumber { value } => parse_arith_number(value),
228 NodeKind::ArithBinaryOp { op, left, right } => {
229 let l = eval_arithmetic(left)?;
230 let r = eval_arithmetic(right)?;
231 apply_binary(op, l, r)
232 }
233 NodeKind::ArithUnaryOp { op, operand } => {
234 let v = eval_arithmetic(operand)?;
235 apply_unary(op, v)
236 }
237 _ => None,
238 }
239}
240
241fn parse_arith_number(value: &str) -> Option<i64> {
242 if let Some(hex) = value
243 .strip_prefix("0x")
244 .or_else(|| value.strip_prefix("0X"))
245 {
246 return i64::from_str_radix(hex, 16).ok();
247 }
248 if value.starts_with('0') && value.len() > 1 && !value.contains(|c: char| !c.is_ascii_digit()) {
249 return i64::from_str_radix(&value[1..], 8).ok();
250 }
251 value.parse::<i64>().ok()
252}
253
254fn apply_binary(op: &str, l: i64, r: i64) -> Option<i64> {
255 match op {
256 "+" => l.checked_add(r),
257 "-" => l.checked_sub(r),
258 "*" => l.checked_mul(r),
259 "/" if r != 0 => l.checked_div(r),
260 "%" if r != 0 => l.checked_rem(r),
261 "**" => {
262 let exp = u32::try_from(r).ok()?;
263 l.checked_pow(exp)
264 }
265 "<<" => {
266 let shift = u32::try_from(r).ok()?;
267 l.checked_shl(shift)
268 }
269 ">>" => {
270 let shift = u32::try_from(r).ok()?;
271 l.checked_shr(shift)
272 }
273 "&" => Some(l & r),
274 "|" => Some(l | r),
275 "^" => Some(l ^ r),
276 _ => None,
277 }
278}
279
280fn apply_unary(op: &str, v: i64) -> Option<i64> {
281 match op {
282 "+" => Some(v),
283 "-" => v.checked_neg(),
284 "~" => Some(!v),
285 "!" => Some(i64::from(v == 0)),
286 _ => None,
287 }
288}
289
290const MAX_BRACE_EXPANSION: usize = 1024;
296
297fn expand_brace(content: &str) -> Option<Vec<String>> {
302 let bytes = content.as_bytes();
303 if bytes.len() < 2 || bytes[0] != b'{' || bytes[bytes.len() - 1] != b'}' {
304 return None;
305 }
306 let inner = &content[1..content.len() - 1];
307 if inner.contains('{') || inner.contains('}') {
308 return None; }
310 if let Some(range) = parse_range(inner) {
311 return if range.len() <= MAX_BRACE_EXPANSION {
312 Some(range)
313 } else {
314 None
315 };
316 }
317 let items: Vec<String> = inner.split(',').map(str::to_string).collect();
318 if items.len() < 2 || items.len() > MAX_BRACE_EXPANSION {
319 return None;
320 }
321 Some(items)
322}
323
324fn parse_range(inner: &str) -> Option<Vec<String>> {
325 let parts: Vec<&str> = inner.splitn(3, "..").collect();
326 if parts.len() < 2 {
327 return None;
328 }
329 if let (Ok(start), Ok(end)) = (parts[0].parse::<i64>(), parts[1].parse::<i64>()) {
330 return numeric_range(start, end);
331 }
332 if parts[0].len() == 1 && parts[1].len() == 1 {
333 let start = parts[0].chars().next()?;
334 let end = parts[1].chars().next()?;
335 if start.is_ascii() && end.is_ascii() {
336 return Some(char_range(start, end));
337 }
338 }
339 None
340}
341
342fn numeric_range(start: i64, end: i64) -> Option<Vec<String>> {
345 let span = (end - start).unsigned_abs();
346 if span >= MAX_BRACE_EXPANSION as u64 {
347 return None;
348 }
349 Some(if start <= end {
350 (start..=end).map(|n| n.to_string()).collect()
351 } else {
352 (end..=start).rev().map(|n| n.to_string()).collect()
353 })
354}
355
356fn char_range(start: char, end: char) -> Vec<String> {
357 let s = start as u8;
360 let e = end as u8;
361 if s <= e {
362 (s..=e).map(|b| (b as char).to_string()).collect()
363 } else {
364 (e..=s).rev().map(|b| (b as char).to_string()).collect()
365 }
366}
367
368fn strip_outer_quotes(s: &str) -> String {
370 let bytes = s.as_bytes();
371 if bytes.len() >= 2
372 && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
373 || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
374 {
375 return s[1..s.len() - 1].to_string();
376 }
377 s.to_string()
378}
379
380#[must_use]
386pub fn resolve_command_args(words: &[Node], vars: &dyn VarLookup) -> ResolvedArgs {
387 let command_position_dynamic = words.first().is_some_and(word_has_param_expansion);
388 let mut resolved: Vec<String> = Vec::with_capacity(words.len());
389 let mut failure_reason: Option<String> = None;
390 let mut all_ok = true;
391 for word in words {
392 match resolve_word(word, vars) {
393 WordResolution::Literal(s) => resolved.push(s),
394 WordResolution::Multiple(items) => resolved.extend(items),
395 WordResolution::Unresolvable { reason } => {
396 if failure_reason.is_none() {
397 failure_reason = Some(reason);
398 }
399 all_ok = false;
400 break;
401 }
402 }
403 }
404 ResolvedArgs {
405 args: if all_ok { Some(resolved) } else { None },
406 command_position_dynamic,
407 failure_reason,
408 }
409}
410
411fn word_has_param_expansion(node: &Node) -> bool {
412 match &node.kind {
413 NodeKind::ParamExpansion { .. } | NodeKind::ParamIndirect { .. } => true,
414 NodeKind::Word { parts, .. } => parts.iter().any(word_has_param_expansion),
415 _ => false,
416 }
417}
418
419#[must_use]
424pub fn shell_join_arg(arg: &str) -> String {
425 if arg.is_empty() {
426 return "''".to_string();
427 }
428 if arg.bytes().all(is_safe_unquoted) {
429 return arg.to_string();
430 }
431 let escaped = arg.replace('\'', r"'\''");
432 format!("'{escaped}'")
433}
434
435const fn is_safe_unquoted(b: u8) -> bool {
436 matches!(
437 b,
438 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b'/' | b'.' | b','
439 )
440}
441
442#[must_use]
444pub fn shell_join(args: &[String]) -> String {
445 args.iter()
446 .map(|a| shell_join_arg(a))
447 .collect::<Vec<_>>()
448 .join(" ")
449}
450
451#[cfg(test)]
452#[allow(
453 clippy::unwrap_used,
454 clippy::panic,
455 clippy::literal_string_with_formatting_args
456)]
457pub(crate) mod tests {
458 use super::*;
459 use crate::parser::BashParser;
460 use std::collections::HashMap;
461
462 pub struct MockLookup {
464 vars: HashMap<String, String>,
465 }
466
467 impl MockLookup {
468 pub fn new() -> Self {
469 Self {
470 vars: HashMap::new(),
471 }
472 }
473 pub fn with(mut self, name: &str, value: &str) -> Self {
474 self.vars.insert(name.to_string(), value.to_string());
475 self
476 }
477 }
478
479 impl VarLookup for MockLookup {
480 fn lookup(&self, name: &str) -> Option<String> {
481 self.vars.get(name).cloned()
482 }
483 }
484
485 fn parse_command(source: &str) -> Vec<Node> {
486 let mut parser = BashParser::new().unwrap();
487 parser.parse(source).unwrap()
488 }
489
490 fn extract_words(source: &str) -> Vec<Node> {
491 let nodes = parse_command(source);
492 let NodeKind::Command { words, .. } = &nodes[0].kind else {
493 panic!("expected Command");
494 };
495 words.clone()
496 }
497
498 fn first_arg_node(source: &str) -> Node {
499 extract_words(source).into_iter().nth(1).unwrap()
501 }
502
503 #[test]
506 fn resolve_word_literal() {
507 let node = first_arg_node("echo hello");
508 let lookup = MockLookup::new();
509 assert_eq!(
510 resolve_word(&node, &lookup),
511 WordResolution::Literal("hello".to_string())
512 );
513 }
514
515 #[test]
516 fn resolve_ansi_c_quote_decoded() {
517 let node = first_arg_node("echo $'\\x41'");
518 let lookup = MockLookup::new();
519 assert_eq!(
520 resolve_word(&node, &lookup),
521 WordResolution::Literal("A".to_string())
522 );
523 }
524
525 #[test]
526 fn resolve_locale_string() {
527 let node = first_arg_node("echo $\"hello\"");
528 let lookup = MockLookup::new();
529 assert_eq!(
530 resolve_word(&node, &lookup),
531 WordResolution::Literal("hello".to_string())
532 );
533 }
534
535 #[test]
538 fn resolve_simple_var_set() {
539 let node = first_arg_node("echo $HOME");
540 let lookup = MockLookup::new().with("HOME", "/Users/test");
541 assert_eq!(
542 resolve_word(&node, &lookup),
543 WordResolution::Literal("/Users/test".to_string())
544 );
545 }
546
547 #[test]
548 fn resolve_simple_var_unset() {
549 let node = first_arg_node("echo $UNSET");
550 let lookup = MockLookup::new();
551 match resolve_word(&node, &lookup) {
552 WordResolution::Unresolvable { reason } => {
553 assert!(reason.contains("$UNSET is not set"));
554 }
555 other => panic!("expected Unresolvable, got {other:?}"),
556 }
557 }
558
559 #[test]
560 fn resolve_braced_var() {
561 let node = first_arg_node("echo ${HOME}");
562 let lookup = MockLookup::new().with("HOME", "/x");
563 assert_eq!(
564 resolve_word(&node, &lookup),
565 WordResolution::Literal("/x".to_string())
566 );
567 }
568
569 #[test]
570 fn resolve_default_when_unset() {
571 let node = first_arg_node("echo ${UNSET:-fallback}");
572 let lookup = MockLookup::new();
573 assert_eq!(
574 resolve_word(&node, &lookup),
575 WordResolution::Literal("fallback".to_string())
576 );
577 }
578
579 #[test]
580 fn resolve_default_when_set() {
581 let node = first_arg_node("echo ${VAR:-fallback}");
582 let lookup = MockLookup::new().with("VAR", "actual");
583 assert_eq!(
584 resolve_word(&node, &lookup),
585 WordResolution::Literal("actual".to_string())
586 );
587 }
588
589 #[test]
590 fn resolve_alt_value_when_set() {
591 let node = first_arg_node("echo ${VAR:+yes}");
592 let lookup = MockLookup::new().with("VAR", "anything");
593 assert_eq!(
594 resolve_word(&node, &lookup),
595 WordResolution::Literal("yes".to_string())
596 );
597 }
598
599 #[test]
600 fn resolve_alt_value_when_unset() {
601 let node = first_arg_node("echo ${UNSET:+yes}");
602 let lookup = MockLookup::new();
603 assert_eq!(
604 resolve_word(&node, &lookup),
605 WordResolution::Literal(String::new())
606 );
607 }
608
609 #[test]
610 fn unsupported_param_op_unresolvable() {
611 let node = first_arg_node("echo ${VAR##prefix}");
612 let lookup = MockLookup::new().with("VAR", "x");
613 assert!(matches!(
614 resolve_word(&node, &lookup),
615 WordResolution::Unresolvable { .. }
616 ));
617 }
618
619 #[test]
620 fn param_indirect_unresolvable() {
621 let node = first_arg_node("echo ${!ref}");
622 let lookup = MockLookup::new().with("ref", "HOME");
623 assert!(matches!(
624 resolve_word(&node, &lookup),
625 WordResolution::Unresolvable { .. }
626 ));
627 }
628
629 #[test]
630 fn param_length_unresolvable() {
631 let node = first_arg_node("echo ${#var}");
632 let lookup = MockLookup::new().with("var", "abc");
633 assert!(matches!(
634 resolve_word(&node, &lookup),
635 WordResolution::Unresolvable { .. }
636 ));
637 }
638
639 #[test]
642 fn resolve_arithmetic_simple() {
643 let node = first_arg_node("echo $((1+2))");
644 let lookup = MockLookup::new();
645 assert_eq!(
646 resolve_word(&node, &lookup),
647 WordResolution::Literal("3".to_string())
648 );
649 }
650
651 #[test]
652 fn resolve_arithmetic_complex() {
653 let node = first_arg_node("echo $((2*3+4))");
654 let lookup = MockLookup::new();
655 assert_eq!(
656 resolve_word(&node, &lookup),
657 WordResolution::Literal("10".to_string())
658 );
659 }
660
661 #[test]
662 fn resolve_arithmetic_unary_negation() {
663 let node = first_arg_node("echo $((-5))");
664 let lookup = MockLookup::new();
665 assert_eq!(
666 resolve_word(&node, &lookup),
667 WordResolution::Literal("-5".to_string())
668 );
669 }
670
671 #[test]
672 fn resolve_arithmetic_division_by_zero_unresolvable() {
673 let node = first_arg_node("echo $((1/0))");
674 let lookup = MockLookup::new();
675 assert!(matches!(
676 resolve_word(&node, &lookup),
677 WordResolution::Unresolvable { .. }
678 ));
679 }
680
681 #[test]
682 fn resolve_arithmetic_with_var_unresolvable() {
683 let node = first_arg_node("echo $((x+1))");
684 let lookup = MockLookup::new();
685 assert!(matches!(
686 resolve_word(&node, &lookup),
687 WordResolution::Unresolvable { .. }
688 ));
689 }
690
691 #[test]
694 fn resolve_brace_comma() {
695 let node = first_arg_node("ls {a,b,c}");
696 let lookup = MockLookup::new();
697 assert_eq!(
698 resolve_word(&node, &lookup),
699 WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
700 );
701 }
702
703 #[test]
704 fn resolve_brace_numeric_range() {
705 let node = first_arg_node("echo {1..3}");
706 let lookup = MockLookup::new();
707 assert_eq!(
708 resolve_word(&node, &lookup),
709 WordResolution::Multiple(vec!["1".into(), "2".into(), "3".into()])
710 );
711 }
712
713 #[test]
714 fn resolve_brace_char_range() {
715 let node = first_arg_node("echo {a..c}");
716 let lookup = MockLookup::new();
717 assert_eq!(
718 resolve_word(&node, &lookup),
719 WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
720 );
721 }
722
723 #[test]
724 fn resolve_brace_with_prefix_and_suffix() {
725 let node = first_arg_node("ls file.{txt,md}");
726 let lookup = MockLookup::new();
727 assert_eq!(
728 resolve_word(&node, &lookup),
729 WordResolution::Multiple(vec!["file.txt".into(), "file.md".into()])
730 );
731 }
732
733 #[test]
734 fn resolve_two_adjacent_brace_expansions() {
735 let node = first_arg_node("ls {a,b}{c,d}");
739 let lookup = MockLookup::new();
740 assert_eq!(
741 resolve_word(&node, &lookup),
742 WordResolution::Multiple(vec!["ac".into(), "ad".into(), "bc".into(), "bd".into(),])
743 );
744 }
745
746 #[test]
747 fn resolve_three_adjacent_brace_expansions() {
748 let node = first_arg_node("ls {a,b}{c,d}{e,f}");
751 let lookup = MockLookup::new();
752 let result = resolve_word(&node, &lookup);
753 let WordResolution::Multiple(items) = result else {
754 panic!("expected Multiple, got {result:?}");
755 };
756 assert_eq!(items.len(), 8);
757 assert!(items.contains(&"ace".to_string()));
758 assert!(items.contains(&"bdf".to_string()));
759 }
760
761 #[test]
764 fn command_substitution_unresolvable() {
765 let node = first_arg_node("echo $(whoami)");
766 let lookup = MockLookup::new();
767 assert!(matches!(
768 resolve_word(&node, &lookup),
769 WordResolution::Unresolvable { .. }
770 ));
771 }
772
773 #[test]
776 fn resolve_full_command_all_literal() {
777 let words = extract_words("echo hello world");
778 let lookup = MockLookup::new();
779 let result = resolve_command_args(&words, &lookup);
780 assert_eq!(
781 result.args,
782 Some(vec!["echo".into(), "hello".into(), "world".into()])
783 );
784 assert!(!result.command_position_dynamic);
785 }
786
787 #[test]
788 fn resolve_full_command_with_var() {
789 let words = extract_words("ls $HOME");
790 let lookup = MockLookup::new().with("HOME", "/x");
791 let result = resolve_command_args(&words, &lookup);
792 assert_eq!(result.args, Some(vec!["ls".into(), "/x".into()]));
793 assert!(!result.command_position_dynamic);
794 }
795
796 #[test]
797 fn resolve_full_command_unresolvable_var() {
798 let words = extract_words("ls $UNSET_XYZ");
799 let lookup = MockLookup::new();
800 let result = resolve_command_args(&words, &lookup);
801 assert!(result.args.is_none());
802 assert!(result.failure_reason.is_some());
803 }
804
805 #[test]
806 fn command_position_dynamic_detected() {
807 let words = extract_words("$cmd hello");
808 let lookup = MockLookup::new().with("cmd", "ls");
809 let result = resolve_command_args(&words, &lookup);
810 assert!(result.command_position_dynamic);
811 assert_eq!(result.args, Some(vec!["ls".into(), "hello".into()]));
812 }
813
814 #[test]
815 fn brace_expansion_expands_args() {
816 let words = extract_words("ls {a,b,c}");
817 let lookup = MockLookup::new();
818 let result = resolve_command_args(&words, &lookup);
819 assert_eq!(
820 result.args,
821 Some(vec!["ls".into(), "a".into(), "b".into(), "c".into()])
822 );
823 }
824
825 #[test]
828 fn shell_join_safe_args() {
829 assert_eq!(shell_join_arg("hello"), "hello");
830 assert_eq!(shell_join_arg("file.txt"), "file.txt");
831 assert_eq!(shell_join_arg("/path/to/file"), "/path/to/file");
832 }
833
834 #[test]
835 fn shell_join_with_spaces() {
836 assert_eq!(shell_join_arg("hello world"), "'hello world'");
837 }
838
839 #[test]
840 fn shell_join_with_inner_quote() {
841 assert_eq!(shell_join_arg("it's"), r"'it'\''s'");
842 }
843
844 #[test]
845 fn shell_join_empty() {
846 assert_eq!(shell_join_arg(""), "''");
847 }
848
849 #[test]
850 fn shell_join_args_list() {
851 let args = vec![
852 "echo".to_string(),
853 "hello world".to_string(),
854 "ok".to_string(),
855 ];
856 assert_eq!(shell_join(&args), "echo 'hello world' ok");
857 }
858
859 #[test]
862 fn strip_outer_quotes_double() {
863 assert_eq!(strip_outer_quotes("\"hello\""), "hello");
864 }
865
866 #[test]
867 fn strip_outer_quotes_single() {
868 assert_eq!(strip_outer_quotes("'hello'"), "hello");
869 }
870
871 #[test]
872 fn strip_outer_quotes_unquoted_unchanged() {
873 assert_eq!(strip_outer_quotes("hello"), "hello");
874 }
875
876 #[test]
877 fn strip_outer_quotes_mismatched_unchanged() {
878 assert_eq!(strip_outer_quotes("'hello\""), "'hello\"");
880 assert_eq!(strip_outer_quotes("\"hello'"), "\"hello'");
881 }
882
883 #[test]
884 fn strip_outer_quotes_only_left_unchanged() {
885 assert_eq!(strip_outer_quotes("'hello"), "'hello");
887 assert_eq!(strip_outer_quotes("hello'"), "hello'");
888 }
889
890 #[test]
891 fn strip_outer_quotes_empty_string() {
892 assert_eq!(strip_outer_quotes(""), "");
893 }
894
895 #[test]
896 fn strip_outer_quotes_single_char_unchanged() {
897 assert_eq!(strip_outer_quotes("'"), "'");
899 assert_eq!(strip_outer_quotes("\""), "\"");
900 }
901
902 #[test]
903 fn strip_outer_quotes_just_quote_pair() {
904 assert_eq!(strip_outer_quotes("''"), "");
906 assert_eq!(strip_outer_quotes("\"\""), "");
907 }
908
909 #[test]
912 fn env_lookup_returns_set_var() {
913 let lookup = EnvLookup;
915 assert!(lookup.lookup("PATH").is_some());
916 }
917
918 #[test]
919 fn env_lookup_returns_none_for_unset() {
920 let lookup = EnvLookup;
921 assert!(
922 lookup
923 .lookup("__RIPPY_TEST_DEFINITELY_UNSET_42__")
924 .is_none()
925 );
926 }
927}