1use std::path::Path;
29
30use serde::{Deserialize, Serialize};
31
32use crate::ast::{CommandArgument, Pipeline, PrimaryExpression, SimpleExpansionElement, Statement, StringPart};
33use crate::wrappers::{peel_simple_command_loose, PeeledCommand};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "snake_case")]
45pub enum GlobalFlagSpec {
46 Bare { flag: String },
49 TakesValue { flag: String },
53}
54
55impl GlobalFlagSpec {
56 pub fn flag(&self) -> &str {
57 match self {
58 Self::Bare { flag } | Self::TakesValue { flag } => flag,
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
72pub struct ToolSchemaInfo {
73 pub tool: String,
75 pub global_flags: Vec<GlobalFlagSpec>,
77 pub max_depth: u8,
81}
82
83pub trait ToolLookup {
91 fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo>;
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ToolInvocation<'a> {
103 pub tool: &'a str,
106 pub subcommand: Vec<&'a str>,
109 pub rest: Vec<&'a str>,
113 pub global_flags: Vec<&'a str>,
116 pub leaf_flags: Vec<&'a str>,
119}
120
121pub fn tool_invocation_of<'a>(
130 peeled: &'a PeeledCommand,
131 registry: &impl ToolLookup,
132) -> Option<ToolInvocation<'a>> {
133 let basename = tool_basename(&peeled.primary);
134 let info = registry.lookup(basename)?;
135
136 let args = &peeled.args;
137 let mut i = 0usize;
138
139 let mut global_flags: Vec<&'a str> = Vec::new();
141 while i < args.len() {
142 let arg: &'a str = args[i].as_str();
143 if !arg.starts_with('-') {
144 break; }
146 match find_global_flag(arg, &info.global_flags) {
147 Some(GlobalFlagSpec::Bare { .. }) => {
148 global_flags.push(arg);
149 i += 1;
150 }
151 Some(GlobalFlagSpec::TakesValue { flag }) => {
152 global_flags.push(arg);
153 i += 1;
154 if arg == flag.as_str() {
157 if let Some(value) = args.get(i) {
158 global_flags.push(value.as_str());
159 i += 1;
160 }
161 }
162 }
164 None => break, }
166 }
167
168 let mut subcommand: Vec<&'a str> = Vec::new();
170 while i < args.len() && subcommand.len() < info.max_depth as usize {
171 let arg: &'a str = args[i].as_str();
172 if arg.starts_with('-') {
173 break; }
175 subcommand.push(arg);
176 i += 1;
177 }
178
179 let mut leaf_flags: Vec<&'a str> = Vec::new();
181 let mut rest: Vec<&'a str> = Vec::new();
182 for arg in &args[i..] {
183 if arg.starts_with('-') {
184 leaf_flags.push(arg.as_str());
185 } else {
186 rest.push(arg.as_str());
187 }
188 }
189
190 Some(ToolInvocation {
191 tool: basename,
192 subcommand,
193 rest,
194 global_flags,
195 leaf_flags,
196 })
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
210pub enum Provenance {
211 Literal,
213 Glob,
215 Variable,
217 ProcessSub,
219 CommandSub,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230pub enum PathScope {
231 WithinCamp,
233 Unresolvable,
236 OutsideCamp,
238 System,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ArgFact {
251 pub raw: String,
253 pub provenance: Provenance,
255 pub scope: Option<PathScope>,
257}
258
259pub fn arg_provenance(arg: &CommandArgument) -> Provenance {
266 match arg {
267 CommandArgument::Primary(p) => primary_provenance(p),
268 CommandArgument::Concatenation(c) => {
269 c.parts.iter().map(primary_provenance).fold(Provenance::Literal, Ord::max)
270 }
271 CommandArgument::Regex(_) | CommandArgument::Operator { .. } => Provenance::Literal,
272 }
273}
274
275pub fn arg_fact(arg: &CommandArgument, camp_root: &Path) -> ArgFact {
280 let raw = arg_raw_text(arg);
281 let provenance = arg_provenance(arg);
282 let scope = if provenance == Provenance::Literal {
283 Some(path_scope(&raw, camp_root))
284 } else {
285 None
286 };
287 ArgFact { raw, provenance, scope }
288}
289
290pub fn path_scope(literal: &str, camp_root: &Path) -> PathScope {
298 if literal.is_empty() {
299 return PathScope::Unresolvable;
300 }
301 let p = Path::new(literal);
302 if p.is_absolute() {
303 return if is_system_path(literal) {
304 PathScope::System
305 } else if p.starts_with(camp_root) {
306 PathScope::WithinCamp
307 } else {
308 PathScope::OutsideCamp
309 };
310 }
311 if literal.starts_with('~') {
313 return PathScope::OutsideCamp;
314 }
315 if !looks_like_path(literal) {
317 return PathScope::Unresolvable;
318 }
319 if escapes_camp(p) {
321 PathScope::OutsideCamp
322 } else {
323 PathScope::WithinCamp
324 }
325}
326
327pub fn pipeline_stage_basenames(pipeline: &Pipeline) -> Vec<Option<String>> {
336 pipeline.stages.iter().map(stmt_basename).collect()
337}
338
339fn tool_basename(primary: &str) -> &str {
344 primary.rsplit('/').next().unwrap_or(primary)
345}
346
347fn find_global_flag<'s>(arg: &str, specs: &'s [GlobalFlagSpec]) -> Option<&'s GlobalFlagSpec> {
353 for spec in specs {
354 let flag = spec.flag();
355 if arg == flag {
356 return Some(spec);
357 }
358 if matches!(spec, GlobalFlagSpec::TakesValue { .. }) {
359 if arg.starts_with(flag) && arg.as_bytes().get(flag.len()) == Some(&b'=') {
361 return Some(spec);
362 }
363 }
364 }
365 None
366}
367
368fn primary_provenance(p: &PrimaryExpression) -> Provenance {
369 match p {
370 PrimaryExpression::Word(w) => {
371 if w.text.contains(['*', '?', '[']) {
372 Provenance::Glob
373 } else {
374 Provenance::Literal
375 }
376 }
377 PrimaryExpression::RawString(_)
378 | PrimaryExpression::Number(_)
379 | PrimaryExpression::AnsiCString(_) => Provenance::Literal,
380 PrimaryExpression::StringNode(s) => string_parts_provenance(&s.parts),
381 PrimaryExpression::TranslatedString(t) => string_parts_provenance(&t.parts),
382 PrimaryExpression::SimpleExpansion(_)
383 | PrimaryExpression::Expansion(_)
384 | PrimaryExpression::ArithmeticExpansion(_) => Provenance::Variable,
385 PrimaryExpression::CommandSubstitution(_) => Provenance::CommandSub,
386 PrimaryExpression::ProcessSubstitution(_) => Provenance::ProcessSub,
387 PrimaryExpression::BraceExpression(_) => Provenance::Glob,
389 }
390}
391
392fn string_parts_provenance(parts: &[StringPart]) -> Provenance {
393 parts
394 .iter()
395 .map(|part| match part {
396 StringPart::Content(_) | StringPart::Raw { .. } => Provenance::Literal,
397 StringPart::SimpleExpansion(_)
398 | StringPart::Expansion(_)
399 | StringPart::ArithmeticExpansion(_) => Provenance::Variable,
400 StringPart::CommandSubstitution(_) => Provenance::CommandSub,
401 })
402 .fold(Provenance::Literal, Ord::max)
403}
404
405pub fn arg_raw_text(arg: &CommandArgument) -> String {
406 match arg {
407 CommandArgument::Primary(p) => primary_raw_text(p),
408 CommandArgument::Concatenation(c) => {
409 c.parts.iter().map(primary_raw_text).collect()
410 }
411 CommandArgument::Regex(r) => r.text.clone(),
412 CommandArgument::Operator { text, .. } => text.clone(),
413 }
414}
415
416fn primary_raw_text(p: &PrimaryExpression) -> String {
417 match p {
418 PrimaryExpression::Word(w) => w.text.clone(),
419 PrimaryExpression::RawString(r) => r.text.trim_matches('\'').to_owned(),
420 PrimaryExpression::Number(n) => n.text.clone(),
421 PrimaryExpression::AnsiCString(a) => a.text.clone(),
422 PrimaryExpression::BraceExpression(b) => b.text.clone(),
423 PrimaryExpression::StringNode(s) => string_parts_text(&s.parts),
424 PrimaryExpression::TranslatedString(t) => string_parts_text(&t.parts),
425 PrimaryExpression::SimpleExpansion(se) => match &se.element {
426 SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
427 SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
428 },
429 PrimaryExpression::Expansion(_) => "${…}".to_owned(),
430 PrimaryExpression::CommandSubstitution(_) => "$(…)".to_owned(),
431 PrimaryExpression::ProcessSubstitution(_) => "<(…)".to_owned(),
432 PrimaryExpression::ArithmeticExpansion(_) => "$((…))".to_owned(),
433 }
434}
435
436fn string_parts_text(parts: &[StringPart]) -> String {
437 parts
438 .iter()
439 .map(|part| match part {
440 StringPart::Content(c) => c.text.clone(),
441 StringPart::Raw { text, .. } => text.clone(),
442 StringPart::SimpleExpansion(se) => match &se.element {
443 SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
444 SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
445 },
446 StringPart::Expansion(_) => "${…}".to_owned(),
447 StringPart::CommandSubstitution(_) => "$(…)".to_owned(),
448 StringPart::ArithmeticExpansion(_) => "$((…))".to_owned(),
449 })
450 .collect()
451}
452
453const SYSTEM_DIR_PREFIXES: &[&str] = &[
454 "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64",
455 "/var", "/dev", "/proc", "/sys", "/boot", "/root", "/run", "/snap",
456];
457
458fn is_system_path(literal: &str) -> bool {
459 if literal == "/" {
460 return true;
461 }
462 SYSTEM_DIR_PREFIXES.iter().any(|dir| {
463 literal == *dir || literal.starts_with(&format!("{}/", dir))
464 })
465}
466
467fn looks_like_path(literal: &str) -> bool {
468 literal.contains('/') || literal.starts_with('.') || literal.starts_with('~')
469}
470
471fn escapes_camp(p: &Path) -> bool {
476 let mut depth: i32 = 0;
477 for component in p.components() {
478 match component {
479 std::path::Component::ParentDir => {
480 depth -= 1;
481 if depth < 0 {
482 return true;
483 }
484 }
485 std::path::Component::Normal(_) => depth += 1,
486 std::path::Component::CurDir | std::path::Component::RootDir => {}
487 std::path::Component::Prefix(_) => {}
488 }
489 }
490 false
491}
492
493fn stmt_basename(stmt: &Statement) -> Option<String> {
494 let cmd = match stmt {
495 Statement::Command(c) => c,
496 Statement::Redirected(r) => match r.body.as_deref() {
497 Some(Statement::Command(c)) => c,
498 _ => return None,
499 },
500 _ => return None,
501 };
502 peel_simple_command_loose(cmd).map(|p| tool_basename(&p.primary).to_lowercase())
503}
504
505#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::{parse_to_ast, wrappers::peel_command};
511
512 struct MockRegistry {
515 schemas: Vec<ToolSchemaInfo>,
516 }
517
518 impl ToolLookup for MockRegistry {
519 fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo> {
520 let key = basename.to_lowercase();
521 self.schemas.iter().find(|s| s.tool == key).cloned()
522 }
523 }
524
525 fn mock_registry() -> MockRegistry {
526 MockRegistry {
527 schemas: vec![ToolSchemaInfo {
528 tool: "mock".to_string(),
529 global_flags: vec![
530 GlobalFlagSpec::Bare { flag: "--bare".to_string() },
531 GlobalFlagSpec::TakesValue { flag: "-X".to_string() },
532 GlobalFlagSpec::TakesValue { flag: "--dir".to_string() },
533 ],
534 max_depth: 2,
535 }],
536 }
537 }
538
539 fn extract(
543 src: &str,
544 registry: &MockRegistry,
545 ) -> Option<(String, Vec<String>, Vec<String>, Vec<String>, Vec<String>)> {
546 let prog = parse_to_ast(src).ok()?;
547 let peeled = peel_command(&prog)?;
548 let inv = tool_invocation_of(&peeled, registry)?;
549 Some((
550 inv.tool.to_owned(),
551 inv.subcommand.iter().map(|s| s.to_string()).collect(),
552 inv.rest.iter().map(|s| s.to_string()).collect(),
553 inv.global_flags.iter().map(|s| s.to_string()).collect(),
554 inv.leaf_flags.iter().map(|s| s.to_string()).collect(),
555 ))
556 }
557
558 #[test]
559 fn bare_invocation() {
560 let reg = mock_registry();
561 let (tool, subcmd, rest, gflags, lflags) = extract("mock sub1", ®).unwrap();
562 assert_eq!(tool, "mock");
563 assert_eq!(subcmd, ["sub1"]);
564 assert!(rest.is_empty());
565 assert!(gflags.is_empty());
566 assert!(lflags.is_empty());
567 }
568
569 #[test]
570 fn dash_flag_with_attached_value() {
571 let reg = mock_registry();
573 let (_, subcmd, _, gflags, _) = extract("mock --dir=/tmp sub1", ®).unwrap();
574 assert_eq!(gflags, ["--dir=/tmp"]);
575 assert_eq!(subcmd, ["sub1"]);
576 }
577
578 #[test]
579 fn dash_dash_flag_equals_with_space() {
580 let reg = mock_registry();
582 let (_, subcmd, _, gflags, _) = extract("mock --dir /tmp sub1", ®).unwrap();
583 assert_eq!(gflags, ["--dir", "/tmp"]);
584 assert_eq!(subcmd, ["sub1"]);
585 }
586
587 #[test]
588 fn short_flag_takes_value() {
589 let reg = mock_registry();
590 let (_, subcmd, _, gflags, _) = extract("mock -X value sub1", ®).unwrap();
591 assert_eq!(gflags, ["-X", "value"]);
592 assert_eq!(subcmd, ["sub1"]);
593 }
594
595 #[test]
596 fn bare_global_flag() {
597 let reg = mock_registry();
598 let (_, subcmd, _, gflags, _) = extract("mock --bare sub1", ®).unwrap();
599 assert_eq!(gflags, ["--bare"]);
600 assert_eq!(subcmd, ["sub1"]);
601 }
602
603 #[test]
604 fn deepest_chain_the_mock_schema_declares() {
605 let reg = mock_registry();
607 let (_, subcmd, rest, _, lflags) =
608 extract("mock sub1 sub2 extra --leaf-flag", ®).unwrap();
609 assert_eq!(subcmd, ["sub1", "sub2"]);
610 assert_eq!(rest, ["extra"]);
611 assert_eq!(lflags, ["--leaf-flag"]);
612 }
613
614 #[test]
615 fn leaf_flags_after_subcommand() {
616 let reg = mock_registry();
617 let (_, subcmd, rest, _, lflags) = extract("mock sub1 --opt target", ®).unwrap();
618 assert_eq!(subcmd, ["sub1"]);
619 assert_eq!(rest, ["target"]);
620 assert_eq!(lflags, ["--opt"]);
621 }
622
623 #[test]
624 fn absolute_path_primary() {
625 let reg = mock_registry();
627 let (tool, subcmd, _, _, _) = extract("/usr/bin/mock sub1", ®).unwrap();
628 assert_eq!(tool, "mock");
629 assert_eq!(subcmd, ["sub1"]);
630 }
631
632 #[test]
633 fn unknown_primary_returns_none() {
634 let reg = mock_registry();
635 let prog = parse_to_ast("notregistered status").expect("parse");
636 let peeled = peel_command(&prog).expect("peel");
637 assert!(tool_invocation_of(&peeled, ®).is_none());
638 }
639
640 #[test]
641 fn variable_primary_returns_none() {
642 let prog = parse_to_ast("$TOOL status").expect("parse");
645 assert!(
646 peel_command(&prog).is_none(),
647 "peel_command should return None for variable primary"
648 );
649 }
650
651 #[test]
652 fn global_flag_stops_at_unknown_flag() {
653 let reg = mock_registry();
658 let (_, subcmd, rest, gflags, lflags) =
659 extract("mock --unknown-flag sub1", ®).unwrap();
660 assert!(gflags.is_empty());
661 assert!(subcmd.is_empty());
662 assert_eq!(lflags, ["--unknown-flag"]);
663 assert_eq!(rest, ["sub1"]);
664 }
665
666 fn first_arg_provenance(src: &str) -> Provenance {
669 let prog = parse_to_ast(src).expect("parse");
670 match &prog.statements[0] {
671 crate::ast::Statement::Command(c) => arg_provenance(&c.arguments[0]),
672 _ => panic!("expected Command statement for: {src}"),
673 }
674 }
675
676 #[test]
677 fn provenance_word_is_literal() {
678 assert_eq!(first_arg_provenance("echo hello"), Provenance::Literal);
679 }
680
681 #[test]
682 fn provenance_number_is_literal() {
683 assert_eq!(first_arg_provenance("sleep 30"), Provenance::Literal);
685 }
686
687 #[test]
688 fn provenance_raw_string_is_literal() {
689 assert_eq!(first_arg_provenance("echo 'hello world'"), Provenance::Literal);
690 }
691
692 #[test]
693 fn provenance_glob_star() {
694 assert_eq!(first_arg_provenance("rm *.rs"), Provenance::Glob);
695 }
696
697 #[test]
698 fn provenance_glob_question_mark() {
699 assert_eq!(first_arg_provenance("ls file?.txt"), Provenance::Glob);
700 }
701
702 #[test]
703 fn provenance_glob_bracket() {
704 assert_eq!(first_arg_provenance("ls [abc].txt"), Provenance::Glob);
705 }
706
707 #[test]
708 fn provenance_simple_expansion_is_variable() {
709 assert_eq!(first_arg_provenance("echo $VAR"), Provenance::Variable);
710 }
711
712 #[test]
713 fn provenance_command_sub_is_command_sub() {
714 assert_eq!(first_arg_provenance("echo $(date)"), Provenance::CommandSub);
715 }
716
717 #[test]
718 fn provenance_process_sub_is_process_sub() {
719 assert_eq!(first_arg_provenance("diff <(ls) <(ls /tmp)"), Provenance::ProcessSub);
721 }
722
723 #[test]
724 fn provenance_double_quoted_all_literal() {
725 assert_eq!(first_arg_provenance(r#"echo "hello world""#), Provenance::Literal);
727 }
728
729 #[test]
730 fn provenance_double_quoted_with_var() {
731 assert_eq!(first_arg_provenance(r#"echo "hello $VAR""#), Provenance::Variable);
733 }
734
735 #[test]
736 fn provenance_double_quoted_with_cmd_sub() {
737 assert_eq!(first_arg_provenance(r#"echo "ts=$(date)""#), Provenance::CommandSub);
739 }
740
741 #[test]
742 fn provenance_ordering_cmd_sub_dominates_variable() {
743 assert!(Provenance::CommandSub > Provenance::Variable);
745 assert!(Provenance::Variable > Provenance::Glob);
746 assert!(Provenance::Glob > Provenance::Literal);
747 }
748
749 #[test]
750 fn provenance_absolute_path_word_is_literal() {
751 assert_eq!(first_arg_provenance("cat /etc/hosts"), Provenance::Literal);
752 }
753
754 #[test]
755 fn provenance_relative_path_word_is_literal() {
756 assert_eq!(first_arg_provenance("rm ./build/output"), Provenance::Literal);
757 }
758
759 fn camp() -> std::path::PathBuf {
762 std::path::PathBuf::from("/home/user/project")
763 }
764
765 #[test]
766 fn scope_absolute_within_camp() {
767 assert_eq!(
768 path_scope("/home/user/project/src/main.rs", &camp()),
769 PathScope::WithinCamp,
770 );
771 }
772
773 #[test]
774 fn scope_absolute_camp_root_itself() {
775 assert_eq!(path_scope("/home/user/project", &camp()), PathScope::WithinCamp);
776 }
777
778 #[test]
779 fn scope_absolute_outside_camp() {
780 assert_eq!(
781 path_scope("/home/user/other/file.rs", &camp()),
782 PathScope::OutsideCamp,
783 );
784 }
785
786 #[test]
787 fn scope_absolute_system_etc() {
788 assert_eq!(path_scope("/etc/hosts", &camp()), PathScope::System);
789 }
790
791 #[test]
792 fn scope_absolute_system_usr_bin() {
793 assert_eq!(path_scope("/usr/bin/python3", &camp()), PathScope::System);
794 }
795
796 #[test]
797 fn scope_absolute_system_root() {
798 assert_eq!(path_scope("/", &camp()), PathScope::System);
799 }
800
801 #[test]
802 fn scope_absolute_system_var() {
803 assert_eq!(path_scope("/var/log/syslog", &camp()), PathScope::System);
804 }
805
806 #[test]
807 fn scope_absolute_tmp_is_outside_not_system() {
808 assert_eq!(path_scope("/tmp/foo", &camp()), PathScope::OutsideCamp);
810 }
811
812 #[test]
813 fn scope_relative_no_escape() {
814 assert_eq!(path_scope("src/main.rs", &camp()), PathScope::WithinCamp);
815 }
816
817 #[test]
818 fn scope_relative_dot_slash() {
819 assert_eq!(path_scope("./build/output", &camp()), PathScope::WithinCamp);
820 }
821
822 #[test]
823 fn scope_relative_single_dotdot_escapes() {
824 assert_eq!(path_scope("../sibling", &camp()), PathScope::OutsideCamp);
826 }
827
828 #[test]
829 fn scope_relative_dotdot_then_back_within() {
830 assert_eq!(path_scope("../project/foo", &camp()), PathScope::OutsideCamp);
832 }
833
834 #[test]
835 fn scope_relative_descends_then_up_stays_within() {
836 assert_eq!(path_scope("src/../README.md", &camp()), PathScope::WithinCamp);
838 }
839
840 #[test]
841 fn scope_tilde_is_outside() {
842 assert_eq!(path_scope("~/secrets", &camp()), PathScope::OutsideCamp);
843 }
844
845 #[test]
846 fn scope_git_ref_is_unresolvable() {
847 assert_eq!(path_scope("HEAD~1", &camp()), PathScope::Unresolvable);
848 }
849
850 #[test]
851 fn scope_remote_name_is_unresolvable() {
852 assert_eq!(path_scope("origin", &camp()), PathScope::Unresolvable);
853 }
854
855 #[test]
856 fn scope_branch_name_is_unresolvable() {
857 assert_eq!(path_scope("main", &camp()), PathScope::Unresolvable);
858 }
859
860 #[test]
861 fn scope_empty_is_unresolvable() {
862 assert_eq!(path_scope("", &camp()), PathScope::Unresolvable);
863 }
864
865 fn pipeline_basenames(src: &str) -> Vec<Option<String>> {
868 let prog = parse_to_ast(src).expect("parse");
869 match &prog.statements[0] {
870 crate::ast::Statement::Pipeline(p) => pipeline_stage_basenames(p),
871 _ => panic!("expected Pipeline statement for: {src}"),
872 }
873 }
874
875 #[test]
876 fn pipe_two_simple_stages() {
877 let basenames = pipeline_basenames("curl url | sh");
878 assert_eq!(basenames, vec![Some("curl".to_string()), Some("sh".to_string())]);
879 }
880
881 #[test]
882 fn pipe_three_stages() {
883 let basenames = pipeline_basenames("cat file | grep pattern | wc -l");
884 assert_eq!(
885 basenames,
886 vec![
887 Some("cat".to_string()),
888 Some("grep".to_string()),
889 Some("wc".to_string()),
890 ],
891 );
892 }
893
894 #[test]
895 fn pipe_subshell_stage_returns_none() {
896 let basenames = pipeline_basenames("(cd /tmp && ls) | grep foo");
898 assert_eq!(basenames[0], None);
899 assert_eq!(basenames[1], Some("grep".to_string()));
900 }
901
902 #[test]
903 fn pipe_basename_lowercased() {
904 let basenames = pipeline_basenames("Git log | grep fix");
906 assert_eq!(basenames[0], Some("git".to_string()));
907 }
908
909 #[test]
910 fn pipe_wrapped_stage_peels() {
911 let basenames = pipeline_basenames("timeout 30 git log | grep fix");
913 assert_eq!(basenames[0], Some("git".to_string()));
914 }
915}