use bpaf::{OptionParser, Parser, construct, long, positional, pure};
use haz_domain::settings::cache_clean::max_age::MaxAge;
use haz_domain::settings::cache_clean::max_size::MaxSize;
pub use haz_domain::settings::output::OutputMode;
pub use haz_vcs::GitFilter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cmd {
Run(RunArgs),
Check(CheckArgs),
Why(WhyArgs),
CacheClear,
CacheClean(CacheCleanArgs),
CacheInfo,
Query(QueryArgs),
Init(InitArgs),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunArgs {
pub targets: Vec<String>,
pub all: bool,
pub workspace: bool,
pub git_filter: Option<GitFilter>,
pub dry_run: bool,
pub output_mode: Option<OutputMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CheckArgs {
pub all: bool,
pub workspace: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhyArgs {
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CacheCleanArgs {
pub soft: bool,
pub max_age: Option<MaxAge>,
pub max_size: Option<MaxSize>,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct QueryArgs {
pub tags: Option<String>,
pub projects: Option<String>,
pub tasks: Option<String>,
pub inputs: Option<String>,
pub outputs: Option<String>,
pub child_of: Option<String>,
pub parent_of: Option<String>,
pub depends_on: Option<String>,
pub ancestor_of: Option<String>,
pub deps_presence: FieldPresence,
pub inputs_presence: FieldPresence,
pub outputs_presence: FieldPresence,
pub mutex: bool,
pub format: OutputFormat,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InitArgs {
pub yes: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FieldPresence {
#[default]
Any,
NonEmpty,
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Plain,
Json,
}
pub fn cli() -> OptionParser<Cmd> {
let run = run_command().map(Cmd::Run).to_options();
let check = check_command().map(Cmd::Check).to_options();
let why = why_command().map(Cmd::Why).to_options();
let cache = cache_command().to_options();
let query = query_command().map(Cmd::Query).to_options();
let init = init_command().map(Cmd::Init).to_options();
let run_subcommand = run.command("run").help("Run one or more tasks");
let check_subcommand = check
.command("check")
.help("Validate the workspace without running (AUX-009..AUX-012)");
let why_subcommand = why
.command("why")
.help("Introspect a single task (AUX-013..AUX-016)");
let cache_subcommand = cache
.command("cache")
.help("Cache management commands (CACHE-021, CACHE-022)");
let query_subcommand = query
.command("query")
.help("Enumerate tasks under a filter expression (QRY-001..QRY-009)");
let init_subcommand = init
.command("init")
.help("Scaffold a workspace (AUX-001..AUX-008)");
construct!([
run_subcommand,
check_subcommand,
why_subcommand,
cache_subcommand,
query_subcommand,
init_subcommand,
])
.to_options()
.version(env!("CARGO_PKG_VERSION"))
.descr("haz: task dependency graph runner")
}
fn run_command() -> impl Parser<RunArgs> {
let all = long("all")
.help("Run every task in the bearing scope (EXEC-024)")
.switch();
let workspace = long("workspace")
.help("Run every task in every project, regardless of cwd (EXEC-025)")
.switch();
let dry_run = long("dry-run")
.help("Enumerate the target set without spawning any task (EXEC-030)")
.switch();
let git_filter = git_filter_parser();
let output_mode = long("output-mode")
.help("Output presentation mode (live | buffered)")
.argument::<String>("MODE")
.parse(|s| match s.as_ref() {
"live" => Ok(OutputMode::Live),
"buffered" => Ok(OutputMode::Buffered),
other => Err(format!(
"unknown output mode `{other}` (expected `live` or `buffered`)"
)),
})
.optional();
let targets = positional::<String>("TASK")
.help("Task reference (e.g. `proj:task`, `^:task`)")
.many();
construct!(RunArgs {
all,
workspace,
git_filter,
dry_run,
output_mode,
targets,
})
.parse(|args: RunArgs| {
let has_positionals = !args.targets.is_empty();
let has_git_filter = args.git_filter.is_some();
match (args.all, args.workspace, has_git_filter, has_positionals) {
(true, true, _, _) => {
Err("`--all` and `--workspace` are mutually exclusive".to_owned())
}
(true, _, _, true) => {
Err("`--all` cannot be combined with positional task references".to_owned())
}
(_, true, _, true) => {
Err("`--workspace` cannot be combined with positional task references".to_owned())
}
(_, _, true, true) => Err(
"git-changeset filters cannot be combined with positional task references"
.to_owned(),
),
_ => Ok(args),
}
})
}
fn git_filter_parser() -> impl Parser<Option<GitFilter>> {
let changed = long("git-changed")
.help("Restrict targets to tasks affected by working-tree changes vs HEAD (EXEC-027)")
.switch();
let staged = long("git-staged")
.help("Restrict targets to tasks affected by staged changes vs HEAD (EXEC-028)")
.switch();
let changed_since = long("git-changed-since")
.help("Restrict targets to tasks affected by changes since <REV> (EXEC-029)")
.argument::<String>("REV")
.optional();
construct!(changed, staged, changed_since).parse(|(changed, staged, changed_since)| {
match (changed, staged, changed_since) {
(false, false, None) => Ok(None),
(true, false, None) => Ok(Some(GitFilter::Changed)),
(false, true, None) => Ok(Some(GitFilter::Staged)),
(false, false, Some(rev)) => Ok(Some(GitFilter::ChangedSince(rev))),
_ => Err("git-changeset filters are mutually exclusive".to_owned()),
}
})
}
fn check_command() -> impl Parser<CheckArgs> {
let all = long("all")
.help("Validate every task in the bearing scope (AUX-009, mirrors EXEC-024)")
.switch();
let workspace = long("workspace")
.help("Validate every task in every project, regardless of cwd (AUX-009, mirrors EXEC-025)")
.switch();
construct!(CheckArgs { all, workspace })
.parse(|args: CheckArgs| {
if args.all && args.workspace {
Err("`--all` and `--workspace` are mutually exclusive".to_owned())
} else {
Ok(args)
}
})
}
fn why_command() -> impl Parser<WhyArgs> {
let target = positional::<String>("TASK").help("Task reference (REF-001..REF-008)");
construct!(WhyArgs { target })
}
fn cache_command() -> impl Parser<Cmd> {
let remove_all = pure(Cmd::CacheClear)
.to_options()
.command("clear")
.help("Remove every cache entry (CACHE-021)");
let prune_stale = clean_command()
.to_options()
.command("clean")
.help("Selectively reclaim cache entries (CACHE-022, AUX-022..AUX-027)");
let report_info = pure(Cmd::CacheInfo)
.to_options()
.command("info")
.help("Report cache-state statistics (AUX-017..AUX-021)");
construct!([remove_all, prune_stale, report_info])
}
fn clean_command() -> impl Parser<Cmd> {
let soft = long("soft")
.help("Reclaim objectively-stale state (CACHE-022)")
.switch();
let max_age = long("max-age")
.help("Evict entries strictly older than DURATION (AUX-022)")
.argument::<String>("DURATION")
.parse(|s| MaxAge::parse(&s).map_err(|e| e.to_string()))
.optional();
let max_size = long("max-size")
.help("Cap on-disk footprint at BYTES, evicting oldest first (AUX-022)")
.argument::<String>("BYTES")
.parse(|s| MaxSize::parse(&s).map_err(|e| e.to_string()))
.optional();
let dry_run = long("dry-run")
.help("Compute the eviction set without removing anything (AUX-024)")
.switch();
construct!(CacheCleanArgs {
soft,
max_age,
max_size,
dry_run,
})
.map(Cmd::CacheClean)
}
fn query_command() -> impl Parser<QueryArgs> {
let tags = long("tags")
.help("Filter by owning-project tag expression (QRY-003)")
.argument::<String>("EXPR")
.optional();
let projects = long("projects")
.help("Filter by project-name expression (QRY-003)")
.argument::<String>("EXPR")
.optional();
let tasks = long("tasks")
.help("Filter by task-name expression (QRY-003)")
.argument::<String>("EXPR")
.optional();
let inputs = long("inputs")
.help("Filter by inputs path-pattern expression (QRY-003)")
.argument::<String>("EXPR")
.optional();
let outputs = long("outputs")
.help("Filter by outputs path-pattern expression (QRY-003)")
.argument::<String>("EXPR")
.optional();
let child_of = long("child-of")
.help("Direct hard-edge predecessor must satisfy EXPR (QRY-004)")
.argument::<String>("EXPR")
.optional();
let parent_of = long("parent-of")
.help("Direct hard-edge successor must satisfy EXPR (QRY-004)")
.argument::<String>("EXPR")
.optional();
let depends_on = long("depends-on")
.help("Transitive hard-edge predecessor must satisfy EXPR (QRY-004)")
.argument::<String>("EXPR")
.optional();
let ancestor_of = long("ancestor-of")
.help("Transitive hard-edge successor must satisfy EXPR (QRY-004)")
.argument::<String>("EXPR")
.optional();
let deps_presence = presence_parser(
"has-deps",
"no-deps",
"Select tasks whose `deps:` is non-empty (QRY-005)",
"Select tasks whose `deps:` is empty (QRY-005)",
);
let inputs_presence = presence_parser(
"has-inputs",
"no-inputs",
"Select tasks whose `inputs:` is non-empty (QRY-005)",
"Select tasks whose `inputs:` is empty (QRY-005)",
);
let outputs_presence = presence_parser(
"has-outputs",
"no-outputs",
"Select tasks whose `outputs:` is non-empty (QRY-005)",
"Select tasks whose `outputs:` is empty (QRY-005)",
);
let mutex = long("mutex")
.help("Select tasks that declare a `mutex:` field (QRY-005)")
.switch();
let format = long("format")
.help("Output format (plain | json) per QRY-008")
.argument::<String>("FMT")
.parse(|s| match s.as_ref() {
"plain" => Ok(OutputFormat::Plain),
"json" => Ok(OutputFormat::Json),
other => Err(format!(
"unknown output format `{other}` (expected `plain` or `json`)"
)),
})
.fallback(OutputFormat::Plain);
construct!(QueryArgs {
tags,
projects,
tasks,
inputs,
outputs,
child_of,
parent_of,
depends_on,
ancestor_of,
deps_presence,
inputs_presence,
outputs_presence,
mutex,
format,
})
}
fn init_command() -> impl Parser<InitArgs> {
let yes = long("yes")
.help("Skip the interactive flow and write a minimal workspace (AUX-007)")
.switch();
construct!(InitArgs { yes })
}
fn presence_parser(
has_flag: &'static str,
no_flag: &'static str,
has_help: &'static str,
no_help: &'static str,
) -> impl Parser<FieldPresence> {
let has = long(has_flag).help(has_help).switch();
let no = long(no_flag).help(no_help).switch();
construct!(has, no).parse(move |(has, no)| match (has, no) {
(false, false) => Ok(FieldPresence::Any),
(true, false) => Ok(FieldPresence::NonEmpty),
(false, true) => Ok(FieldPresence::Empty),
(true, true) => Err(format!(
"`--{has_flag}` and `--{no_flag}` are mutually exclusive"
)),
})
}
#[cfg(test)]
mod tests {
use super::{
CacheCleanArgs, CheckArgs, Cmd, FieldPresence, GitFilter, InitArgs, MaxAge, MaxSize,
OutputFormat, OutputMode, QueryArgs, RunArgs, WhyArgs, cli,
};
fn parse(args: &[&str]) -> Result<Cmd, String> {
cli().run_inner(args).map_err(|err| err.unwrap_stderr())
}
fn bare_run_args() -> RunArgs {
RunArgs {
targets: Vec::new(),
all: false,
workspace: false,
git_filter: None,
dry_run: false,
output_mode: None,
}
}
#[test]
fn run_with_no_targets_parses() {
let parsed = parse(&["run"]).unwrap();
assert_eq!(parsed, Cmd::Run(bare_run_args()));
}
#[test]
fn run_with_one_target_parses() {
let parsed = parse(&["run", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec!["lib:build".to_owned()],
..bare_run_args()
}),
);
}
#[test]
fn run_with_multiple_targets_parses_in_order() {
let parsed = parse(&["run", "lib:build", "app:test", "^:lint"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec![
"lib:build".to_owned(),
"app:test".to_owned(),
"^:lint".to_owned(),
],
..bare_run_args()
}),
);
}
#[test]
fn run_with_output_mode_live() {
let parsed = parse(&["run", "--output-mode", "live", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec!["lib:build".to_owned()],
output_mode: Some(OutputMode::Live),
..bare_run_args()
}),
);
}
#[test]
fn run_with_output_mode_buffered() {
let parsed = parse(&["run", "--output-mode", "buffered", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec!["lib:build".to_owned()],
output_mode: Some(OutputMode::Buffered),
..bare_run_args()
}),
);
}
#[test]
fn run_with_all_flag_parses() {
let parsed = parse(&["run", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
all: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_workspace_flag_parses() {
let parsed = parse(&["run", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
workspace: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_flag_parses() {
let parsed = parse(&["run", "--dry-run"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_positional() {
let parsed = parse(&["run", "--dry-run", "lib:build"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
targets: vec!["lib:build".to_owned()],
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_all() {
let parsed = parse(&["run", "--dry-run", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
all: true,
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_with_dry_run_composes_with_workspace() {
let parsed = parse(&["run", "--dry-run", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
workspace: true,
dry_run: true,
..bare_run_args()
}),
);
}
#[test]
fn run_all_plus_workspace_rejects() {
let err = parse(&["run", "--all", "--workspace"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the mutex: {err}",
);
}
#[test]
fn run_all_plus_positional_rejects() {
let err = parse(&["run", "--all", "lib:build"]).unwrap_err();
assert!(
err.contains("`--all`") && err.contains("positional"),
"diagnostic should mention --all and positional refs: {err}",
);
}
#[test]
fn run_workspace_plus_positional_rejects() {
let err = parse(&["run", "--workspace", "lib:build"]).unwrap_err();
assert!(
err.contains("`--workspace`") && err.contains("positional"),
"diagnostic should mention --workspace and positional refs: {err}",
);
}
#[test]
fn run_with_git_changed_parses() {
let parsed = parse(&["run", "--git-changed"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_with_git_staged_parses() {
let parsed = parse(&["run", "--git-staged"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::Staged),
..bare_run_args()
}),
);
}
#[test]
fn run_with_git_changed_since_parses() {
let parsed = parse(&["run", "--git-changed-since", "main"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
git_filter: Some(GitFilter::ChangedSince("main".to_owned())),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_all_composes() {
let parsed = parse(&["run", "--git-changed", "--all"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
all: true,
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_workspace_composes() {
let parsed = parse(&["run", "--git-changed", "--workspace"]).unwrap();
assert_eq!(
parsed,
Cmd::Run(RunArgs {
workspace: true,
git_filter: Some(GitFilter::Changed),
..bare_run_args()
}),
);
}
#[test]
fn run_git_changed_plus_staged_rejects() {
let err = parse(&["run", "--git-changed", "--git-staged"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_changed_plus_changed_since_rejects() {
let err = parse(&["run", "--git-changed", "--git-changed-since", "main"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_staged_plus_changed_since_rejects() {
let err = parse(&["run", "--git-staged", "--git-changed-since", "main"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-family mutex: {err}",
);
}
#[test]
fn run_git_changed_plus_positional_rejects() {
let err = parse(&["run", "--git-changed", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_git_staged_plus_positional_rejects() {
let err = parse(&["run", "--git-staged", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_git_changed_since_plus_positional_rejects() {
let err = parse(&["run", "--git-changed-since", "main", "lib:build"]).unwrap_err();
assert!(
err.contains("git-changeset") && err.contains("positional"),
"diagnostic should mention the filter-vs-positional mutex: {err}",
);
}
#[test]
fn run_with_unknown_output_mode_rejects() {
let err = parse(&["run", "--output-mode", "noisy", "lib:build"]).unwrap_err();
assert!(
err.contains("unknown output mode"),
"diagnostic should name the offending value: {err}",
);
}
#[test]
fn aux_009_check_with_no_args_parses() {
assert_eq!(parse(&["check"]).unwrap(), Cmd::Check(CheckArgs::default()),);
}
#[test]
fn aux_009_check_with_all_flag_parses() {
assert_eq!(
parse(&["check", "--all"]).unwrap(),
Cmd::Check(CheckArgs {
all: true,
workspace: false,
}),
);
}
#[test]
fn aux_009_check_with_workspace_flag_parses() {
assert_eq!(
parse(&["check", "--workspace"]).unwrap(),
Cmd::Check(CheckArgs {
all: false,
workspace: true,
}),
);
}
#[test]
fn aux_009_check_all_plus_workspace_rejects() {
let err = parse(&["check", "--all", "--workspace"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the mutex: {err}",
);
}
#[test]
fn aux_009_check_rejects_unknown_flag() {
let err = parse(&["check", "--dry-run"]).unwrap_err();
assert!(
!err.is_empty(),
"unknown flag must produce a diagnostic: {err}",
);
}
#[test]
fn aux_009_check_rejects_positional() {
let err = parse(&["check", "lib:build"]).unwrap_err();
assert!(
!err.is_empty(),
"positional argument must produce a diagnostic: {err}",
);
}
#[test]
fn aux_013_why_parses_a_single_positional_target() {
assert_eq!(
parse(&["why", "lib:build"]).unwrap(),
Cmd::Why(WhyArgs {
target: "lib:build".to_owned(),
}),
);
}
#[test]
fn aux_013_why_accepts_fan_out_reference_form_at_parse_time() {
assert_eq!(
parse(&["why", "^:test"]).unwrap(),
Cmd::Why(WhyArgs {
target: "^:test".to_owned(),
}),
);
}
#[test]
fn aux_013_why_rejects_missing_positional() {
let err = parse(&["why"]).unwrap_err();
assert!(
!err.is_empty(),
"missing positional must produce a diagnostic: {err}",
);
}
#[test]
fn aux_013_why_rejects_extra_positional() {
let err = parse(&["why", "lib:build", "other:task"]).unwrap_err();
assert!(
!err.is_empty(),
"extra positional must produce a diagnostic: {err}",
);
}
#[test]
fn aux_013_why_rejects_unknown_flag() {
let err = parse(&["why", "--all", "lib:build"]).unwrap_err();
assert!(
!err.is_empty(),
"unknown flag must produce a diagnostic: {err}",
);
}
#[test]
fn cache_clear_parses() {
assert_eq!(parse(&["cache", "clear"]).unwrap(), Cmd::CacheClear);
}
#[test]
fn cache_info_parses() {
assert_eq!(parse(&["cache", "info"]).unwrap(), Cmd::CacheInfo);
}
#[test]
fn aux_017_cache_info_rejects_positional() {
let err = parse(&["cache", "info", "extra"]).unwrap_err();
assert!(
!err.is_empty(),
"extra positional must produce a diagnostic: {err}",
);
}
#[test]
fn aux_017_cache_info_rejects_unknown_flag() {
let err = parse(&["cache", "info", "--soft"]).unwrap_err();
assert!(
!err.is_empty(),
"unknown flag must produce a diagnostic: {err}",
);
}
#[test]
fn cache_clean_soft_parses() {
assert_eq!(
parse(&["cache", "clean", "--soft"]).unwrap(),
Cmd::CacheClean(CacheCleanArgs {
soft: true,
..CacheCleanArgs::default()
}),
);
}
#[test]
fn cache_clean_bare_parses_with_no_modes() {
assert_eq!(
parse(&["cache", "clean"]).unwrap(),
Cmd::CacheClean(CacheCleanArgs::default()),
);
}
#[test]
fn cache_clean_max_age_parses_valid_duration() {
let want = MaxAge::parse("90d").unwrap();
assert_eq!(
parse(&["cache", "clean", "--max-age", "90d"]).unwrap(),
Cmd::CacheClean(CacheCleanArgs {
max_age: Some(want),
..CacheCleanArgs::default()
}),
);
}
#[test]
fn cache_clean_max_age_rejects_bare_integer() {
let err = parse(&["cache", "clean", "--max-age", "90"]).unwrap_err();
assert!(
!err.is_empty(),
"malformed --max-age must produce a diagnostic: {err}",
);
}
#[test]
fn cache_clean_max_size_parses_si_suffix() {
let want = MaxSize::parse("1GB").unwrap();
assert_eq!(
parse(&["cache", "clean", "--max-size", "1GB"]).unwrap(),
Cmd::CacheClean(CacheCleanArgs {
max_size: Some(want),
..CacheCleanArgs::default()
}),
);
}
#[test]
fn cache_clean_max_size_rejects_unknown_suffix() {
let err = parse(&["cache", "clean", "--max-size", "1ZB"]).unwrap_err();
assert!(
!err.is_empty(),
"malformed --max-size must produce a diagnostic: {err}",
);
}
#[test]
fn cache_clean_dry_run_composes_with_other_flags() {
let want_age = MaxAge::parse("30d").unwrap();
assert_eq!(
parse(&["cache", "clean", "--soft", "--max-age", "30d", "--dry-run"]).unwrap(),
Cmd::CacheClean(CacheCleanArgs {
soft: true,
max_age: Some(want_age),
max_size: None,
dry_run: true,
}),
);
}
#[test]
fn no_subcommand_rejects() {
let err = parse(&[]).unwrap_err();
assert!(
!err.is_empty(),
"missing subcommand must produce a diagnostic",
);
}
#[test]
fn qry_001_query_with_no_flags_parses_to_default() {
assert_eq!(parse(&["query"]).unwrap(), Cmd::Query(QueryArgs::default()),);
}
#[test]
fn qry_003_tags_filter_captures_expression_verbatim() {
assert_eq!(
parse(&["query", "--tags", "backend & !legacy"]).unwrap(),
Cmd::Query(QueryArgs {
tags: Some("backend & !legacy".to_owned()),
..QueryArgs::default()
}),
);
}
#[test]
fn qry_003_projects_filter_captures_expression_verbatim() {
assert_eq!(
parse(&["query", "--projects", "lib | web"]).unwrap(),
Cmd::Query(QueryArgs {
projects: Some("lib | web".to_owned()),
..QueryArgs::default()
}),
);
}
#[test]
fn qry_003_tasks_filter_captures_expression_verbatim() {
assert_eq!(
parse(&["query", "--tasks", "build"]).unwrap(),
Cmd::Query(QueryArgs {
tasks: Some("build".to_owned()),
..QueryArgs::default()
}),
);
}
#[test]
fn qry_003_inputs_and_outputs_filters_capture_expressions_verbatim() {
assert_eq!(
parse(&[
"query",
"--inputs",
"src/**/*.rs",
"--outputs",
"/lib/dist/*.so",
])
.unwrap(),
Cmd::Query(QueryArgs {
inputs: Some("src/**/*.rs".to_owned()),
outputs: Some("/lib/dist/*.so".to_owned()),
..QueryArgs::default()
}),
);
}
#[test]
fn qry_004_relational_filters_capture_expressions_verbatim() {
assert_eq!(
parse(&[
"query",
"--child-of",
"name:build",
"--parent-of",
"tag:backend",
"--depends-on",
"project:lib",
"--ancestor-of",
"name:lint",
])
.unwrap(),
Cmd::Query(QueryArgs {
child_of: Some("name:build".to_owned()),
parent_of: Some("tag:backend".to_owned()),
depends_on: Some("project:lib".to_owned()),
ancestor_of: Some("name:lint".to_owned()),
..QueryArgs::default()
}),
);
}
#[test]
fn qry_005_individual_shortcut_flags_parse() {
let parsed = parse(&[
"query",
"--has-deps",
"--has-inputs",
"--no-outputs",
"--mutex",
])
.unwrap();
assert_eq!(
parsed,
Cmd::Query(QueryArgs {
deps_presence: FieldPresence::NonEmpty,
inputs_presence: FieldPresence::NonEmpty,
outputs_presence: FieldPresence::Empty,
mutex: true,
..QueryArgs::default()
}),
);
}
#[test]
fn qry_005_has_deps_plus_no_deps_rejects() {
let err = parse(&["query", "--has-deps", "--no-deps"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-pair mutex: {err}",
);
}
#[test]
fn qry_005_has_inputs_plus_no_inputs_rejects() {
let err = parse(&["query", "--has-inputs", "--no-inputs"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-pair mutex: {err}",
);
}
#[test]
fn qry_005_has_outputs_plus_no_outputs_rejects() {
let err = parse(&["query", "--has-outputs", "--no-outputs"]).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"diagnostic should name the intra-pair mutex: {err}",
);
}
#[test]
fn qry_008_format_plain_parses() {
assert_eq!(
parse(&["query", "--format", "plain"]).unwrap(),
Cmd::Query(QueryArgs {
format: OutputFormat::Plain,
..QueryArgs::default()
}),
);
}
#[test]
fn qry_008_format_json_parses() {
assert_eq!(
parse(&["query", "--format", "json"]).unwrap(),
Cmd::Query(QueryArgs {
format: OutputFormat::Json,
..QueryArgs::default()
}),
);
}
#[test]
fn qry_008_format_default_is_plain() {
let Cmd::Query(args) = parse(&["query"]).unwrap() else {
panic!("expected Cmd::Query");
};
assert_eq!(args.format, OutputFormat::Plain);
}
#[test]
fn qry_008_unknown_format_rejects() {
let err = parse(&["query", "--format", "yaml"]).unwrap_err();
assert!(
err.contains("unknown output format"),
"diagnostic should name the offending value: {err}",
);
}
#[test]
fn qry_006_multiple_filter_families_combine_into_one_args() {
let parsed = parse(&[
"query",
"--tags",
"backend",
"--tasks",
"test",
"--has-deps",
"--format",
"json",
])
.unwrap();
assert_eq!(
parsed,
Cmd::Query(QueryArgs {
tags: Some("backend".to_owned()),
tasks: Some("test".to_owned()),
deps_presence: FieldPresence::NonEmpty,
format: OutputFormat::Json,
..QueryArgs::default()
}),
);
}
#[test]
fn qry_001_query_rejects_positional_arguments() {
let err = parse(&["query", "lib:build"]).unwrap_err();
assert!(
!err.is_empty(),
"positional argument must produce a diagnostic: {err}",
);
}
#[test]
fn aux_001_init_bare_parses_with_yes_false() {
assert_eq!(
parse(&["init"]).unwrap(),
Cmd::Init(InitArgs { yes: false }),
);
}
#[test]
fn aux_001_init_with_yes_flag_parses() {
assert_eq!(
parse(&["init", "--yes"]).unwrap(),
Cmd::Init(InitArgs { yes: true }),
);
}
#[test]
fn aux_001_init_rejects_positional_argument() {
let err = parse(&["init", "extra"]).unwrap_err();
assert!(
!err.is_empty(),
"positional argument must produce a diagnostic: {err}",
);
}
#[test]
fn aux_001_init_rejects_unknown_flag() {
let err = parse(&["init", "--no-such-flag"]).unwrap_err();
assert!(
!err.is_empty(),
"unknown flag must produce a diagnostic: {err}",
);
}
}