use bpaf::{Parser, construct, long};
#[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, 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(super) 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 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::{FieldPresence, OutputFormat, QueryArgs};
use crate::cli::{Cmd, parse};
#[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}",
);
}
}