1use bpaf::{Parser, construct, long};
9
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub struct QueryArgs {
25 pub tags: Option<String>,
27 pub projects: Option<String>,
29 pub tasks: Option<String>,
31 pub inputs: Option<String>,
33 pub outputs: Option<String>,
35 pub child_of: Option<String>,
37 pub parent_of: Option<String>,
39 pub depends_on: Option<String>,
41 pub ancestor_of: Option<String>,
43 pub deps_presence: FieldPresence,
45 pub inputs_presence: FieldPresence,
47 pub outputs_presence: FieldPresence,
49 pub mutex: bool,
51 pub format: OutputFormat,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum FieldPresence {
62 #[default]
65 Any,
66 NonEmpty,
68 Empty,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum OutputFormat {
75 #[default]
78 Plain,
79 Json,
82}
83
84pub(super) fn query_command() -> impl Parser<QueryArgs> {
85 let tags = long("tags")
86 .help("Filter by owning-project tag expression (QRY-003)")
87 .argument::<String>("EXPR")
88 .optional();
89 let projects = long("projects")
90 .help("Filter by project-name expression (QRY-003)")
91 .argument::<String>("EXPR")
92 .optional();
93 let tasks = long("tasks")
94 .help("Filter by task-name expression (QRY-003)")
95 .argument::<String>("EXPR")
96 .optional();
97 let inputs = long("inputs")
98 .help("Filter by inputs path-pattern expression (QRY-003)")
99 .argument::<String>("EXPR")
100 .optional();
101 let outputs = long("outputs")
102 .help("Filter by outputs path-pattern expression (QRY-003)")
103 .argument::<String>("EXPR")
104 .optional();
105 let child_of = long("child-of")
106 .help("Direct hard-edge predecessor must satisfy EXPR (QRY-004)")
107 .argument::<String>("EXPR")
108 .optional();
109 let parent_of = long("parent-of")
110 .help("Direct hard-edge successor must satisfy EXPR (QRY-004)")
111 .argument::<String>("EXPR")
112 .optional();
113 let depends_on = long("depends-on")
114 .help("Transitive hard-edge predecessor must satisfy EXPR (QRY-004)")
115 .argument::<String>("EXPR")
116 .optional();
117 let ancestor_of = long("ancestor-of")
118 .help("Transitive hard-edge successor must satisfy EXPR (QRY-004)")
119 .argument::<String>("EXPR")
120 .optional();
121
122 let deps_presence = presence_parser(
123 "has-deps",
124 "no-deps",
125 "Select tasks whose `deps:` is non-empty (QRY-005)",
126 "Select tasks whose `deps:` is empty (QRY-005)",
127 );
128 let inputs_presence = presence_parser(
129 "has-inputs",
130 "no-inputs",
131 "Select tasks whose `inputs:` is non-empty (QRY-005)",
132 "Select tasks whose `inputs:` is empty (QRY-005)",
133 );
134 let outputs_presence = presence_parser(
135 "has-outputs",
136 "no-outputs",
137 "Select tasks whose `outputs:` is non-empty (QRY-005)",
138 "Select tasks whose `outputs:` is empty (QRY-005)",
139 );
140 let mutex = long("mutex")
141 .help("Select tasks that declare a `mutex:` field (QRY-005)")
142 .switch();
143
144 let format = long("format")
145 .help("Output format (plain | json) per QRY-008")
146 .argument::<String>("FMT")
147 .parse(|s| match s.as_ref() {
148 "plain" => Ok(OutputFormat::Plain),
149 "json" => Ok(OutputFormat::Json),
150 other => Err(format!(
151 "unknown output format `{other}` (expected `plain` or `json`)"
152 )),
153 })
154 .fallback(OutputFormat::Plain);
155
156 construct!(QueryArgs {
157 tags,
158 projects,
159 tasks,
160 inputs,
161 outputs,
162 child_of,
163 parent_of,
164 depends_on,
165 ancestor_of,
166 deps_presence,
167 inputs_presence,
168 outputs_presence,
169 mutex,
170 format,
171 })
172}
173
174fn presence_parser(
179 has_flag: &'static str,
180 no_flag: &'static str,
181 has_help: &'static str,
182 no_help: &'static str,
183) -> impl Parser<FieldPresence> {
184 let has = long(has_flag).help(has_help).switch();
185 let no = long(no_flag).help(no_help).switch();
186 construct!(has, no).parse(move |(has, no)| match (has, no) {
187 (false, false) => Ok(FieldPresence::Any),
188 (true, false) => Ok(FieldPresence::NonEmpty),
189 (false, true) => Ok(FieldPresence::Empty),
190 (true, true) => Err(format!(
191 "`--{has_flag}` and `--{no_flag}` are mutually exclusive"
192 )),
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::{FieldPresence, OutputFormat, QueryArgs};
199 use crate::cli::{Cmd, parse};
200
201 #[test]
202 fn qry_001_query_with_no_flags_parses_to_default() {
203 assert_eq!(parse(&["query"]).unwrap(), Cmd::Query(QueryArgs::default()),);
204 }
205
206 #[test]
207 fn qry_003_tags_filter_captures_expression_verbatim() {
208 assert_eq!(
209 parse(&["query", "--tags", "backend & !legacy"]).unwrap(),
210 Cmd::Query(QueryArgs {
211 tags: Some("backend & !legacy".to_owned()),
212 ..QueryArgs::default()
213 }),
214 );
215 }
216
217 #[test]
218 fn qry_003_projects_filter_captures_expression_verbatim() {
219 assert_eq!(
220 parse(&["query", "--projects", "lib | web"]).unwrap(),
221 Cmd::Query(QueryArgs {
222 projects: Some("lib | web".to_owned()),
223 ..QueryArgs::default()
224 }),
225 );
226 }
227
228 #[test]
229 fn qry_003_tasks_filter_captures_expression_verbatim() {
230 assert_eq!(
231 parse(&["query", "--tasks", "build"]).unwrap(),
232 Cmd::Query(QueryArgs {
233 tasks: Some("build".to_owned()),
234 ..QueryArgs::default()
235 }),
236 );
237 }
238
239 #[test]
240 fn qry_003_inputs_and_outputs_filters_capture_expressions_verbatim() {
241 assert_eq!(
242 parse(&[
243 "query",
244 "--inputs",
245 "src/**/*.rs",
246 "--outputs",
247 "/lib/dist/*.so",
248 ])
249 .unwrap(),
250 Cmd::Query(QueryArgs {
251 inputs: Some("src/**/*.rs".to_owned()),
252 outputs: Some("/lib/dist/*.so".to_owned()),
253 ..QueryArgs::default()
254 }),
255 );
256 }
257
258 #[test]
259 fn qry_004_relational_filters_capture_expressions_verbatim() {
260 assert_eq!(
261 parse(&[
262 "query",
263 "--child-of",
264 "name:build",
265 "--parent-of",
266 "tag:backend",
267 "--depends-on",
268 "project:lib",
269 "--ancestor-of",
270 "name:lint",
271 ])
272 .unwrap(),
273 Cmd::Query(QueryArgs {
274 child_of: Some("name:build".to_owned()),
275 parent_of: Some("tag:backend".to_owned()),
276 depends_on: Some("project:lib".to_owned()),
277 ancestor_of: Some("name:lint".to_owned()),
278 ..QueryArgs::default()
279 }),
280 );
281 }
282
283 #[test]
284 fn qry_005_individual_shortcut_flags_parse() {
285 let parsed = parse(&[
286 "query",
287 "--has-deps",
288 "--has-inputs",
289 "--no-outputs",
290 "--mutex",
291 ])
292 .unwrap();
293 assert_eq!(
294 parsed,
295 Cmd::Query(QueryArgs {
296 deps_presence: FieldPresence::NonEmpty,
297 inputs_presence: FieldPresence::NonEmpty,
298 outputs_presence: FieldPresence::Empty,
299 mutex: true,
300 ..QueryArgs::default()
301 }),
302 );
303 }
304
305 #[test]
306 fn qry_005_has_deps_plus_no_deps_rejects() {
307 let err = parse(&["query", "--has-deps", "--no-deps"]).unwrap_err();
308 assert!(
309 err.contains("mutually exclusive"),
310 "diagnostic should name the intra-pair mutex: {err}",
311 );
312 }
313
314 #[test]
315 fn qry_005_has_inputs_plus_no_inputs_rejects() {
316 let err = parse(&["query", "--has-inputs", "--no-inputs"]).unwrap_err();
317 assert!(
318 err.contains("mutually exclusive"),
319 "diagnostic should name the intra-pair mutex: {err}",
320 );
321 }
322
323 #[test]
324 fn qry_005_has_outputs_plus_no_outputs_rejects() {
325 let err = parse(&["query", "--has-outputs", "--no-outputs"]).unwrap_err();
326 assert!(
327 err.contains("mutually exclusive"),
328 "diagnostic should name the intra-pair mutex: {err}",
329 );
330 }
331
332 #[test]
333 fn qry_008_format_plain_parses() {
334 assert_eq!(
335 parse(&["query", "--format", "plain"]).unwrap(),
336 Cmd::Query(QueryArgs {
337 format: OutputFormat::Plain,
338 ..QueryArgs::default()
339 }),
340 );
341 }
342
343 #[test]
344 fn qry_008_format_json_parses() {
345 assert_eq!(
346 parse(&["query", "--format", "json"]).unwrap(),
347 Cmd::Query(QueryArgs {
348 format: OutputFormat::Json,
349 ..QueryArgs::default()
350 }),
351 );
352 }
353
354 #[test]
355 fn qry_008_format_default_is_plain() {
356 let Cmd::Query(args) = parse(&["query"]).unwrap() else {
357 panic!("expected Cmd::Query");
358 };
359 assert_eq!(args.format, OutputFormat::Plain);
360 }
361
362 #[test]
363 fn qry_008_unknown_format_rejects() {
364 let err = parse(&["query", "--format", "yaml"]).unwrap_err();
365 assert!(
366 err.contains("unknown output format"),
367 "diagnostic should name the offending value: {err}",
368 );
369 }
370
371 #[test]
372 fn qry_006_multiple_filter_families_combine_into_one_args() {
373 let parsed = parse(&[
374 "query",
375 "--tags",
376 "backend",
377 "--tasks",
378 "test",
379 "--has-deps",
380 "--format",
381 "json",
382 ])
383 .unwrap();
384 assert_eq!(
385 parsed,
386 Cmd::Query(QueryArgs {
387 tags: Some("backend".to_owned()),
388 tasks: Some("test".to_owned()),
389 deps_presence: FieldPresence::NonEmpty,
390 format: OutputFormat::Json,
391 ..QueryArgs::default()
392 }),
393 );
394 }
395
396 #[test]
397 fn qry_001_query_rejects_positional_arguments() {
398 let err = parse(&["query", "lib:build"]).unwrap_err();
399 assert!(
400 !err.is_empty(),
401 "positional argument must produce a diagnostic: {err}",
402 );
403 }
404}