1use crate::config::{IncludeParents, SelectionSpec, SourceStatus};
30use crate::error::{CliError, CliResult};
31use crate::expand::{ExpandedNode, NodeRole};
32use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
33
34#[derive(Debug, Clone, Default)]
36pub struct RunSelection {
37 pub select: Vec<String>,
39 pub only: Vec<String>,
41 pub skip: Vec<String>,
43 pub status: Vec<SourceStatus>,
45 pub tags: Vec<String>,
47 pub include_parents: IncludeParents,
49}
50
51impl RunSelection {
52 #[allow(clippy::too_many_arguments)]
58 pub fn resolve(
59 select: &[String],
60 only: &[String],
61 skip: &[String],
62 status: &[String],
63 tags: &[String],
64 include_parents_flag: Option<&str>,
65 cfg_selection: Option<&SelectionSpec>,
66 ) -> CliResult<Self> {
67 let status = status
68 .iter()
69 .map(|s| {
70 SourceStatus::parse(s).ok_or_else(|| CliError::UnknownStatus {
71 value: s.clone(),
72 available: SourceStatus::ALL
73 .iter()
74 .map(|v| v.as_str().to_owned())
75 .collect(),
76 })
77 })
78 .collect::<CliResult<Vec<_>>>()?;
79
80 let include_parents = match include_parents_flag {
81 Some(s) => IncludeParents::parse(s).ok_or_else(|| CliError::UnknownIncludeParents {
82 value: s.to_owned(),
83 })?,
84 None => cfg_selection.map(|s| s.include_parents).unwrap_or_default(),
85 };
86
87 Ok(Self {
88 select: dedup(select),
89 only: dedup(only),
90 skip: dedup(skip),
91 status,
92 tags: dedup(tags),
93 include_parents,
94 })
95 }
96
97 pub fn from_args(
100 args: &crate::cli::SelectionArgs,
101 cfg_selection: Option<&SelectionSpec>,
102 ) -> CliResult<Self> {
103 Self::resolve(
104 &args.select,
105 &args.only,
106 &args.skip,
107 &args.status,
108 &args.tags,
109 args.include_parents.as_deref(),
110 cfg_selection,
111 )
112 }
113
114 pub fn narrows(&self) -> bool {
118 self.has_matrix_only_selector() || !self.status.is_empty()
119 }
120
121 fn has_matrix_only_selector(&self) -> bool {
125 !self.select.is_empty()
126 || !self.only.is_empty()
127 || !self.skip.is_empty()
128 || !self.tags.is_empty()
129 }
130}
131
132pub fn select_nodes(
140 nodes: Vec<ExpandedNode>,
141 sel: &RunSelection,
142 has_matrix: bool,
143) -> CliResult<Vec<ExpandedNode>> {
144 if !has_matrix && sel.has_matrix_only_selector() {
145 let mut flags = Vec::new();
146 if !sel.select.is_empty() {
147 flags.push("--select");
148 }
149 if !sel.only.is_empty() {
150 flags.push("--only");
151 }
152 if !sel.skip.is_empty() {
153 flags.push("--skip");
154 }
155 if !sel.tags.is_empty() {
156 flags.push("--tag");
157 }
158 return Err(CliError::SelectorsWithoutMatrix {
159 flags: flags.join(", "),
160 });
161 }
162
163 for token in &sel.select {
168 if !nodes.iter().any(|n| &n.id == token) {
169 return Err(CliError::NoMatchForSelector {
170 flag: "--select",
171 token: token.clone(),
172 available: all_ids(&nodes),
173 });
174 }
175 }
176 for token in sel.only.iter().chain(sel.skip.iter()) {
177 let flag = if sel.only.contains(token) {
178 "--only"
179 } else {
180 "--skip"
181 };
182 if !nodes.iter().any(|n| token_matches(token, &n.id)) {
183 return Err(CliError::NoMatchForSelector {
184 flag,
185 token: token.clone(),
186 available: all_ids(&nodes),
187 });
188 }
189 }
190 if !sel.tags.is_empty() {
191 let present: BTreeSet<&str> = nodes
192 .iter()
193 .flat_map(|n| n.tags.iter().map(String::as_str))
194 .collect();
195 for tag in &sel.tags {
196 if !present.contains(tag.as_str()) {
197 return Err(CliError::UnknownTag {
198 tag: tag.clone(),
199 available: present.iter().map(|s| (*s).to_owned()).collect(),
200 });
201 }
202 }
203 }
204
205 let mut active_status: HashSet<SourceStatus> =
207 HashSet::from([SourceStatus::Mandatory, SourceStatus::Active]);
208 active_status.extend(sel.status.iter().copied());
209
210 let has_identity = !sel.select.is_empty() || !sel.only.is_empty();
211 let has_tag = !sel.tags.is_empty();
212
213 let is_eligible = |n: &ExpandedNode| active_status.contains(&n.status);
214 let is_identity = |n: &ExpandedNode| {
215 sel.select.iter().any(|id| id == &n.id) || sel.only.iter().any(|g| token_matches(g, &n.id))
216 };
217 let matches_tag = |n: &ExpandedNode| sel.tags.iter().any(|t| n.tags.iter().any(|nt| nt == t));
218
219 let mut run: HashSet<String> = HashSet::new();
221 for n in &nodes {
222 let included = if !has_identity && !has_tag {
223 is_eligible(n)
224 } else {
225 let by_tag = has_tag && is_eligible(n) && matches_tag(n);
226 let by_identity = has_identity && is_identity(n);
227 by_tag || by_identity
228 };
229 if included {
230 run.insert(n.id.clone());
231 }
232 }
233
234 let node_by_id: HashMap<&str, &ExpandedNode> =
236 nodes.iter().map(|n| (n.id.as_str(), n)).collect();
237 apply_parent_policy(
238 &nodes,
239 &node_by_id,
240 &active_status,
241 sel.include_parents,
242 &mut run,
243 )?;
244
245 for n in &nodes {
248 if !run.contains(&n.id) {
249 continue;
250 }
251 let mandatory = n.status == SourceStatus::Mandatory;
252 let removed = sel
253 .skip
254 .iter()
255 .any(|tok| skip_matches(tok, &n.id, mandatory));
256 if removed {
257 run.remove(&n.id);
258 }
259 }
260
261 let mut orphans: Vec<String> = Vec::new();
265 for n in &nodes {
266 if !run.contains(&n.id) {
267 continue;
268 }
269 for (anc, kind) in required_ancestors(n) {
270 if !run.contains(&anc) {
271 orphans.push(format!("{} → {anc} ({kind})", n.id));
272 }
273 }
274 }
275 if !orphans.is_empty() {
276 orphans.sort();
277 orphans.dedup();
278 return Err(CliError::RunSetMissingAncestors {
279 pairs: orphans,
280 policy: sel.include_parents.as_str(),
281 });
282 }
283
284 if run.is_empty() {
285 let rows = nodes
286 .iter()
287 .map(|n| format!("{} [{}]", n.id, n.status.as_str()))
288 .collect();
289 return Err(CliError::EmptyRunSet { rows });
290 }
291
292 Ok(nodes.into_iter().filter(|n| run.contains(&n.id)).collect())
293}
294
295fn apply_parent_policy(
299 _nodes: &[ExpandedNode],
300 node_by_id: &HashMap<&str, &ExpandedNode>,
301 active_status: &HashSet<SourceStatus>,
302 policy: IncludeParents,
303 run: &mut HashSet<String>,
304) -> CliResult<()> {
305 let mut violations: Vec<String> = Vec::new();
306 let mut queue: VecDeque<String> = run.iter().cloned().collect();
307 while let Some(id) = queue.pop_front() {
308 let node = match node_by_id.get(id.as_str()) {
310 Some(n) => *n,
311 None => continue,
312 };
313 for (anc, kind) in required_ancestors(node) {
314 if run.contains(&anc) {
315 continue;
316 }
317 let anc_status = node_by_id.get(anc.as_str()).map(|n| n.status);
319 let eligible = anc_status
320 .map(|s| active_status.contains(&s))
321 .unwrap_or(false);
322 match policy {
323 IncludeParents::Off => {
324 violations.push(format!("{id} → {anc} ({kind})"));
325 }
326 IncludeParents::Eligible => {
327 if eligible {
328 if run.insert(anc.clone()) {
329 tracing::info!(
330 dependent = %id, ancestor = %anc, edge = kind,
331 "include_parents=eligible: auto-included required ancestor"
332 );
333 queue.push_back(anc);
334 }
335 } else {
336 violations.push(format!("{id} → {anc} ({kind}, parked)"));
337 }
338 }
339 IncludeParents::All => {
340 if run.insert(anc.clone()) {
341 if eligible {
342 tracing::info!(
343 dependent = %id, ancestor = %anc, edge = kind,
344 "include_parents=all: auto-included required ancestor"
345 );
346 } else {
347 tracing::warn!(
348 dependent = %id, ancestor = %anc, edge = kind,
349 "include_parents=all: pulling a parked ancestor into the run set"
350 );
351 }
352 queue.push_back(anc);
353 }
354 }
355 }
356 }
357 }
358 if !violations.is_empty() {
359 violations.sort();
360 violations.dedup();
361 return Err(CliError::RunSetMissingAncestors {
362 pairs: violations,
363 policy: policy.as_str(),
364 });
365 }
366 Ok(())
367}
368
369fn required_ancestors(node: &ExpandedNode) -> Vec<(String, &'static str)> {
372 let mut out = Vec::new();
373 if let NodeRole::Child { parent_id, .. } = &node.role {
374 out.push((parent_id.clone(), "parent"));
375 }
376 for d in &node.depends_on {
377 out.push((d.clone(), "depends_on"));
378 }
379 out
380}
381
382fn all_ids(nodes: &[ExpandedNode]) -> Vec<String> {
383 nodes.iter().map(|n| n.id.clone()).collect()
384}
385
386fn dedup(items: &[String]) -> Vec<String> {
388 let mut seen = HashSet::new();
389 let mut out = Vec::new();
390 for it in items {
391 if seen.insert(it.clone()) {
392 out.push(it.clone());
393 }
394 }
395 out
396}
397
398fn skip_matches(token: &str, id: &str, mandatory: bool) -> bool {
401 if has_glob(token) {
402 !mandatory && glob_match(token, id)
403 } else {
404 token == id
405 }
406}
407
408fn token_matches(token: &str, id: &str) -> bool {
410 if has_glob(token) {
411 glob_match(token, id)
412 } else {
413 token == id
414 }
415}
416
417fn has_glob(s: &str) -> bool {
418 s.contains('*') || s.contains('?')
419}
420
421fn glob_match(pattern: &str, text: &str) -> bool {
424 let p: Vec<char> = pattern.chars().collect();
425 let t: Vec<char> = text.chars().collect();
426 let (mut pi, mut ti) = (0usize, 0usize);
428 let (mut star, mut mark) = (None::<usize>, 0usize);
429 while ti < t.len() {
430 if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
431 pi += 1;
432 ti += 1;
433 } else if pi < p.len() && p[pi] == '*' {
434 star = Some(pi);
435 mark = ti;
436 pi += 1;
437 } else if let Some(s) = star {
438 pi = s + 1;
439 mark += 1;
440 ti = mark;
441 } else {
442 return false;
443 }
444 }
445 while pi < p.len() && p[pi] == '*' {
446 pi += 1;
447 }
448 pi == p.len()
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::config::parse_with_extension;
455 use crate::expand::expand;
456
457 fn nodes(yaml: &str) -> Vec<ExpandedNode> {
459 expand(&parse_with_extension(yaml, "yaml").unwrap()).unwrap()
460 }
461
462 fn ids(nodes: &[ExpandedNode]) -> Vec<String> {
463 let mut v: Vec<String> = nodes.iter().map(|n| n.id.clone()).collect();
464 v.sort();
465 v
466 }
467
468 fn sel() -> RunSelection {
469 RunSelection::default()
470 }
471
472 const HIBOB: &str = r#"
474version: 1
475pipeline:
476 sources:
477 hibob: { type: rest, config: { base_url: https://api.hibob.com } }
478 sinks:
479 wh: { type: jsonl, config: { path: ./o } }
480matrix:
481 - id: people
482 source: { ref: hibob, status: active, config: { path: /people } }
483 sink: { ref: wh }
484 tags: [core, daily]
485 - id: payroll
486 source: { ref: hibob, status: mandatory, config: { path: /payroll } }
487 sink: { ref: wh }
488 tags: [finance]
489 - id: audit
490 source: { ref: hibob, status: available, config: { path: /audit } }
491 sink: { ref: wh }
492 tags: [finance]
493 - id: beta
494 source: { ref: hibob, status: draft, config: { path: /beta } }
495 sink: { ref: wh }
496"#;
497
498 #[test]
499 fn glob_matches_star_and_question() {
500 assert!(glob_match("timeoff_*", "timeoff_requests"));
501 assert!(glob_match("timeoff_*", "timeoff_"));
502 assert!(!glob_match("timeoff_*", "people"));
503 assert!(glob_match("a?c", "abc"));
504 assert!(!glob_match("a?c", "ac"));
505 assert!(glob_match("*", "anything"));
506 assert!(glob_match("p*e", "people"));
507 assert!(!glob_match("p*e", "payroll"));
508 }
509
510 #[test]
511 fn bare_run_includes_mandatory_and_active_only() {
512 let out = select_nodes(nodes(HIBOB), &sel(), true).unwrap();
513 assert_eq!(ids(&out), vec!["payroll", "people"]);
514 }
515
516 #[test]
517 fn status_widens_eligible_set_additively() {
518 let s = RunSelection {
519 status: vec![SourceStatus::Available],
520 ..sel()
521 };
522 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
523 assert_eq!(ids(&out), vec!["audit", "payroll", "people"]);
524 }
525
526 #[test]
527 fn select_by_id_bypasses_status_gate() {
528 let s = RunSelection {
530 select: vec!["beta".into()],
531 ..sel()
532 };
533 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
534 assert_eq!(ids(&out), vec!["beta"]);
535 }
536
537 #[test]
538 fn only_glob_selects_subset() {
539 let s = RunSelection {
540 only: vec!["p*".into()],
541 ..sel()
542 };
543 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
544 assert_eq!(ids(&out), vec!["payroll", "people"]);
546 }
547
548 #[test]
549 fn tag_narrows_within_eligible_only() {
550 let s = RunSelection {
553 tags: vec!["finance".into()],
554 ..sel()
555 };
556 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
557 assert_eq!(ids(&out), vec!["payroll"]);
558 }
559
560 #[test]
561 fn tag_plus_status_resurrects_parked_row() {
562 let s = RunSelection {
563 tags: vec!["finance".into()],
564 status: vec![SourceStatus::Available],
565 ..sel()
566 };
567 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
568 assert_eq!(ids(&out), vec!["audit", "payroll"]);
569 }
570
571 #[test]
572 fn skip_removes_after_selection() {
573 let s = RunSelection {
574 status: vec![SourceStatus::Available],
575 skip: vec!["audit".into()],
576 ..sel()
577 };
578 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
579 assert_eq!(ids(&out), vec!["payroll", "people"]);
580 }
581
582 #[test]
583 fn mandatory_survives_glob_skip_but_not_exact_skip() {
584 let s = RunSelection {
586 skip: vec!["p*".into()],
587 ..sel()
588 };
589 let out = select_nodes(nodes(HIBOB), &s, true).unwrap();
590 assert_eq!(ids(&out), vec!["payroll"]);
591 let s = RunSelection {
593 select: vec!["payroll".into()],
594 skip: vec!["payroll".into()],
595 ..sel()
596 };
597 let err = select_nodes(nodes(HIBOB), &s, true).unwrap_err();
598 assert!(matches!(err, CliError::EmptyRunSet { .. }), "got {err:?}");
599 }
600
601 #[test]
602 fn unknown_select_token_errors_with_available() {
603 let s = RunSelection {
604 select: vec!["peeple".into()],
605 ..sel()
606 };
607 match select_nodes(nodes(HIBOB), &s, true).unwrap_err() {
608 CliError::NoMatchForSelector {
609 flag,
610 token,
611 available,
612 } => {
613 assert_eq!(flag, "--select");
614 assert_eq!(token, "peeple");
615 assert!(available.contains(&"people".to_string()));
616 }
617 other => panic!("expected NoMatchForSelector, got {other:?}"),
618 }
619 }
620
621 #[test]
622 fn unknown_tag_errors() {
623 let s = RunSelection {
624 tags: vec!["nope".into()],
625 ..sel()
626 };
627 assert!(matches!(
628 select_nodes(nodes(HIBOB), &s, true).unwrap_err(),
629 CliError::UnknownTag { .. }
630 ));
631 }
632
633 #[test]
634 fn empty_run_set_errors_when_all_parked() {
635 let yaml = r#"
636version: 1
637pipeline:
638 sources:
639 api: { type: rest, config: { base_url: https://x } }
640 sinks:
641 wh: { type: jsonl, config: { path: ./o } }
642matrix:
643 - id: a
644 source: { ref: api, status: available }
645 sink: { ref: wh }
646 - id: b
647 source: { ref: api, status: draft }
648 sink: { ref: wh }
649"#;
650 assert!(matches!(
651 select_nodes(nodes(yaml), &sel(), true).unwrap_err(),
652 CliError::EmptyRunSet { .. }
653 ));
654 }
655
656 const DEPS: &str = r#"
657version: 1
658pipeline:
659 sources:
660 api: { type: rest, config: { base_url: https://x } }
661 sinks:
662 wh: { type: jsonl, config: { path: ./o } }
663matrix:
664 - id: dims
665 source: { ref: api, status: active }
666 sink: { ref: wh }
667 tags: [core]
668 - id: facts
669 source: { ref: api, status: active }
670 sink: { ref: wh }
671 tags: [finance]
672 depends_on: [dims]
673"#;
674
675 #[test]
676 fn include_parents_off_errors_on_missing_ancestor() {
677 let s = RunSelection {
679 tags: vec!["finance".into()],
680 include_parents: IncludeParents::Off,
681 ..sel()
682 };
683 match select_nodes(nodes(DEPS), &s, true).unwrap_err() {
684 CliError::RunSetMissingAncestors { pairs, policy } => {
685 assert_eq!(policy, "off");
686 assert!(
687 pairs
688 .iter()
689 .any(|p| p.contains("facts") && p.contains("dims"))
690 );
691 }
692 other => panic!("expected RunSetMissingAncestors, got {other:?}"),
693 }
694 }
695
696 #[test]
697 fn include_parents_eligible_pulls_in_active_ancestor() {
698 let s = RunSelection {
699 tags: vec!["finance".into()],
700 include_parents: IncludeParents::Eligible,
701 ..sel()
702 };
703 let out = select_nodes(nodes(DEPS), &s, true).unwrap();
704 assert_eq!(ids(&out), vec!["dims", "facts"]);
705 }
706
707 #[test]
708 fn include_parents_eligible_errors_on_parked_ancestor() {
709 let yaml = r#"
710version: 1
711pipeline:
712 sources:
713 api: { type: rest, config: { base_url: https://x } }
714 sinks:
715 wh: { type: jsonl, config: { path: ./o } }
716matrix:
717 - id: dims
718 source: { ref: api, status: available }
719 sink: { ref: wh }
720 - id: facts
721 source: { ref: api, status: active }
722 sink: { ref: wh }
723 depends_on: [dims]
724"#;
725 let s = RunSelection {
726 select: vec!["facts".into()],
727 include_parents: IncludeParents::Eligible,
728 ..sel()
729 };
730 assert!(matches!(
731 select_nodes(nodes(yaml), &s, true).unwrap_err(),
732 CliError::RunSetMissingAncestors { .. }
733 ));
734 }
735
736 #[test]
737 fn include_parents_all_pulls_in_parked_ancestor() {
738 let yaml = r#"
739version: 1
740pipeline:
741 sources:
742 api: { type: rest, config: { base_url: https://x } }
743 sinks:
744 wh: { type: jsonl, config: { path: ./o } }
745matrix:
746 - id: dims
747 source: { ref: api, status: draft }
748 sink: { ref: wh }
749 - id: facts
750 source: { ref: api, status: active }
751 sink: { ref: wh }
752 depends_on: [dims]
753"#;
754 let s = RunSelection {
755 select: vec!["facts".into()],
756 include_parents: IncludeParents::All,
757 ..sel()
758 };
759 let out = select_nodes(nodes(yaml), &s, true).unwrap();
760 assert_eq!(ids(&out), vec!["dims", "facts"]);
761 }
762
763 #[test]
764 fn select_ancestor_by_id_satisfies_dependency() {
765 let s = RunSelection {
766 select: vec!["facts".into(), "dims".into()],
767 include_parents: IncludeParents::Off,
768 ..sel()
769 };
770 let out = select_nodes(nodes(DEPS), &s, true).unwrap();
771 assert_eq!(ids(&out), vec!["dims", "facts"]);
772 }
773
774 #[test]
775 fn parent_edge_closure_respected() {
776 let yaml = r#"
779version: 1
780pipeline:
781 sources:
782 api: { type: rest, config: { base_url: https://x } }
783 sinks:
784 wh: { type: jsonl, config: { path: ./o } }
785matrix:
786 - id: users
787 source: { ref: api, status: active }
788 sink: { ref: wh }
789 - id: posts
790 parent: users
791 source: { ref: api, status: active, config: { path: "/u/${users.id}/posts" } }
792 sink: { ref: wh }
793"#;
794 let s = RunSelection {
795 select: vec!["posts".into()],
796 include_parents: IncludeParents::Eligible,
797 ..sel()
798 };
799 let out = select_nodes(nodes(yaml), &s, true).unwrap();
800 assert_eq!(ids(&out), vec!["posts", "users"]);
801 }
802
803 #[test]
804 fn matrix_only_selectors_rejected_without_matrix() {
805 let yaml = r#"
806version: 1
807pipeline:
808 source: { type: rest, config: { base_url: https://x } }
809 sink: { type: jsonl, config: { path: ./o } }
810"#;
811 let s = RunSelection {
812 select: vec!["row-0".into()],
813 ..sel()
814 };
815 assert!(matches!(
816 select_nodes(nodes(yaml), &s, false).unwrap_err(),
817 CliError::SelectorsWithoutMatrix { .. }
818 ));
819 }
820
821 #[test]
822 fn no_selectors_keeps_plain_config_unchanged() {
823 let yaml = r#"
826version: 1
827pipeline:
828 source: { type: rest, config: { base_url: https://x } }
829 sink: { type: jsonl, config: { path: ./o } }
830matrix:
831 - { id: a }
832 - { id: b }
833 - { id: c }
834"#;
835 let out = select_nodes(nodes(yaml), &sel(), true).unwrap();
836 assert_eq!(ids(&out), vec!["a", "b", "c"]);
837 }
838}