Skip to main content

faucet_cli/
select.rs

1//! Runtime matrix-row selection — the composable selection model spanning
2//! four issues that resolve through **one** eligibility → narrowing → parents
3//! → skip formula:
4//!
5//! ```text
6//! 1. eligible  = status gate ({mandatory, active} ∪ --status)          # #371
7//! 2. narrowed  = (eligible ∩ --tag) ∪ (--select / --only by id)        # #376 / #370
8//! 3. parents   = apply include_parents policy to narrowed              # #377
9//! 4. run set   = parents − (--skip)                                    # #370
10//! ```
11//!
12//! - **#370 — identity.** `--select <id>` (exact) / `--only <glob>` force-include
13//!   a row *by name*, bypassing the status gate; `--skip <id|glob>` removes last.
14//! - **#371 — readiness (`status`).** Each row's source carries a
15//!   [`SourceStatus`] ladder. The status gate decides *eligibility*;
16//!   `--status <tier>` additively widens the eligible set.
17//! - **#376 — classification (`tags`).** `--tag <t>` narrows *within* the
18//!   eligible set. A tag can only shrink the eligible set, never resurrect a
19//!   non-ready (`available`/`draft`/`archived`) row.
20//! - **#377 — `include_parents`.** The single, explicit policy that decides
21//!   whether a selected row's `parent:` / `depends_on:` ancestors are pulled in.
22//!   Default `off` (strict): a required ancestor missing from the run set is a
23//!   hard, fail-fast error.
24//!
25//! Selection runs on the **expanded node list** (after `expand()`), so it never
26//! alters `{name}::{row_id}` state-key derivation — bookmarks stay identical
27//! across a full run and any selected subset.
28
29use crate::config::{IncludeParents, SelectionSpec, SourceStatus};
30use crate::error::{CliError, CliResult};
31use crate::expand::{ExpandedNode, NodeRole};
32use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
33
34/// A fully-resolved selection request, built from CLI flags + config.
35#[derive(Debug, Clone, Default)]
36pub struct RunSelection {
37    /// Exact row ids to force-include (bypass status/tags).
38    pub select: Vec<String>,
39    /// Glob patterns to force-include by id (bypass status/tags).
40    pub only: Vec<String>,
41    /// Row ids / globs to remove from the run set (applied last).
42    pub skip: Vec<String>,
43    /// Status tiers to add to the default `{mandatory, active}` eligible set.
44    pub status: Vec<SourceStatus>,
45    /// Tags to narrow the eligible set by (union within the list).
46    pub tags: Vec<String>,
47    /// Parent/dependency inclusion policy.
48    pub include_parents: IncludeParents,
49}
50
51impl RunSelection {
52    /// Resolve raw CLI strings + the config's `selection:` block into a typed
53    /// [`RunSelection`]. Parses `--status` tiers and `--include-parents`,
54    /// surfacing typed errors on unknown values. Precedence for the policy:
55    /// `--include-parents` flag/env > `selection.include_parents` in config >
56    /// built-in default (`off`).
57    #[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    /// Build from the shared CLI [`SelectionArgs`](crate::cli::SelectionArgs)
98    /// plus the config's `selection:` block.
99    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    /// Whether any selector actively narrows/widens the run set (so callers
115    /// like `validate` know to print a selection report). `--include-parents`
116    /// alone does not count — it only governs ancestor inclusion.
117    pub fn narrows(&self) -> bool {
118        self.has_matrix_only_selector() || !self.status.is_empty()
119    }
120
121    /// Any row-narrowing selector present (matrix-only flags). `--status` and
122    /// `--include-parents` are excluded because they are meaningful even on a
123    /// single anonymous row.
124    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
132/// Apply `sel` to `nodes` (expanded, in BFS order) and return the running
133/// subset, order-preserved. Errors on unknown tokens, an empty run set, or a
134/// dependency violation under the active `include_parents` policy.
135///
136/// `has_matrix` is `false` for the single anonymous invocation (no `matrix:`);
137/// matrix-only selectors (`--select`/`--only`/`--skip`/`--tag`) are then a hard
138/// error. The status gate still applies to the lone row.
139pub 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    // Typo protection: every identity/skip token must match ≥1 row id, and
164    // every requested tag must be present on some row. Checked against the
165    // full node set (before any gating), so a typo is caught regardless of
166    // status.
167    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    // Effective status set = {mandatory, active} ∪ --status.
206    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    // Stage 1 + 2: eligibility → narrowing.
220    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    // Stage 3: parent / dependency closure under the include_parents policy.
235    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    // Stage 4: skip (applied last). A `mandatory` row is removable only by an
246    // exact `--skip <id>`, never by a glob.
247    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    // Post-skip integrity: skipping a row that a surviving row structurally
262    // depends on would orphan the dependent (a child can't fan out without its
263    // parent). Fail fast rather than run a broken graph.
264    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
295/// Walk the `parent:` / `depends_on:` ancestor closure of the current run set,
296/// adding or rejecting ancestors per the policy. Collects **every** offending
297/// pair (transitively) before erroring.
298fn 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        // `id` is always a real node (run set only ever holds known ids).
309        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            // Ancestor id validity was proven at expand time.
318            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
369/// The `parent:` + `depends_on:` edges of `node` — the "required ancestors" a
370/// run-set row cannot execute correctly without.
371fn 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
386/// Dedup a token list, preserving first-seen order.
387fn 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
398/// Whether a `--skip` token removes `id`. A glob token never removes a
399/// `mandatory` row; an exact-id token removes any row (including mandatory).
400fn 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
408/// Whether a token (exact id or glob) matches `id`.
409fn 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
421/// Minimal `*` (any run, incl. empty) / `?` (exactly one char) glob matcher.
422/// Sufficient for row-id selection; no character classes or escaping.
423fn glob_match(pattern: &str, text: &str) -> bool {
424    let p: Vec<char> = pattern.chars().collect();
425    let t: Vec<char> = text.chars().collect();
426    // Iterative backtracking match.
427    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    /// Build expanded nodes from YAML for selection tests.
458    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    /// A HiBob-style multi-endpoint matrix used by most tests.
473    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        // `beta` is draft (parked) but explicitly selected → runs anyway.
529        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        // p* matches people + payroll (both identity-selected, status bypassed).
545        assert_eq!(ids(&out), vec!["payroll", "people"]);
546    }
547
548    #[test]
549    fn tag_narrows_within_eligible_only() {
550        // `finance` tags payroll (mandatory, eligible) + audit (available, NOT
551        // eligible). Bare --tag finance keeps only the eligible one.
552        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        // A glob skip cannot drop the mandatory `payroll` row…
585        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        // …but an exact-id skip can.
592        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        // Selecting only `facts` (via tag) drops its `depends_on: dims`.
678        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        // `posts` is a per-record child of `users`; selecting only `posts` must
777        // pull in `users` under `eligible`.
778        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        // A matrix with no status/tags anywhere must run every row (no
824        // behaviour change for pre-selection configs).
825        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}