1use bpaf::{OptionParser, Parser, construct, long, positional, pure};
15use haz_domain::settings::cache_clean::max_age::MaxAge;
16use haz_domain::settings::cache_clean::max_size::MaxSize;
17pub use haz_domain::settings::output::OutputMode;
18pub use haz_vcs::GitFilter;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Cmd {
28 Run(RunArgs),
33 Check(CheckArgs),
36 Why(WhyArgs),
39 CacheClear,
41 CacheClean(CacheCleanArgs),
48 CacheInfo,
51 Query(QueryArgs),
55 Init(InitArgs),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct RunArgs {
70 pub targets: Vec<String>,
74 pub all: bool,
79 pub workspace: bool,
84 pub git_filter: Option<GitFilter>,
91 pub dry_run: bool,
94 pub output_mode: Option<OutputMode>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Default)]
108pub struct CheckArgs {
109 pub all: bool,
113 pub workspace: bool,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct WhyArgs {
128 pub target: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct CacheCleanArgs {
147 pub soft: bool,
149 pub max_age: Option<MaxAge>,
152 pub max_size: Option<MaxSize>,
156 pub dry_run: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Default)]
175pub struct QueryArgs {
176 pub tags: Option<String>,
178 pub projects: Option<String>,
180 pub tasks: Option<String>,
182 pub inputs: Option<String>,
184 pub outputs: Option<String>,
186 pub child_of: Option<String>,
188 pub parent_of: Option<String>,
190 pub depends_on: Option<String>,
192 pub ancestor_of: Option<String>,
194 pub deps_presence: FieldPresence,
196 pub inputs_presence: FieldPresence,
198 pub outputs_presence: FieldPresence,
200 pub mutex: bool,
202 pub format: OutputFormat,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Default)]
214pub struct InitArgs {
215 pub yes: bool,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
225pub enum FieldPresence {
226 #[default]
229 Any,
230 NonEmpty,
232 Empty,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
238pub enum OutputFormat {
239 #[default]
242 Plain,
243 Json,
246}
247
248pub fn cli() -> OptionParser<Cmd> {
254 let run = run_command().map(Cmd::Run).to_options();
255 let check = check_command().map(Cmd::Check).to_options();
256 let why = why_command().map(Cmd::Why).to_options();
257 let cache = cache_command().to_options();
258 let query = query_command().map(Cmd::Query).to_options();
259 let init = init_command().map(Cmd::Init).to_options();
260
261 let run_subcommand = run.command("run").help("Run one or more tasks");
262 let check_subcommand = check
263 .command("check")
264 .help("Validate the workspace without running (AUX-009..AUX-012)");
265 let why_subcommand = why
266 .command("why")
267 .help("Introspect a single task (AUX-013..AUX-016)");
268 let cache_subcommand = cache
269 .command("cache")
270 .help("Cache management commands (CACHE-021, CACHE-022)");
271 let query_subcommand = query
272 .command("query")
273 .help("Enumerate tasks under a filter expression (QRY-001..QRY-009)");
274 let init_subcommand = init
275 .command("init")
276 .help("Scaffold a workspace (AUX-001..AUX-008)");
277
278 construct!([
279 run_subcommand,
280 check_subcommand,
281 why_subcommand,
282 cache_subcommand,
283 query_subcommand,
284 init_subcommand,
285 ])
286 .to_options()
287 .version(env!("CARGO_PKG_VERSION"))
288 .descr("haz: task dependency graph runner")
289}
290
291fn run_command() -> impl Parser<RunArgs> {
292 let all = long("all")
293 .help("Run every task in the bearing scope (EXEC-024)")
294 .switch();
295 let workspace = long("workspace")
296 .help("Run every task in every project, regardless of cwd (EXEC-025)")
297 .switch();
298 let dry_run = long("dry-run")
299 .help("Enumerate the target set without spawning any task (EXEC-030)")
300 .switch();
301 let git_filter = git_filter_parser();
302 let output_mode = long("output-mode")
303 .help("Output presentation mode (live | buffered)")
304 .argument::<String>("MODE")
305 .parse(|s| match s.as_ref() {
306 "live" => Ok(OutputMode::Live),
307 "buffered" => Ok(OutputMode::Buffered),
308 other => Err(format!(
309 "unknown output mode `{other}` (expected `live` or `buffered`)"
310 )),
311 })
312 .optional();
313 let targets = positional::<String>("TASK")
314 .help("Task reference (e.g. `proj:task`, `^:task`)")
315 .many();
316 construct!(RunArgs {
321 all,
322 workspace,
323 git_filter,
324 dry_run,
325 output_mode,
326 targets,
327 })
328 .parse(|args: RunArgs| {
335 let has_positionals = !args.targets.is_empty();
336 let has_git_filter = args.git_filter.is_some();
337 match (args.all, args.workspace, has_git_filter, has_positionals) {
338 (true, true, _, _) => {
339 Err("`--all` and `--workspace` are mutually exclusive".to_owned())
340 }
341 (true, _, _, true) => {
342 Err("`--all` cannot be combined with positional task references".to_owned())
343 }
344 (_, true, _, true) => {
345 Err("`--workspace` cannot be combined with positional task references".to_owned())
346 }
347 (_, _, true, true) => Err(
348 "git-changeset filters cannot be combined with positional task references"
349 .to_owned(),
350 ),
351 _ => Ok(args),
352 }
353 })
354}
355
356fn git_filter_parser() -> impl Parser<Option<GitFilter>> {
364 let changed = long("git-changed")
365 .help("Restrict targets to tasks affected by working-tree changes vs HEAD (EXEC-027)")
366 .switch();
367 let staged = long("git-staged")
368 .help("Restrict targets to tasks affected by staged changes vs HEAD (EXEC-028)")
369 .switch();
370 let changed_since = long("git-changed-since")
371 .help("Restrict targets to tasks affected by changes since <REV> (EXEC-029)")
372 .argument::<String>("REV")
373 .optional();
374 construct!(changed, staged, changed_since).parse(|(changed, staged, changed_since)| {
375 match (changed, staged, changed_since) {
376 (false, false, None) => Ok(None),
377 (true, false, None) => Ok(Some(GitFilter::Changed)),
378 (false, true, None) => Ok(Some(GitFilter::Staged)),
379 (false, false, Some(rev)) => Ok(Some(GitFilter::ChangedSince(rev))),
380 _ => Err("git-changeset filters are mutually exclusive".to_owned()),
381 }
382 })
383}
384
385fn check_command() -> impl Parser<CheckArgs> {
386 let all = long("all")
387 .help("Validate every task in the bearing scope (AUX-009, mirrors EXEC-024)")
388 .switch();
389 let workspace = long("workspace")
390 .help("Validate every task in every project, regardless of cwd (AUX-009, mirrors EXEC-025)")
391 .switch();
392 construct!(CheckArgs { all, workspace })
393 .parse(|args: CheckArgs| {
396 if args.all && args.workspace {
397 Err("`--all` and `--workspace` are mutually exclusive".to_owned())
398 } else {
399 Ok(args)
400 }
401 })
402}
403
404fn why_command() -> impl Parser<WhyArgs> {
405 let target = positional::<String>("TASK").help("Task reference (REF-001..REF-008)");
409 construct!(WhyArgs { target })
410}
411
412fn cache_command() -> impl Parser<Cmd> {
413 let remove_all = pure(Cmd::CacheClear)
421 .to_options()
422 .command("clear")
423 .help("Remove every cache entry (CACHE-021)");
424 let prune_stale = clean_command()
425 .to_options()
426 .command("clean")
427 .help("Selectively reclaim cache entries (CACHE-022, AUX-022..AUX-027)");
428 let report_info = pure(Cmd::CacheInfo)
429 .to_options()
430 .command("info")
431 .help("Report cache-state statistics (AUX-017..AUX-021)");
432 construct!([remove_all, prune_stale, report_info])
433}
434
435fn clean_command() -> impl Parser<Cmd> {
436 let soft = long("soft")
441 .help("Reclaim objectively-stale state (CACHE-022)")
442 .switch();
443 let max_age = long("max-age")
444 .help("Evict entries strictly older than DURATION (AUX-022)")
445 .argument::<String>("DURATION")
446 .parse(|s| MaxAge::parse(&s).map_err(|e| e.to_string()))
447 .optional();
448 let max_size = long("max-size")
449 .help("Cap on-disk footprint at BYTES, evicting oldest first (AUX-022)")
450 .argument::<String>("BYTES")
451 .parse(|s| MaxSize::parse(&s).map_err(|e| e.to_string()))
452 .optional();
453 let dry_run = long("dry-run")
454 .help("Compute the eviction set without removing anything (AUX-024)")
455 .switch();
456 construct!(CacheCleanArgs {
457 soft,
458 max_age,
459 max_size,
460 dry_run,
461 })
462 .map(Cmd::CacheClean)
463}
464
465fn query_command() -> impl Parser<QueryArgs> {
466 let tags = long("tags")
467 .help("Filter by owning-project tag expression (QRY-003)")
468 .argument::<String>("EXPR")
469 .optional();
470 let projects = long("projects")
471 .help("Filter by project-name expression (QRY-003)")
472 .argument::<String>("EXPR")
473 .optional();
474 let tasks = long("tasks")
475 .help("Filter by task-name expression (QRY-003)")
476 .argument::<String>("EXPR")
477 .optional();
478 let inputs = long("inputs")
479 .help("Filter by inputs path-pattern expression (QRY-003)")
480 .argument::<String>("EXPR")
481 .optional();
482 let outputs = long("outputs")
483 .help("Filter by outputs path-pattern expression (QRY-003)")
484 .argument::<String>("EXPR")
485 .optional();
486 let child_of = long("child-of")
487 .help("Direct hard-edge predecessor must satisfy EXPR (QRY-004)")
488 .argument::<String>("EXPR")
489 .optional();
490 let parent_of = long("parent-of")
491 .help("Direct hard-edge successor must satisfy EXPR (QRY-004)")
492 .argument::<String>("EXPR")
493 .optional();
494 let depends_on = long("depends-on")
495 .help("Transitive hard-edge predecessor must satisfy EXPR (QRY-004)")
496 .argument::<String>("EXPR")
497 .optional();
498 let ancestor_of = long("ancestor-of")
499 .help("Transitive hard-edge successor must satisfy EXPR (QRY-004)")
500 .argument::<String>("EXPR")
501 .optional();
502
503 let deps_presence = presence_parser(
504 "has-deps",
505 "no-deps",
506 "Select tasks whose `deps:` is non-empty (QRY-005)",
507 "Select tasks whose `deps:` is empty (QRY-005)",
508 );
509 let inputs_presence = presence_parser(
510 "has-inputs",
511 "no-inputs",
512 "Select tasks whose `inputs:` is non-empty (QRY-005)",
513 "Select tasks whose `inputs:` is empty (QRY-005)",
514 );
515 let outputs_presence = presence_parser(
516 "has-outputs",
517 "no-outputs",
518 "Select tasks whose `outputs:` is non-empty (QRY-005)",
519 "Select tasks whose `outputs:` is empty (QRY-005)",
520 );
521 let mutex = long("mutex")
522 .help("Select tasks that declare a `mutex:` field (QRY-005)")
523 .switch();
524
525 let format = long("format")
526 .help("Output format (plain | json) per QRY-008")
527 .argument::<String>("FMT")
528 .parse(|s| match s.as_ref() {
529 "plain" => Ok(OutputFormat::Plain),
530 "json" => Ok(OutputFormat::Json),
531 other => Err(format!(
532 "unknown output format `{other}` (expected `plain` or `json`)"
533 )),
534 })
535 .fallback(OutputFormat::Plain);
536
537 construct!(QueryArgs {
538 tags,
539 projects,
540 tasks,
541 inputs,
542 outputs,
543 child_of,
544 parent_of,
545 depends_on,
546 ancestor_of,
547 deps_presence,
548 inputs_presence,
549 outputs_presence,
550 mutex,
551 format,
552 })
553}
554
555fn init_command() -> impl Parser<InitArgs> {
556 let yes = long("yes")
557 .help("Skip the interactive flow and write a minimal workspace (AUX-007)")
558 .switch();
559 construct!(InitArgs { yes })
560}
561
562fn presence_parser(
567 has_flag: &'static str,
568 no_flag: &'static str,
569 has_help: &'static str,
570 no_help: &'static str,
571) -> impl Parser<FieldPresence> {
572 let has = long(has_flag).help(has_help).switch();
573 let no = long(no_flag).help(no_help).switch();
574 construct!(has, no).parse(move |(has, no)| match (has, no) {
575 (false, false) => Ok(FieldPresence::Any),
576 (true, false) => Ok(FieldPresence::NonEmpty),
577 (false, true) => Ok(FieldPresence::Empty),
578 (true, true) => Err(format!(
579 "`--{has_flag}` and `--{no_flag}` are mutually exclusive"
580 )),
581 })
582}
583
584#[cfg(test)]
585mod tests {
586 use super::{
587 CacheCleanArgs, CheckArgs, Cmd, FieldPresence, GitFilter, InitArgs, MaxAge, MaxSize,
588 OutputFormat, OutputMode, QueryArgs, RunArgs, WhyArgs, cli,
589 };
590
591 fn parse(args: &[&str]) -> Result<Cmd, String> {
592 cli().run_inner(args).map_err(|err| err.unwrap_stderr())
593 }
594
595 fn bare_run_args() -> RunArgs {
596 RunArgs {
597 targets: Vec::new(),
598 all: false,
599 workspace: false,
600 git_filter: None,
601 dry_run: false,
602 output_mode: None,
603 }
604 }
605
606 #[test]
607 fn run_with_no_targets_parses() {
608 let parsed = parse(&["run"]).unwrap();
609 assert_eq!(parsed, Cmd::Run(bare_run_args()));
610 }
611
612 #[test]
613 fn run_with_one_target_parses() {
614 let parsed = parse(&["run", "lib:build"]).unwrap();
615 assert_eq!(
616 parsed,
617 Cmd::Run(RunArgs {
618 targets: vec!["lib:build".to_owned()],
619 ..bare_run_args()
620 }),
621 );
622 }
623
624 #[test]
625 fn run_with_multiple_targets_parses_in_order() {
626 let parsed = parse(&["run", "lib:build", "app:test", "^:lint"]).unwrap();
627 assert_eq!(
628 parsed,
629 Cmd::Run(RunArgs {
630 targets: vec![
631 "lib:build".to_owned(),
632 "app:test".to_owned(),
633 "^:lint".to_owned(),
634 ],
635 ..bare_run_args()
636 }),
637 );
638 }
639
640 #[test]
641 fn run_with_output_mode_live() {
642 let parsed = parse(&["run", "--output-mode", "live", "lib:build"]).unwrap();
643 assert_eq!(
644 parsed,
645 Cmd::Run(RunArgs {
646 targets: vec!["lib:build".to_owned()],
647 output_mode: Some(OutputMode::Live),
648 ..bare_run_args()
649 }),
650 );
651 }
652
653 #[test]
654 fn run_with_output_mode_buffered() {
655 let parsed = parse(&["run", "--output-mode", "buffered", "lib:build"]).unwrap();
656 assert_eq!(
657 parsed,
658 Cmd::Run(RunArgs {
659 targets: vec!["lib:build".to_owned()],
660 output_mode: Some(OutputMode::Buffered),
661 ..bare_run_args()
662 }),
663 );
664 }
665
666 #[test]
667 fn run_with_all_flag_parses() {
668 let parsed = parse(&["run", "--all"]).unwrap();
669 assert_eq!(
670 parsed,
671 Cmd::Run(RunArgs {
672 all: true,
673 ..bare_run_args()
674 }),
675 );
676 }
677
678 #[test]
679 fn run_with_workspace_flag_parses() {
680 let parsed = parse(&["run", "--workspace"]).unwrap();
681 assert_eq!(
682 parsed,
683 Cmd::Run(RunArgs {
684 workspace: true,
685 ..bare_run_args()
686 }),
687 );
688 }
689
690 #[test]
691 fn run_with_dry_run_flag_parses() {
692 let parsed = parse(&["run", "--dry-run"]).unwrap();
693 assert_eq!(
694 parsed,
695 Cmd::Run(RunArgs {
696 dry_run: true,
697 ..bare_run_args()
698 }),
699 );
700 }
701
702 #[test]
703 fn run_with_dry_run_composes_with_positional() {
704 let parsed = parse(&["run", "--dry-run", "lib:build"]).unwrap();
705 assert_eq!(
706 parsed,
707 Cmd::Run(RunArgs {
708 targets: vec!["lib:build".to_owned()],
709 dry_run: true,
710 ..bare_run_args()
711 }),
712 );
713 }
714
715 #[test]
716 fn run_with_dry_run_composes_with_all() {
717 let parsed = parse(&["run", "--dry-run", "--all"]).unwrap();
718 assert_eq!(
719 parsed,
720 Cmd::Run(RunArgs {
721 all: true,
722 dry_run: true,
723 ..bare_run_args()
724 }),
725 );
726 }
727
728 #[test]
729 fn run_with_dry_run_composes_with_workspace() {
730 let parsed = parse(&["run", "--dry-run", "--workspace"]).unwrap();
731 assert_eq!(
732 parsed,
733 Cmd::Run(RunArgs {
734 workspace: true,
735 dry_run: true,
736 ..bare_run_args()
737 }),
738 );
739 }
740
741 #[test]
742 fn run_all_plus_workspace_rejects() {
743 let err = parse(&["run", "--all", "--workspace"]).unwrap_err();
745 assert!(
746 err.contains("mutually exclusive"),
747 "diagnostic should name the mutex: {err}",
748 );
749 }
750
751 #[test]
752 fn run_all_plus_positional_rejects() {
753 let err = parse(&["run", "--all", "lib:build"]).unwrap_err();
755 assert!(
756 err.contains("`--all`") && err.contains("positional"),
757 "diagnostic should mention --all and positional refs: {err}",
758 );
759 }
760
761 #[test]
762 fn run_workspace_plus_positional_rejects() {
763 let err = parse(&["run", "--workspace", "lib:build"]).unwrap_err();
765 assert!(
766 err.contains("`--workspace`") && err.contains("positional"),
767 "diagnostic should mention --workspace and positional refs: {err}",
768 );
769 }
770
771 #[test]
772 fn run_with_git_changed_parses() {
773 let parsed = parse(&["run", "--git-changed"]).unwrap();
774 assert_eq!(
775 parsed,
776 Cmd::Run(RunArgs {
777 git_filter: Some(GitFilter::Changed),
778 ..bare_run_args()
779 }),
780 );
781 }
782
783 #[test]
784 fn run_with_git_staged_parses() {
785 let parsed = parse(&["run", "--git-staged"]).unwrap();
786 assert_eq!(
787 parsed,
788 Cmd::Run(RunArgs {
789 git_filter: Some(GitFilter::Staged),
790 ..bare_run_args()
791 }),
792 );
793 }
794
795 #[test]
796 fn run_with_git_changed_since_parses() {
797 let parsed = parse(&["run", "--git-changed-since", "main"]).unwrap();
798 assert_eq!(
799 parsed,
800 Cmd::Run(RunArgs {
801 git_filter: Some(GitFilter::ChangedSince("main".to_owned())),
802 ..bare_run_args()
803 }),
804 );
805 }
806
807 #[test]
808 fn run_git_changed_plus_all_composes() {
809 let parsed = parse(&["run", "--git-changed", "--all"]).unwrap();
811 assert_eq!(
812 parsed,
813 Cmd::Run(RunArgs {
814 all: true,
815 git_filter: Some(GitFilter::Changed),
816 ..bare_run_args()
817 }),
818 );
819 }
820
821 #[test]
822 fn run_git_changed_plus_workspace_composes() {
823 let parsed = parse(&["run", "--git-changed", "--workspace"]).unwrap();
824 assert_eq!(
825 parsed,
826 Cmd::Run(RunArgs {
827 workspace: true,
828 git_filter: Some(GitFilter::Changed),
829 ..bare_run_args()
830 }),
831 );
832 }
833
834 #[test]
835 fn run_git_changed_plus_staged_rejects() {
836 let err = parse(&["run", "--git-changed", "--git-staged"]).unwrap_err();
838 assert!(
839 err.contains("mutually exclusive"),
840 "diagnostic should name the intra-family mutex: {err}",
841 );
842 }
843
844 #[test]
845 fn run_git_changed_plus_changed_since_rejects() {
846 let err = parse(&["run", "--git-changed", "--git-changed-since", "main"]).unwrap_err();
847 assert!(
848 err.contains("mutually exclusive"),
849 "diagnostic should name the intra-family mutex: {err}",
850 );
851 }
852
853 #[test]
854 fn run_git_staged_plus_changed_since_rejects() {
855 let err = parse(&["run", "--git-staged", "--git-changed-since", "main"]).unwrap_err();
856 assert!(
857 err.contains("mutually exclusive"),
858 "diagnostic should name the intra-family mutex: {err}",
859 );
860 }
861
862 #[test]
863 fn run_git_changed_plus_positional_rejects() {
864 let err = parse(&["run", "--git-changed", "lib:build"]).unwrap_err();
866 assert!(
867 err.contains("git-changeset") && err.contains("positional"),
868 "diagnostic should mention the filter-vs-positional mutex: {err}",
869 );
870 }
871
872 #[test]
873 fn run_git_staged_plus_positional_rejects() {
874 let err = parse(&["run", "--git-staged", "lib:build"]).unwrap_err();
875 assert!(
876 err.contains("git-changeset") && err.contains("positional"),
877 "diagnostic should mention the filter-vs-positional mutex: {err}",
878 );
879 }
880
881 #[test]
882 fn run_git_changed_since_plus_positional_rejects() {
883 let err = parse(&["run", "--git-changed-since", "main", "lib:build"]).unwrap_err();
884 assert!(
885 err.contains("git-changeset") && err.contains("positional"),
886 "diagnostic should mention the filter-vs-positional mutex: {err}",
887 );
888 }
889
890 #[test]
891 fn run_with_unknown_output_mode_rejects() {
892 let err = parse(&["run", "--output-mode", "noisy", "lib:build"]).unwrap_err();
893 assert!(
894 err.contains("unknown output mode"),
895 "diagnostic should name the offending value: {err}",
896 );
897 }
898
899 #[test]
900 fn aux_009_check_with_no_args_parses() {
901 assert_eq!(parse(&["check"]).unwrap(), Cmd::Check(CheckArgs::default()),);
902 }
903
904 #[test]
905 fn aux_009_check_with_all_flag_parses() {
906 assert_eq!(
907 parse(&["check", "--all"]).unwrap(),
908 Cmd::Check(CheckArgs {
909 all: true,
910 workspace: false,
911 }),
912 );
913 }
914
915 #[test]
916 fn aux_009_check_with_workspace_flag_parses() {
917 assert_eq!(
918 parse(&["check", "--workspace"]).unwrap(),
919 Cmd::Check(CheckArgs {
920 all: false,
921 workspace: true,
922 }),
923 );
924 }
925
926 #[test]
927 fn aux_009_check_all_plus_workspace_rejects() {
928 let err = parse(&["check", "--all", "--workspace"]).unwrap_err();
930 assert!(
931 err.contains("mutually exclusive"),
932 "diagnostic should name the mutex: {err}",
933 );
934 }
935
936 #[test]
937 fn aux_009_check_rejects_unknown_flag() {
938 let err = parse(&["check", "--dry-run"]).unwrap_err();
941 assert!(
942 !err.is_empty(),
943 "unknown flag must produce a diagnostic: {err}",
944 );
945 }
946
947 #[test]
948 fn aux_009_check_rejects_positional() {
949 let err = parse(&["check", "lib:build"]).unwrap_err();
952 assert!(
953 !err.is_empty(),
954 "positional argument must produce a diagnostic: {err}",
955 );
956 }
957
958 #[test]
961 fn aux_013_why_parses_a_single_positional_target() {
962 assert_eq!(
963 parse(&["why", "lib:build"]).unwrap(),
964 Cmd::Why(WhyArgs {
965 target: "lib:build".to_owned(),
966 }),
967 );
968 }
969
970 #[test]
971 fn aux_013_why_accepts_fan_out_reference_form_at_parse_time() {
972 assert_eq!(
977 parse(&["why", "^:test"]).unwrap(),
978 Cmd::Why(WhyArgs {
979 target: "^:test".to_owned(),
980 }),
981 );
982 }
983
984 #[test]
985 fn aux_013_why_rejects_missing_positional() {
986 let err = parse(&["why"]).unwrap_err();
988 assert!(
989 !err.is_empty(),
990 "missing positional must produce a diagnostic: {err}",
991 );
992 }
993
994 #[test]
995 fn aux_013_why_rejects_extra_positional() {
996 let err = parse(&["why", "lib:build", "other:task"]).unwrap_err();
998 assert!(
999 !err.is_empty(),
1000 "extra positional must produce a diagnostic: {err}",
1001 );
1002 }
1003
1004 #[test]
1005 fn aux_013_why_rejects_unknown_flag() {
1006 let err = parse(&["why", "--all", "lib:build"]).unwrap_err();
1008 assert!(
1009 !err.is_empty(),
1010 "unknown flag must produce a diagnostic: {err}",
1011 );
1012 }
1013
1014 #[test]
1017 fn cache_clear_parses() {
1018 assert_eq!(parse(&["cache", "clear"]).unwrap(), Cmd::CacheClear);
1019 }
1020
1021 #[test]
1022 fn cache_info_parses() {
1023 assert_eq!(parse(&["cache", "info"]).unwrap(), Cmd::CacheInfo);
1024 }
1025
1026 #[test]
1027 fn aux_017_cache_info_rejects_positional() {
1028 let err = parse(&["cache", "info", "extra"]).unwrap_err();
1031 assert!(
1032 !err.is_empty(),
1033 "extra positional must produce a diagnostic: {err}",
1034 );
1035 }
1036
1037 #[test]
1038 fn aux_017_cache_info_rejects_unknown_flag() {
1039 let err = parse(&["cache", "info", "--soft"]).unwrap_err();
1041 assert!(
1042 !err.is_empty(),
1043 "unknown flag must produce a diagnostic: {err}",
1044 );
1045 }
1046
1047 #[test]
1048 fn cache_clean_soft_parses() {
1049 assert_eq!(
1050 parse(&["cache", "clean", "--soft"]).unwrap(),
1051 Cmd::CacheClean(CacheCleanArgs {
1052 soft: true,
1053 ..CacheCleanArgs::default()
1054 }),
1055 );
1056 }
1057
1058 #[test]
1059 fn cache_clean_bare_parses_with_no_modes() {
1060 assert_eq!(
1065 parse(&["cache", "clean"]).unwrap(),
1066 Cmd::CacheClean(CacheCleanArgs::default()),
1067 );
1068 }
1069
1070 #[test]
1071 fn cache_clean_max_age_parses_valid_duration() {
1072 let want = MaxAge::parse("90d").unwrap();
1073 assert_eq!(
1074 parse(&["cache", "clean", "--max-age", "90d"]).unwrap(),
1075 Cmd::CacheClean(CacheCleanArgs {
1076 max_age: Some(want),
1077 ..CacheCleanArgs::default()
1078 }),
1079 );
1080 }
1081
1082 #[test]
1083 fn cache_clean_max_age_rejects_bare_integer() {
1084 let err = parse(&["cache", "clean", "--max-age", "90"]).unwrap_err();
1087 assert!(
1088 !err.is_empty(),
1089 "malformed --max-age must produce a diagnostic: {err}",
1090 );
1091 }
1092
1093 #[test]
1094 fn cache_clean_max_size_parses_si_suffix() {
1095 let want = MaxSize::parse("1GB").unwrap();
1096 assert_eq!(
1097 parse(&["cache", "clean", "--max-size", "1GB"]).unwrap(),
1098 Cmd::CacheClean(CacheCleanArgs {
1099 max_size: Some(want),
1100 ..CacheCleanArgs::default()
1101 }),
1102 );
1103 }
1104
1105 #[test]
1106 fn cache_clean_max_size_rejects_unknown_suffix() {
1107 let err = parse(&["cache", "clean", "--max-size", "1ZB"]).unwrap_err();
1108 assert!(
1109 !err.is_empty(),
1110 "malformed --max-size must produce a diagnostic: {err}",
1111 );
1112 }
1113
1114 #[test]
1115 fn cache_clean_dry_run_composes_with_other_flags() {
1116 let want_age = MaxAge::parse("30d").unwrap();
1117 assert_eq!(
1118 parse(&["cache", "clean", "--soft", "--max-age", "30d", "--dry-run"]).unwrap(),
1119 Cmd::CacheClean(CacheCleanArgs {
1120 soft: true,
1121 max_age: Some(want_age),
1122 max_size: None,
1123 dry_run: true,
1124 }),
1125 );
1126 }
1127
1128 #[test]
1129 fn no_subcommand_rejects() {
1130 let err = parse(&[]).unwrap_err();
1131 assert!(
1132 !err.is_empty(),
1133 "missing subcommand must produce a diagnostic",
1134 );
1135 }
1136
1137 #[test]
1140 fn qry_001_query_with_no_flags_parses_to_default() {
1141 assert_eq!(parse(&["query"]).unwrap(), Cmd::Query(QueryArgs::default()),);
1142 }
1143
1144 #[test]
1145 fn qry_003_tags_filter_captures_expression_verbatim() {
1146 assert_eq!(
1147 parse(&["query", "--tags", "backend & !legacy"]).unwrap(),
1148 Cmd::Query(QueryArgs {
1149 tags: Some("backend & !legacy".to_owned()),
1150 ..QueryArgs::default()
1151 }),
1152 );
1153 }
1154
1155 #[test]
1156 fn qry_003_projects_filter_captures_expression_verbatim() {
1157 assert_eq!(
1158 parse(&["query", "--projects", "lib | web"]).unwrap(),
1159 Cmd::Query(QueryArgs {
1160 projects: Some("lib | web".to_owned()),
1161 ..QueryArgs::default()
1162 }),
1163 );
1164 }
1165
1166 #[test]
1167 fn qry_003_tasks_filter_captures_expression_verbatim() {
1168 assert_eq!(
1169 parse(&["query", "--tasks", "build"]).unwrap(),
1170 Cmd::Query(QueryArgs {
1171 tasks: Some("build".to_owned()),
1172 ..QueryArgs::default()
1173 }),
1174 );
1175 }
1176
1177 #[test]
1178 fn qry_003_inputs_and_outputs_filters_capture_expressions_verbatim() {
1179 assert_eq!(
1180 parse(&[
1181 "query",
1182 "--inputs",
1183 "src/**/*.rs",
1184 "--outputs",
1185 "/lib/dist/*.so",
1186 ])
1187 .unwrap(),
1188 Cmd::Query(QueryArgs {
1189 inputs: Some("src/**/*.rs".to_owned()),
1190 outputs: Some("/lib/dist/*.so".to_owned()),
1191 ..QueryArgs::default()
1192 }),
1193 );
1194 }
1195
1196 #[test]
1197 fn qry_004_relational_filters_capture_expressions_verbatim() {
1198 assert_eq!(
1199 parse(&[
1200 "query",
1201 "--child-of",
1202 "name:build",
1203 "--parent-of",
1204 "tag:backend",
1205 "--depends-on",
1206 "project:lib",
1207 "--ancestor-of",
1208 "name:lint",
1209 ])
1210 .unwrap(),
1211 Cmd::Query(QueryArgs {
1212 child_of: Some("name:build".to_owned()),
1213 parent_of: Some("tag:backend".to_owned()),
1214 depends_on: Some("project:lib".to_owned()),
1215 ancestor_of: Some("name:lint".to_owned()),
1216 ..QueryArgs::default()
1217 }),
1218 );
1219 }
1220
1221 #[test]
1222 fn qry_005_individual_shortcut_flags_parse() {
1223 let parsed = parse(&[
1224 "query",
1225 "--has-deps",
1226 "--has-inputs",
1227 "--no-outputs",
1228 "--mutex",
1229 ])
1230 .unwrap();
1231 assert_eq!(
1232 parsed,
1233 Cmd::Query(QueryArgs {
1234 deps_presence: FieldPresence::NonEmpty,
1235 inputs_presence: FieldPresence::NonEmpty,
1236 outputs_presence: FieldPresence::Empty,
1237 mutex: true,
1238 ..QueryArgs::default()
1239 }),
1240 );
1241 }
1242
1243 #[test]
1244 fn qry_005_has_deps_plus_no_deps_rejects() {
1245 let err = parse(&["query", "--has-deps", "--no-deps"]).unwrap_err();
1246 assert!(
1247 err.contains("mutually exclusive"),
1248 "diagnostic should name the intra-pair mutex: {err}",
1249 );
1250 }
1251
1252 #[test]
1253 fn qry_005_has_inputs_plus_no_inputs_rejects() {
1254 let err = parse(&["query", "--has-inputs", "--no-inputs"]).unwrap_err();
1255 assert!(
1256 err.contains("mutually exclusive"),
1257 "diagnostic should name the intra-pair mutex: {err}",
1258 );
1259 }
1260
1261 #[test]
1262 fn qry_005_has_outputs_plus_no_outputs_rejects() {
1263 let err = parse(&["query", "--has-outputs", "--no-outputs"]).unwrap_err();
1264 assert!(
1265 err.contains("mutually exclusive"),
1266 "diagnostic should name the intra-pair mutex: {err}",
1267 );
1268 }
1269
1270 #[test]
1271 fn qry_008_format_plain_parses() {
1272 assert_eq!(
1273 parse(&["query", "--format", "plain"]).unwrap(),
1274 Cmd::Query(QueryArgs {
1275 format: OutputFormat::Plain,
1276 ..QueryArgs::default()
1277 }),
1278 );
1279 }
1280
1281 #[test]
1282 fn qry_008_format_json_parses() {
1283 assert_eq!(
1284 parse(&["query", "--format", "json"]).unwrap(),
1285 Cmd::Query(QueryArgs {
1286 format: OutputFormat::Json,
1287 ..QueryArgs::default()
1288 }),
1289 );
1290 }
1291
1292 #[test]
1293 fn qry_008_format_default_is_plain() {
1294 let Cmd::Query(args) = parse(&["query"]).unwrap() else {
1295 panic!("expected Cmd::Query");
1296 };
1297 assert_eq!(args.format, OutputFormat::Plain);
1298 }
1299
1300 #[test]
1301 fn qry_008_unknown_format_rejects() {
1302 let err = parse(&["query", "--format", "yaml"]).unwrap_err();
1303 assert!(
1304 err.contains("unknown output format"),
1305 "diagnostic should name the offending value: {err}",
1306 );
1307 }
1308
1309 #[test]
1310 fn qry_006_multiple_filter_families_combine_into_one_args() {
1311 let parsed = parse(&[
1312 "query",
1313 "--tags",
1314 "backend",
1315 "--tasks",
1316 "test",
1317 "--has-deps",
1318 "--format",
1319 "json",
1320 ])
1321 .unwrap();
1322 assert_eq!(
1323 parsed,
1324 Cmd::Query(QueryArgs {
1325 tags: Some("backend".to_owned()),
1326 tasks: Some("test".to_owned()),
1327 deps_presence: FieldPresence::NonEmpty,
1328 format: OutputFormat::Json,
1329 ..QueryArgs::default()
1330 }),
1331 );
1332 }
1333
1334 #[test]
1335 fn qry_001_query_rejects_positional_arguments() {
1336 let err = parse(&["query", "lib:build"]).unwrap_err();
1337 assert!(
1338 !err.is_empty(),
1339 "positional argument must produce a diagnostic: {err}",
1340 );
1341 }
1342
1343 #[test]
1346 fn aux_001_init_bare_parses_with_yes_false() {
1347 assert_eq!(
1348 parse(&["init"]).unwrap(),
1349 Cmd::Init(InitArgs { yes: false }),
1350 );
1351 }
1352
1353 #[test]
1354 fn aux_001_init_with_yes_flag_parses() {
1355 assert_eq!(
1356 parse(&["init", "--yes"]).unwrap(),
1357 Cmd::Init(InitArgs { yes: true }),
1358 );
1359 }
1360
1361 #[test]
1362 fn aux_001_init_rejects_positional_argument() {
1363 let err = parse(&["init", "extra"]).unwrap_err();
1364 assert!(
1365 !err.is_empty(),
1366 "positional argument must produce a diagnostic: {err}",
1367 );
1368 }
1369
1370 #[test]
1371 fn aux_001_init_rejects_unknown_flag() {
1372 let err = parse(&["init", "--no-such-flag"]).unwrap_err();
1373 assert!(
1374 !err.is_empty(),
1375 "unknown flag must produce a diagnostic: {err}",
1376 );
1377 }
1378}