Skip to main content

yah_qed/
config.rs

1//! @yah:ticket(R299-T4, "Create .yah/qed/ pipeline directory")
2//! @yah:at(2026-05-23T01:43:12Z)
3//! @yah:status(review)
4//! @yah:parent(R299)
5//! @yah:handoff(".yah/qed/ directory created at workspace root. PipelineLoader.list_all() now dedupes built-ins + custom files. Camp TOML overrides built-ins by name.")
6
7use crate::peers::PeerConfig;
8use crate::registries::{extract_registry_host, RegistryConfig, RegistryConfigError};
9use crate::types::{
10    GhaWorkflowConfig, OnFail, ParamDef, Pipeline, Placement, QedStep, StepKind,
11    StepValidationError, SubPipelineRef, SubPipelineResolver,
12};
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18
19/// Parse a `P{n}-{name}` filename stem into `(n, name)`.
20/// e.g. `"P006-build-yah-yubaba"` → `(6, "build-yah-yubaba")`.
21fn parse_p_prefix(stem: &str) -> Option<(u32, &str)> {
22    let rest = stem.strip_prefix('P')?;
23    let dash = rest.find('-')?;
24    if dash == 0 {
25        return None;
26    }
27    let num: u32 = rest[..dash].parse().ok()?;
28    Some((num, &rest[dash + 1..]))
29}
30
31/// Locate `{dir}/P*-{name}.toml` (canonical) or `{dir}/{name}.toml` (legacy /
32/// auto-generated fallback). Returns `None` when the directory doesn't exist
33/// or no matching file is found.
34fn find_pipeline_file(dir: &Path, name: &str) -> Option<PathBuf> {
35    if !dir.exists() {
36        return None;
37    }
38    // Prefer the prefixed form.
39    if let Ok(entries) = fs::read_dir(dir) {
40        for entry in entries.flatten() {
41            let path = entry.path();
42            if path.extension().map_or(false, |e| e == "toml") {
43                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
44                    if let Some((_, stem_name)) = parse_p_prefix(stem) {
45                        if stem_name == name {
46                            return Some(path);
47                        }
48                    }
49                }
50            }
51        }
52    }
53    // Fallback: unprefixed (cloud-init generated cards, legacy, tests).
54    let legacy = dir.join(format!("{name}.toml"));
55    if legacy.exists() {
56        Some(legacy)
57    } else {
58        None
59    }
60}
61
62#[derive(Error, Debug)]
63pub enum ConfigError {
64    #[error("IO error: {0}")]
65    Io(#[from] std::io::Error),
66    #[error("TOML parse error: {0}")]
67    TomlParse(#[from] toml::de::Error),
68    #[error("Pipeline not found: {0}")]
69    NotFound(String),
70    #[error("Invalid step: {0}")]
71    InvalidStep(#[from] StepValidationError),
72    #[error("Registry config: {0}")]
73    Registry(#[from] RegistryConfigError),
74    #[error("Sub-pipeline graph: {0}")]
75    SubPipelineGraph(#[from] crate::types::SubPipelineError),
76    #[error("Invalid bind: {0}")]
77    InvalidBind(String),
78}
79
80/// On-disk shape of a `.yah/qed/*.toml` pipeline file. This is the JSON-Schema
81/// source of truth (R533-T10): `cargo run -p xtask -- emit-schemas` derives
82/// `qed-pipeline.toml.schema.json` from it via `schemars`, and a drift test
83/// asserts the committed schema matches. Kept `pub` solely so xtask can name it
84/// in `schema_for!`.
85#[derive(Debug, Deserialize)]
86#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
87pub struct PipelineToml {
88    pub pipeline: PipelineConfig,
89    /// W209: top-level `[[bind]]` tables — placed at file root (not inside
90    /// `[pipeline]`) per the design doc's examples. The loader hoists them
91    /// onto `Pipeline.binds`.
92    #[serde(default, rename = "bind")]
93    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
94    pub binds: Vec<manifest_bind::BindSpec>,
95    /// W209/R510-F6: top-level `[[on_change]]` hash-change hooks, hoisted onto
96    /// `Pipeline.on_change` (same root-level placement as `[[bind]]`).
97    #[serde(default)]
98    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
99    pub on_change: Vec<manifest_bind::OnChangeHook>,
100}
101
102#[derive(Debug, Deserialize)]
103#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
104pub struct PipelineConfig {
105    name: String,
106    label: String,
107    #[serde(default)]
108    steps: Vec<QedStep>,
109    #[serde(default)]
110    params: Option<HashMap<String, ParamDef>>,
111    #[serde(default)]
112    on_success: Vec<crate::types::Outcome>,
113    #[serde(default)]
114    on_fail: Vec<crate::types::Outcome>,
115    #[serde(default)]
116    triggers: Vec<crate::types::Trigger>,
117    #[serde(default)]
118    concurrency_key: Option<String>,
119    #[serde(default)]
120    placement: Placement,
121    #[serde(default)]
122    workspace: crate::types::WorkspaceMode,
123    #[serde(default)]
124    wraps: Option<String>,
125    #[serde(default)]
126    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
127    matrix: Option<crate::matrix::MatrixSpec>,
128    #[serde(default)]
129    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
130    toolchain: Option<crate::toolchain::ToolchainSpec>,
131    /// W207 Gap #6 (R513-F4): `[[finally]]` always-run teardown steps. Authored
132    /// at the `[pipeline]` level (a sibling of `[[steps]]`). Hoisted onto
133    /// [`Pipeline::finally`] and validated with [`QedStep::validate_finally`].
134    #[serde(default)]
135    finally: Vec<QedStep>,
136}
137
138#[derive(Clone)]
139pub struct PipelineLoader {
140    pub(crate) qed_dir: std::path::PathBuf,
141    /// Per-camp registry allowlist used by parse-time `push = true`
142    /// validation (R381-T6). Auto-loaded from `<qed_dir>/registries.toml`
143    /// on construction; tests can swap it with [`Self::with_registries`].
144    registries: RegistryConfig,
145    /// Per-camp peer registry used by [`SubPipelineRef::Peer`] resolution
146    /// (R494-F2). Auto-loaded from `<qed_dir>/peers.toml`; missing file is
147    /// fine — `Peer` refs will fail at the resolver with the same
148    /// "unresolvable target" surface unknown peers get.
149    pub(crate) peers: PeerConfig,
150}
151
152impl PipelineLoader {
153    /// Construct a loader rooted at `qed_dir`. Reads
154    /// `<qed_dir>/registries.toml` + `<qed_dir>/peers.toml` opportunistically
155    /// — missing files are fine. A malformed file surfaces on the first
156    /// `load*` call rather than at construction so callers don't have to
157    /// handle the error twice.
158    pub fn new(qed_dir: impl AsRef<Path>) -> Self {
159        let qed_dir = qed_dir.as_ref().to_path_buf();
160        let registries = RegistryConfig::load(&qed_dir).unwrap_or_default();
161        let peers = PeerConfig::load(&qed_dir).unwrap_or_default();
162        Self {
163            qed_dir,
164            registries,
165            peers,
166        }
167    }
168
169    /// Replace the auto-loaded registry config. Useful in tests when the
170    /// fixture qed_dir doesn't carry a `registries.toml`.
171    pub fn with_registries(mut self, registries: RegistryConfig) -> Self {
172        self.registries = registries;
173        self
174    }
175
176    /// Replace the auto-loaded peer config (R494-F2). Tests construct a
177    /// fixture loader that already knows about its sibling peer camps
178    /// without needing a `peers.toml` on disk.
179    pub fn with_peers(mut self, peers: PeerConfig) -> Self {
180        self.peers = peers;
181        self
182    }
183
184    /// Load a pipeline by name. Resolution order:
185    ///   1. `<qed_dir>/P{n}-<name>.toml` (or legacy `<name>.toml`)
186    ///   2. `<workspace>/.github/workflows/<name>.yml` (or `.yaml`),
187    ///      synthesised into a one-step `StepKind::GhaWorkflow` pipeline.
188    pub fn load(&self, name: &str) -> Result<Pipeline, ConfigError> {
189        if let Some(path) = find_pipeline_file(&self.qed_dir, name) {
190            return self.load_from_file(&path);
191        }
192        if let Some(entry) = self.find_gha_workflow(name) {
193            return Ok(synthesise_gha_pipeline(&entry));
194        }
195        Err(ConfigError::NotFound(name.to_string()))
196    }
197
198    /// Returns `true` when a camp-level TOML file exists for `name`
199    /// (prefixed or legacy form).
200    pub fn has_camp_file(&self, name: &str) -> bool {
201        find_pipeline_file(&self.qed_dir, name).is_some()
202    }
203
204    /// Camp root, derived from `<qed_dir>/../..` (the conventional
205    /// `<camp>/.yah/qed` layout). Falls back to `qed_dir` itself when the
206    /// loader is rooted somewhere unusual (test fixtures, in-memory dirs).
207    pub fn workspace_root(&self) -> PathBuf {
208        self.qed_dir
209            .parent()
210            .and_then(|p| p.parent())
211            .map(|p| p.to_path_buf())
212            .unwrap_or_else(|| self.qed_dir.clone())
213    }
214
215    /// Walk `<workspace>/.github/workflows/*.yml` and return parsed
216    /// workflows. Files that fail to parse are skipped (logged at `warn`)
217    /// so a single malformed workflow doesn't blank the whole catalog.
218    pub fn list_gha_workflows(&self) -> Vec<GhaWorkflowEntry> {
219        let workflows_dir = self.workspace_root().join(".github").join("workflows");
220        if !workflows_dir.exists() {
221            return Vec::new();
222        }
223        let mut out = Vec::new();
224        let entries = match fs::read_dir(&workflows_dir) {
225            Ok(it) => it,
226            Err(_) => return Vec::new(),
227        };
228        for entry in entries.flatten() {
229            let path = entry.path();
230            let ext = path.extension().and_then(|e| e.to_str());
231            if ext != Some("yml") && ext != Some("yaml") {
232                continue;
233            }
234            let name = match path.file_stem().and_then(|s| s.to_str()) {
235                Some(s) => s.to_string(),
236                None => continue,
237            };
238            let content = match fs::read_to_string(&path) {
239                Ok(c) => c,
240                Err(_) => continue,
241            };
242            let workflow = match yah_qed_gha::parse_workflow(&content) {
243                Ok(w) => w,
244                Err(e) => {
245                    tracing::warn!(
246                        path = %path.display(),
247                        error = %e,
248                        "gha workflow parse failed; skipping",
249                    );
250                    continue;
251                }
252            };
253            let rel_path = path
254                .strip_prefix(self.workspace_root())
255                .map(|p| p.to_path_buf())
256                .unwrap_or_else(|_| path.clone());
257            out.push(GhaWorkflowEntry {
258                name,
259                rel_path,
260                workflow,
261            });
262        }
263        out.sort_by(|a, b| a.name.cmp(&b.name));
264        out
265    }
266
267    fn find_gha_workflow(&self, name: &str) -> Option<GhaWorkflowEntry> {
268        self.list_gha_workflows()
269            .into_iter()
270            .find(|w| w.name == name)
271    }
272
273    /// List all pipeline names from `<qed_dir>/*.toml`, sorted by P-number
274    /// prefix (unprefixed files last).
275    pub fn list_all(&self) -> Result<Vec<String>, ConfigError> {
276        let mut names: Vec<String> = Vec::new();
277
278        if self.qed_dir.exists() {
279            let mut file_entries: Vec<(u32, String)> = Vec::new();
280            for entry in fs::read_dir(&self.qed_dir)? {
281                let entry = entry?;
282                let path = entry.path();
283                if path.extension().map_or(false, |e| e == "toml") {
284                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
285                        let (num, name) = if let Some((n, base)) = parse_p_prefix(stem) {
286                            (n, base.to_string())
287                        } else {
288                            (u32::MAX, stem.to_string())
289                        };
290                        if !names.iter().any(|n| n == &name) {
291                            file_entries.push((num, name));
292                        }
293                    }
294                }
295            }
296            file_entries.sort_by_key(|(n, _)| *n);
297            names.extend(file_entries.into_iter().map(|(_, n)| n));
298        }
299
300        Ok(names)
301    }
302
303    /// Load a pipeline by name AND walk its sub-pipeline graph for cycles
304    /// and excessive nesting (R488-F1/F2). Use this before handing a
305    /// pipeline to [`PipelineRunner`](crate::runner::PipelineRunner) when
306    /// you want parse-time confirmation that the SubPipeline graph is
307    /// well-formed; plain [`Self::load`] skips the walk so loading
308    /// individual children doesn't re-validate the whole graph repeatedly.
309    pub fn load_and_validate_graph(&self, name: &str) -> Result<Pipeline, ConfigError> {
310        let pipeline = self.load(name)?;
311        let resolver = LoaderSubPipelineResolver::new(self.clone());
312        crate::types::validate_sub_pipeline_graph(&pipeline, &resolver)?;
313        Ok(pipeline)
314    }
315
316    /// Return P-numbers for the requested pipeline names. Numbers come from
317    /// the numeric prefix of `P{n}-{name}.toml` files in `<qed_dir>`. Names
318    /// with no prefixed file return no entry; callers already treat
319    /// `p_numbers.get(name)` as `Option<u32>`.
320    pub fn load_p_numbers(&self, names: &[String]) -> HashMap<String, u32> {
321        let mut map: HashMap<String, u32> = HashMap::new();
322        if self.qed_dir.exists() {
323            if let Ok(entries) = fs::read_dir(&self.qed_dir) {
324                for entry in entries.flatten() {
325                    let path = entry.path();
326                    if path.extension().map_or(false, |e| e == "toml") {
327                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
328                            if let Some((num, name)) = parse_p_prefix(stem) {
329                                map.insert(name.to_string(), num);
330                            }
331                        }
332                    }
333                }
334            }
335        }
336        names
337            .iter()
338            .filter_map(|n| map.get(n).map(|&v| (n.clone(), v)))
339            .collect()
340    }
341
342    /// List only custom pipeline files from `.yah/qed/` (excludes built-ins),
343    /// returning the pipeline name (prefix stripped).
344    pub fn list_files(&self) -> Result<Vec<String>, ConfigError> {
345        let mut pipelines = Vec::new();
346        if self.qed_dir.exists() {
347            for entry in fs::read_dir(&self.qed_dir)? {
348                let entry = entry?;
349                let path = entry.path();
350                if path.extension().map_or(false, |ext| ext == "toml") {
351                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
352                        let name = if let Some((_, base)) = parse_p_prefix(stem) {
353                            base.to_string()
354                        } else {
355                            stem.to_string()
356                        };
357                        pipelines.push(name);
358                    }
359                }
360            }
361        }
362        Ok(pipelines)
363    }
364
365    #[cfg(test)]
366    fn load_from_str(&self, content: &str) -> Result<Pipeline, ConfigError> {
367        let parsed: PipelineToml = toml::from_str(content)?;
368        let pipeline = Pipeline {
369            name: parsed.pipeline.name,
370            label: parsed.pipeline.label,
371            steps: parsed.pipeline.steps,
372            params: parsed.pipeline.params.unwrap_or_default(),
373            on_success: parsed.pipeline.on_success,
374            on_fail: parsed.pipeline.on_fail,
375            triggers: parsed.pipeline.triggers,
376            concurrency_key: parsed.pipeline.concurrency_key,
377            placement: parsed.pipeline.placement,
378            workspace: parsed.pipeline.workspace,
379            wraps: parsed.pipeline.wraps,
380            matrix: parsed.pipeline.matrix,
381            toolchain: parsed.pipeline.toolchain,
382            binds: parsed.binds,
383            on_change: parsed.on_change,
384            finally: parsed.pipeline.finally,
385        };
386        self.validate_steps(&pipeline)?;
387        self.validate_binds(&pipeline)?;
388        Ok(pipeline)
389    }
390
391    /// Public helper: parse a pipeline directly from a file path, bypassing
392    /// the `<qed_dir>/P{n}-<name>.toml` lookup. Used by `qed plan
393    /// <path>.toml` to preview drafts that haven't been moved into the camp
394    /// pipeline directory yet.
395    pub fn parse_from_path(&self, path: &Path) -> Result<Pipeline, ConfigError> {
396        self.load_from_file(path)
397    }
398
399    pub(crate) fn load_from_file(&self, path: &Path) -> Result<Pipeline, ConfigError> {
400        let content = fs::read_to_string(path)?;
401        let parsed: PipelineToml = toml::from_str(&content)?;
402        let pipeline = Pipeline {
403            name: parsed.pipeline.name,
404            label: parsed.pipeline.label,
405            steps: parsed.pipeline.steps,
406            params: parsed.pipeline.params.unwrap_or_default(),
407            on_success: parsed.pipeline.on_success,
408            on_fail: parsed.pipeline.on_fail,
409            triggers: parsed.pipeline.triggers,
410            concurrency_key: parsed.pipeline.concurrency_key,
411            placement: parsed.pipeline.placement,
412            workspace: parsed.pipeline.workspace,
413            wraps: parsed.pipeline.wraps,
414            matrix: parsed.pipeline.matrix,
415            toolchain: parsed.pipeline.toolchain,
416            binds: parsed.binds,
417            on_change: parsed.on_change,
418            finally: parsed.pipeline.finally,
419        };
420        self.validate_steps(&pipeline)?;
421        self.validate_binds(&pipeline)?;
422        Ok(pipeline)
423    }
424
425    /// Run [`QedStep::validate`] across every step, then enforce the
426    /// per-camp registry allowlist on any `build-image` step with
427    /// `push = true` (R381-T6). Surfaces the *first* failure — pipeline TOML
428    /// authors get one error at a time, which is friendlier than a wall of
429    /// validation failures.
430    fn validate_steps(&self, pipeline: &Pipeline) -> Result<(), ConfigError> {
431        for step in &pipeline.steps {
432            step.validate()?;
433            if matches!(step.kind, StepKind::BuildImage) && step.push {
434                let tag_for_host = step.tag.as_deref().or(step.image.as_deref()).unwrap_or("");
435                let host = extract_registry_host(tag_for_host);
436                if !self.registries.is_writable(host) {
437                    return Err(ConfigError::InvalidStep(
438                        StepValidationError::PushRequiresWritableRegistry {
439                            step: step.name.clone(),
440                            host: host.to_string(),
441                        },
442                    ));
443                }
444            }
445        }
446        // R513-F4: `[[finally]]` teardown steps validate with the stricter
447        // finally rule (subprocess-only, never background) on top of the normal
448        // kind-specific checks.
449        for step in &pipeline.finally {
450            step.validate_finally()
451                .map_err(ConfigError::InvalidStep)?;
452        }
453        Ok(())
454    }
455
456    /// W209 parse-time bind validation: every `[[bind]].from` that names a
457    /// step output must reference (a) a step that exists in this pipeline,
458    /// and (b) an output key declared on that step. URI-shaped `from`
459    /// (`registry://...`) is the escape hatch and skips this check. Surfaces
460    /// the first offender — authors get one error at a time, same as
461    /// `validate_steps`.
462    fn validate_binds(&self, pipeline: &Pipeline) -> Result<(), ConfigError> {
463        for bind in &pipeline.binds {
464            match &bind.from {
465                manifest_bind::OutputRef::Uri(_) => continue,
466                manifest_bind::OutputRef::StepOutput { step, key } => {
467                    let Some(producer) = pipeline.steps.iter().find(|s| &s.name == step) else {
468                        return Err(ConfigError::InvalidBind(format!(
469                            "[[bind]] file = {:?}: from references unknown step {step:?}",
470                            bind.file
471                        )));
472                    };
473                    if !producer.outputs.iter().any(|o| &o.name == key) {
474                        return Err(ConfigError::InvalidBind(format!(
475                            "[[bind]] file = {:?}: step {step:?} does not declare output {key:?} \
476                             (declare it under [[pipeline.steps]].outputs)",
477                            bind.file
478                        )));
479                    }
480                }
481            }
482        }
483        // W209/R510-F6: every `[[on_change]].bind` selector must reference a
484        // declared `[[bind]].path` — a hook keyed off a slot nothing binds is
485        // dead config (a typo'd selector). Same first-offender surface as the
486        // bind checks above.
487        for hook in &pipeline.on_change {
488            if !pipeline.binds.iter().any(|b| b.path == hook.bind) {
489                return Err(ConfigError::InvalidBind(format!(
490                    "[[on_change]] bind = {:?}: no [[bind]] declares path {:?} \
491                     (the selector must match a bound slot's `path`)",
492                    hook.bind, hook.bind
493                )));
494            }
495        }
496        Ok(())
497    }
498}
499
500/// Bridge a [`PipelineLoader`] into the [`SubPipelineResolver`] trait so
501/// [`PipelineRunner`](crate::runner::PipelineRunner) can recurse into
502/// SubPipeline children without `runner.rs` taking a direct dependency on
503/// the loader (and so callers don't need to write their own resolver).
504///
505/// Resolution:
506/// - `Builtin(name)` → `loader.load(name)` (which itself prefers camp-level
507///   `.yah/qed/<name>.toml` over the bundled builtin — same precedence as
508///   any other pipeline lookup).
509/// - `Path(p)` → `loader.load_from_file(&p)` against `p` as-is when
510///   absolute, otherwise resolved against the loader's `qed_dir` parent
511///   (the camp root).
512/// - `GhaWorkflow { .. }` → returns `None` until W200-F9 (StepKind::GhaWorkflow
513///   + native runtime dispatch) lands; the runner surfaces this as a clear
514///   "unresolvable" `StepFailed` at execution time.
515///
516/// Errors from the underlying `load` are swallowed into `None` so the
517/// walker / runner can give consistent "unresolvable" error messages
518/// (rather than threading a richer error through `SubPipelineResolver`).
519/// Operators see the missing-pipeline path/name in the runner's
520/// `StepFailed.msg`; if `load` returned an error mid-recursion, the
521/// equivalent surface is "target not found".
522/// One parsed `.github/workflows/<name>.yml` surfaced by
523/// [`PipelineLoader::list_gha_workflows`]. The daemon's `qed.pipelines`
524/// handler uses this to flatten jobs/steps into a `QedPipelineWire`, and
525/// [`PipelineLoader::load`] uses it to synthesise a one-step
526/// `StepKind::GhaWorkflow` pipeline when an operator runs the workflow by
527/// name (so `yah qed run release` works for `.github/workflows/release.yml`).
528pub struct GhaWorkflowEntry {
529    /// Filename stem (`release.yml` → `release`). The catalog key.
530    pub name: String,
531    /// Path relative to the workspace root (`.github/workflows/release.yml`).
532    pub rel_path: PathBuf,
533    /// Fully parsed workflow as returned by `yah_qed_gha::parse_workflow`. The
534    /// daemon walks `workflow.jobs[].steps[]` to build the wire's `steps[]`,
535    /// keeping a single source of truth between visualisation and execution.
536    pub workflow: yah_qed_gha::Workflow,
537}
538
539/// Synthesise a one-step `StepKind::GhaWorkflow` pipeline that wraps the
540/// given workflow entry. Mirrors [`SubPipelineRef::GhaWorkflow`] resolution
541/// so `yah qed run <workflow>` and `target = { gha-workflow = ... }` end up
542/// at the same runner arm.
543fn synthesise_gha_pipeline(entry: &GhaWorkflowEntry) -> Pipeline {
544    let step = QedStep {
545        background: false,
546        background_until: None,
547        wait_for: None,
548        manifest_stitch: None,
549        name: "gha-workflow".to_string(),
550        argv: Vec::new(),
551        cwd: None,
552        env: HashMap::new(),
553        timeout: None,
554        on_fail: OnFail::Abort,
555        produces: Vec::new(),
556        runtime: None,
557        kind: StepKind::GhaWorkflow,
558        image: None,
559        tag: None,
560        push: false,
561        platforms: Vec::new(),
562        binary_path: None,
563        triple: None,
564        package: None,
565        context: None,
566        load: false,
567        sub_pipeline: None,
568        outputs: Vec::new(),
569        import: None,
570        gha_workflow: Some(GhaWorkflowConfig {
571            path: entry.rel_path.clone(),
572            event: None,
573            inputs: HashMap::new(),
574        }),
575        matrix: None,
576        enabled: true,
577        activation: crate::types::StepActivation::Active,
578        if_cond: None,
579        platform: None,
580        toolchain: None,
581    };
582    Pipeline {
583        name: entry.name.clone(),
584        label: entry
585            .workflow
586            .name
587            .clone()
588            .unwrap_or_else(|| entry.name.clone()),
589        steps: vec![step],
590        params: HashMap::new(),
591        on_success: Vec::new(),
592        on_fail: Vec::new(),
593        triggers: Vec::new(),
594        concurrency_key: None,
595        placement: Placement::default(),
596        workspace: crate::types::WorkspaceMode::default(),
597        wraps: None,
598        matrix: None,
599        toolchain: None,
600        binds: Vec::new(),
601        on_change: Vec::new(),
602        finally: Vec::new(),
603    }
604}
605
606pub struct LoaderSubPipelineResolver {
607    loader: PipelineLoader,
608}
609
610impl LoaderSubPipelineResolver {
611    pub fn new(loader: PipelineLoader) -> Self {
612        Self { loader }
613    }
614
615    /// Resolve a local peer camp's root from `peers.toml`, relative to this
616    /// camp. Returns `None` for unknown camps and for remote peers (`rig`
617    /// set) — those don't resolve to a local path. Shared by [`resolve`]
618    /// (to load the peer's pipeline) and [`resolved_camp_root`] (to run that
619    /// pipeline's steps in the peer's workspace).
620    fn local_peer_camp_root(&self, camp: &str) -> Option<std::path::PathBuf> {
621        let entry = self.loader.peers.get(camp)?;
622        if entry.rig.is_some() {
623            return None;
624        }
625        if entry.path.is_absolute() {
626            return Some(entry.path.clone());
627        }
628        // self.loader.qed_dir is `<this camp root>/.yah/qed`; pop twice to
629        // reach `<this camp root>`, then join the peer's relative path.
630        self.loader
631            .qed_dir
632            .parent()
633            .and_then(|p| p.parent())
634            .map(|root| root.join(&entry.path))
635    }
636}
637
638impl SubPipelineResolver for LoaderSubPipelineResolver {
639    fn resolve(&self, target: &SubPipelineRef) -> Option<Pipeline> {
640        match target {
641            SubPipelineRef::Builtin(name) => self.loader.load(name).ok(),
642            SubPipelineRef::Path(p) => {
643                let resolved: std::path::PathBuf = if p.is_absolute() {
644                    p.clone()
645                } else {
646                    // qed_dir is conventionally `<camp>/.yah/qed`; its parent
647                    // is `<camp>/.yah` — pop once more to reach the camp root
648                    // so a SubPipeline path of `.yah/qed/foo.toml` resolves
649                    // correctly against the camp.
650                    self.loader
651                        .qed_dir
652                        .parent()
653                        .and_then(|p| p.parent())
654                        .map(|root| root.join(p))
655                        .unwrap_or_else(|| p.clone())
656                };
657                self.loader.load_from_file(&resolved).ok()
658            }
659            // GhaWorkflow children synthesize a one-step Pipeline whose
660            // single step is `StepKind::GhaWorkflow` (W200-F9). The runner's
661            // own arm then dispatches to yah_qed_gha::execute_workflow and lifts
662            // ProducedArtifacts the same way Subprocess `produces` does.
663            // Going through SubPipeline preserves the propagate.produces /
664            // suppress_publish_outcomes plumbing so a child workflow's R2
665            // staging fires from the parent's terminal publish, not the
666            // child's.
667            SubPipelineRef::GhaWorkflow {
668                path,
669                event,
670                inputs,
671            } => {
672                let step = crate::types::QedStep {
673                    background: false,
674                    background_until: None,
675                    wait_for: None,
676                    manifest_stitch: None,
677                    name: "gha-workflow".into(),
678                    argv: Vec::new(),
679                    cwd: None,
680                    env: std::collections::HashMap::new(),
681                    timeout: None,
682                    on_fail: crate::types::OnFail::Abort,
683                    produces: Vec::new(),
684                    runtime: None,
685                    kind: crate::types::StepKind::GhaWorkflow,
686                    image: None,
687                    tag: None,
688                    push: false,
689                    platforms: Vec::new(),
690                    binary_path: None,
691                    triple: None,
692                    package: None,
693                    context: None,
694                    load: false,
695                    sub_pipeline: None,
696                    outputs: Vec::new(),
697                    import: None,
698                    gha_workflow: Some(crate::types::GhaWorkflowConfig {
699                        path: path.clone(),
700                        event: event.clone(),
701                        inputs: inputs.clone(),
702                    }),
703                    matrix: None,
704                    enabled: true,
705                    activation: crate::types::StepActivation::Active,
706                    if_cond: None,
707                    platform: None,
708                    toolchain: None,
709                };
710                Some(crate::types::Pipeline {
711                    name: format!("gha-workflow:{}", path.display()),
712                    label: String::new(),
713                    concurrency_key: None,
714                    steps: vec![step],
715                    triggers: Vec::new(),
716                    on_success: Vec::new(),
717                    on_fail: Vec::new(),
718                    placement: crate::types::Placement::default(),
719                    workspace: crate::types::WorkspaceMode::default(),
720                    wraps: None,
721                    matrix: None,
722                    params: std::collections::HashMap::new(),
723                    toolchain: None,
724                    binds: Vec::new(),
725                    on_change: Vec::new(),
726                    finally: Vec::new(),
727                })
728            }
729            // Peer resolution (R494-F2). Look the peer up in this camp's
730            // `peers.toml`; resolve its camp root relative to ours (or use
731            // the absolute path for remote peers, which T5 will refine into
732            // a typed unsupported-error path — for now they swallow to
733            // None like any unresolvable target). Stamp `concurrency_key`
734            // to `peer:<camp>` when the loaded pipeline didn't set one
735            // itself, so two yah runs invoking different pipelines in the
736            // same peer camp (e.g. cheers/build + cheers/test) still
737            // serialize on cheers' shared `target/`.
738            SubPipelineRef::Peer { camp, pipeline } => {
739                // Remote peers (`rig` set) go through kamaji, which isn't
740                // wired yet. `local_peer_camp_root` returns None for them and
741                // for unknown camps; the runner consults `unresolved_reason`
742                // below to surface a typed message in StepFailed.msg rather
743                // than the generic "target unresolvable" tail.
744                let peer_camp_root = self.local_peer_camp_root(camp)?;
745                let peer_qed_dir = peer_camp_root.join(".yah").join("qed");
746                let peer_loader = PipelineLoader::new(&peer_qed_dir);
747                let mut child = peer_loader.load(pipeline).ok()?;
748                if child.concurrency_key.is_none() {
749                    child.concurrency_key = Some(format!("peer:{camp}"));
750                }
751                Some(child)
752            }
753        }
754    }
755
756    fn unresolved_reason(&self, target: &SubPipelineRef) -> Option<String> {
757        match target {
758            SubPipelineRef::Peer { camp, pipeline } => match self.loader.peers.get(camp) {
759                None => Some(format!(
760                    "peer camp `{camp}` is not declared in `{}/peers.toml` \
761                     (add `[peer.{camp}]` with `path = \"...\"`)",
762                    self.loader.qed_dir.display()
763                )),
764                Some(entry) => entry
765                    .rig
766                    .as_ref()
767                    .map(|rig| {
768                        format!(
769                            "remote peer `{camp}` lives on rig `{rig}` — \
770                         cross-rig peer execution is not yet supported \
771                         (R494-T5: kamaji hop pending). Drop the `rig = ...` \
772                         field on `[peer.{camp}]` in peers.toml to run the \
773                         peer camp locally, or wait for R494-F10.",
774                        )
775                    })
776                    .or_else(|| {
777                        Some(format!(
778                            "peer camp `{camp}` is declared but pipeline `{pipeline}` \
779                     was not found in `{}/.yah/qed/` \
780                     (check the peer's pipeline name)",
781                            entry.path.display()
782                        ))
783                    }),
784            },
785            _ => None,
786        }
787    }
788
789    fn resolved_camp_root(&self, target: &SubPipelineRef) -> Option<std::path::PathBuf> {
790        // Only Peer children switch camps; Builtin/Path/GhaWorkflow run in
791        // the parent's camp (return None → runner inherits parent camp_root).
792        match target {
793            SubPipelineRef::Peer { camp, .. } => self.local_peer_camp_root(camp),
794            _ => None,
795        }
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn parse_p_prefix_parses_canonical_form() {
805        assert_eq!(
806            parse_p_prefix("P006-build-yah-yubaba"),
807            Some((6, "build-yah-yubaba"))
808        );
809        assert_eq!(parse_p_prefix("P001-check"), Some((1, "check")));
810        assert_eq!(
811            parse_p_prefix("P013-full-release"),
812            Some((13, "full-release"))
813        );
814    }
815
816    #[test]
817    fn parse_p_prefix_rejects_non_prefixed() {
818        assert_eq!(parse_p_prefix("check"), None);
819        assert_eq!(parse_p_prefix("publish-assets"), None);
820        assert_eq!(parse_p_prefix("peers"), None);
821        assert_eq!(parse_p_prefix("P-bad"), None);
822        assert_eq!(parse_p_prefix("P"), None);
823    }
824    use crate::registries::RegistryEntry;
825    use crate::types::Outcome;
826
827    /// W209: round-trip a pipeline TOML that declares a typed step output
828    /// and a `[[bind]]` referencing it. Loader must parse, type-validate,
829    /// and surface the BindSpec on the loaded Pipeline.
830    #[test]
831    fn loads_pipeline_with_typed_output_and_bind() {
832        let toml = r#"
833[pipeline]
834name = "publish-assets"
835label = "Publish whisper assets"
836
837[[pipeline.steps]]
838name = "apply"
839kind = "subprocess"
840argv = ["yah", "cloud", "apply"]
841
842[[pipeline.steps.outputs]]
843name = "discovered_asset_blake3"
844type = "blake3-hex"
845
846[[pipeline.steps.outputs]]
847name = "discovered_fetch_blake3"
848type = "blake3-hex"
849
850[[bind]]
851file   = "app/yah/desktop/assets/whisper/workload.toml"
852path   = "asset[filename='whisper.tar.gz'].blake3"
853from   = "apply.outputs.discovered_asset_blake3"
854intent = "latest"
855
856[[bind]]
857file   = "app/yah/desktop/assets/whisper/workload.toml"
858path   = "asset[filename='whisper.tar.gz'].derive.fetch.blake3"
859from   = "apply.outputs.discovered_fetch_blake3"
860"#;
861        let dir = tempfile::tempdir().unwrap();
862        let loader = PipelineLoader::new(dir.path());
863        let pipeline = loader.load_from_str(toml).expect("loads cleanly");
864        assert_eq!(pipeline.binds.len(), 2);
865        assert_eq!(pipeline.steps[0].outputs.len(), 2);
866        assert_eq!(
867            pipeline.steps[0].outputs[0].kind,
868            manifest_bind::ValueType::Blake3Hex,
869        );
870        // First bind = explicit latest, second omits intent and defaults to pin.
871        assert!(matches!(
872            pipeline.binds[0].intent,
873            manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest)
874        ));
875        assert!(matches!(
876            pipeline.binds[1].intent,
877            manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Pin)
878        ));
879    }
880
881    /// W209: a bind whose `from` references an undeclared step output is
882    /// rejected at parse time.
883    #[test]
884    fn rejects_bind_referencing_undeclared_output() {
885        let toml = r#"
886[pipeline]
887name = "publish-assets"
888label = "Publish whisper assets"
889
890[[pipeline.steps]]
891name = "apply"
892kind = "subprocess"
893argv = ["yah", "cloud", "apply"]
894
895[[bind]]
896file   = "workload.toml"
897path   = "image"
898from   = "apply.outputs.missing_key"
899intent = "latest"
900"#;
901        let dir = tempfile::tempdir().unwrap();
902        let loader = PipelineLoader::new(dir.path());
903        let err = loader.load_from_str(toml).unwrap_err();
904        assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
905    }
906
907    /// R513-F4: a `[[pipeline.finally]]` subprocess teardown step parses and is
908    /// hoisted onto `Pipeline::finally`.
909    #[test]
910    fn parses_finally_teardown_steps() {
911        let toml = r#"
912[pipeline]
913name = "e2e"
914label = "Dashboard E2E"
915
916[[pipeline.steps]]
917name = "test"
918kind = "subprocess"
919argv = ["playwright", "test"]
920
921[[pipeline.finally]]
922name = "upload-traces"
923kind = "subprocess"
924argv = ["aws", "s3", "cp", "traces/", "s3://ci/traces/", "--recursive"]
925"#;
926        let dir = tempfile::tempdir().unwrap();
927        let loader = PipelineLoader::new(dir.path());
928        let pipeline = loader.load_from_str(toml).expect("loads cleanly");
929        assert_eq!(pipeline.finally.len(), 1);
930        assert_eq!(pipeline.finally[0].name, "upload-traces");
931        assert_eq!(pipeline.finally[0].kind, StepKind::Subprocess);
932    }
933
934    /// R513-F4: a non-subprocess `[[pipeline.finally]]` step is rejected at
935    /// parse time (v1 teardown is subprocess-only).
936    #[test]
937    fn rejects_non_subprocess_finally_step() {
938        let toml = r#"
939[pipeline]
940name = "e2e"
941label = "Dashboard E2E"
942
943[[pipeline.steps]]
944name = "test"
945kind = "subprocess"
946argv = ["true"]
947
948[[pipeline.finally]]
949name = "gate"
950kind = "wait-for"
951[pipeline.finally.wait_for]
952http = "http://localhost:3000/health"
953"#;
954        let dir = tempfile::tempdir().unwrap();
955        let loader = PipelineLoader::new(dir.path());
956        let err = loader.load_from_str(toml).unwrap_err();
957        assert!(
958            matches!(
959                err,
960                ConfigError::InvalidStep(StepValidationError::FinallyRequiresSubprocess(_))
961            ),
962            "got {err:?}"
963        );
964    }
965
966    /// W209: a bind whose `from` names a step that doesn't exist in this
967    /// pipeline is rejected at parse time.
968    #[test]
969    fn rejects_bind_referencing_unknown_step() {
970        let toml = r#"
971[pipeline]
972name = "publish-assets"
973label = "Publish whisper assets"
974
975[[pipeline.steps]]
976name = "apply"
977kind = "subprocess"
978argv = ["yah", "cloud", "apply"]
979
980[[bind]]
981file   = "workload.toml"
982path   = "image"
983from   = "doesnt_exist.outputs.x"
984intent = "latest"
985"#;
986        let dir = tempfile::tempdir().unwrap();
987        let loader = PipelineLoader::new(dir.path());
988        let err = loader.load_from_str(toml).unwrap_err();
989        assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
990    }
991
992    /// W209/R510-F6: a pipeline with `[[bind]]` + `[[on_change]]` round-trips
993    /// through the loader, with the hooks hoisted onto `Pipeline.on_change`
994    /// and the action variants parsed.
995    #[test]
996    fn loads_pipeline_with_on_change_hooks() {
997        let toml = r#"
998[pipeline]
999name = "publish-assets"
1000label = "Publish whisper assets"
1001
1002[[pipeline.steps]]
1003name = "apply"
1004kind = "subprocess"
1005argv = ["yah", "cloud", "apply"]
1006
1007[[pipeline.steps.outputs]]
1008name = "discovered_asset_blake3"
1009type = "blake3-hex"
1010
1011[[bind]]
1012file   = "app/yah/desktop/assets/whisper/workload.toml"
1013path   = "asset[filename='whisper.tar.gz'].blake3"
1014from   = "apply.outputs.discovered_asset_blake3"
1015intent = "latest"
1016
1017[[on_change]]
1018bind   = "asset[filename='whisper.tar.gz'].blake3"
1019action = { pipeline = "release.bump-manifest", params = { component = "whisper-coreml" } }
1020
1021[[on_change]]
1022bind   = "asset[filename='whisper.tar.gz'].blake3"
1023action = { journal = ".yah/qed/whisper.journal" }
1024"#;
1025        let dir = tempfile::tempdir().unwrap();
1026        let loader = PipelineLoader::new(dir.path());
1027        let pipeline = loader.load_from_str(toml).expect("loads cleanly");
1028        assert_eq!(pipeline.on_change.len(), 2);
1029        assert!(matches!(
1030            pipeline.on_change[0].action,
1031            manifest_bind::OnChangeAction::Pipeline { .. }
1032        ));
1033        assert!(matches!(
1034            pipeline.on_change[1].action,
1035            manifest_bind::OnChangeAction::Journal { .. }
1036        ));
1037    }
1038
1039    /// W209/R510-F6: an `[[on_change]]` whose `bind` selector matches no
1040    /// declared `[[bind]].path` is dead config and rejected at parse time.
1041    #[test]
1042    fn rejects_on_change_referencing_undeclared_bind() {
1043        let toml = r#"
1044[pipeline]
1045name = "publish-assets"
1046label = "Publish whisper assets"
1047
1048[[pipeline.steps]]
1049name = "apply"
1050kind = "subprocess"
1051argv = ["yah", "cloud", "apply"]
1052
1053[[pipeline.steps.outputs]]
1054name = "discovered_asset_blake3"
1055type = "blake3-hex"
1056
1057[[bind]]
1058file   = "workload.toml"
1059path   = "blake3"
1060from   = "apply.outputs.discovered_asset_blake3"
1061intent = "latest"
1062
1063[[on_change]]
1064bind   = "image"
1065action = { journal = ".yah/qed/x.journal" }
1066"#;
1067        let dir = tempfile::tempdir().unwrap();
1068        let loader = PipelineLoader::new(dir.path());
1069        let err = loader.load_from_str(toml).unwrap_err();
1070        assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
1071    }
1072
1073    /// W209: URI-shaped `from` (escape hatch) bypasses the
1074    /// step/output-existence check — the producer is external.
1075    #[test]
1076    fn uri_from_bypasses_step_existence_check() {
1077        let toml = r#"
1078[pipeline]
1079name = "pin-image"
1080label = "Pin python image"
1081
1082[[pipeline.steps]]
1083name = "noop"
1084kind = "subprocess"
1085argv = ["true"]
1086
1087[[bind]]
1088file   = ".yah/qed/transforms/whisper-bundle-tar.toml"
1089path   = "image"
1090from   = "registry://python:3.12-slim"
1091intent = { semver = "^3.12" }
1092"#;
1093        let dir = tempfile::tempdir().unwrap();
1094        let loader = PipelineLoader::new(dir.path());
1095        let pipeline = loader.load_from_str(toml).expect("URI from loads cleanly");
1096        assert_eq!(pipeline.binds.len(), 1);
1097        assert!(matches!(
1098            pipeline.binds[0].from,
1099            manifest_bind::OutputRef::Uri(_)
1100        ));
1101    }
1102
1103    #[test]
1104    fn parses_on_success_outcomes_from_toml() {
1105        let loader = PipelineLoader::new(".yah/qed");
1106        let toml = r#"
1107[pipeline]
1108name    = "release"
1109label   = "Release pipeline"
1110
1111[[pipeline.steps]]
1112name = "build"
1113argv = ["cargo", "build", "--release", "-p", "yah"]
1114
1115[[pipeline.on_success]]
1116kind    = "yubaba-deploy"
1117service = "yah"
1118env     = "production"
1119
1120[[pipeline.on_success]]
1121kind     = "almanac-run"
1122pipeline = "update-release-index"
1123
1124[[pipeline.on_fail]]
1125kind     = "almanac-run"
1126pipeline = "notify-failure"
1127"#;
1128        let pipeline = loader.load_from_str(toml).expect("should parse");
1129        assert_eq!(pipeline.on_success.len(), 2);
1130        assert_eq!(pipeline.on_fail.len(), 1);
1131
1132        assert!(matches!(
1133            &pipeline.on_success[0],
1134            Outcome::WardenDeploy { service, env }
1135            if service == "yah" && env == "production"
1136        ));
1137        assert!(matches!(
1138            &pipeline.on_success[1],
1139            Outcome::AlmanacRun { pipeline } if pipeline == "update-release-index"
1140        ));
1141        assert!(matches!(
1142            &pipeline.on_fail[0],
1143            Outcome::AlmanacRun { pipeline } if pipeline == "notify-failure"
1144        ));
1145    }
1146
1147    #[test]
1148    fn parses_provider_outcome_with_config_table() {
1149        // R509: a vendor `provider` outcome (notarize) with a `with` config
1150        // table + base_url round-trips through the real loader onto
1151        // `Outcome::Provider`. This is the schema noisetable's release.apple.toml
1152        // drafts against for the mac slice (notarize → sparkle).
1153        let loader = PipelineLoader::new(".yah/qed");
1154        let toml = r#"
1155[pipeline]
1156name  = "release.apple"
1157label = "Apple release"
1158
1159[[pipeline.steps]]
1160name = "build"
1161argv = ["cargo", "build", "--release"]
1162
1163[[pipeline.on_success]]
1164kind     = "provider"
1165provider = "notarize"
1166base_url = "https://releases.yah.dev"
1167with     = { artifacts = ["desktop"] }
1168"#;
1169        let pipeline = loader.load_from_str(toml).expect("should parse");
1170        assert_eq!(pipeline.on_success.len(), 1);
1171        match &pipeline.on_success[0] {
1172            Outcome::Provider {
1173                provider,
1174                with,
1175                base_url,
1176            } => {
1177                assert_eq!(provider, "notarize");
1178                assert_eq!(base_url.as_deref(), Some("https://releases.yah.dev"));
1179                assert_eq!(with["artifacts"][0], "desktop");
1180            }
1181            other => panic!("expected Outcome::Provider, got {other:?}"),
1182        }
1183    }
1184
1185    #[test]
1186    fn pipeline_without_outcomes_defaults_to_empty() {
1187        let loader = PipelineLoader::new(".yah/qed");
1188        let toml = r#"
1189[pipeline]
1190name  = "check"
1191label = "Quick check"
1192
1193[[pipeline.steps]]
1194name = "cargo-check"
1195argv = ["cargo", "check"]
1196"#;
1197        let pipeline = loader.load_from_str(toml).expect("should parse");
1198        assert!(pipeline.on_success.is_empty());
1199        assert!(pipeline.on_fail.is_empty());
1200    }
1201
1202    #[test]
1203    fn parses_toolchain_pins_pipeline_and_step_scope() {
1204        // R507/W208: `[pipeline.toolchain]` + per-step `toolchain.<tool>`
1205        // override survive the real loader onto Pipeline/QedStep.
1206        let loader = PipelineLoader::new(".yah/qed");
1207        let toml = r#"
1208[pipeline]
1209name  = "release.apple"
1210label = "Apple release"
1211
1212[pipeline.toolchain]
1213rust  = "1.84.0"
1214xcode = "15.4"
1215ndk   = "r27"
1216
1217[[pipeline.steps]]
1218name = "build"
1219argv = ["cargo", "build", "--release"]
1220
1221[[pipeline.steps]]
1222name = "build-android"
1223argv = ["cargo", "ndk", "build"]
1224toolchain.ndk = "r26d"
1225"#;
1226        let pipeline = loader.load_from_str(toml).expect("should parse");
1227        let tc = pipeline
1228            .toolchain
1229            .as_ref()
1230            .expect("pipeline toolchain present");
1231        assert_eq!(tc.pins.get("xcode").map(String::as_str), Some("15.4"));
1232        assert_eq!(tc.pins.get("rust").map(String::as_str), Some("1.84.0"));
1233        // The build step inherits the pipeline pins (no override block).
1234        assert!(pipeline.steps[0].toolchain.is_none());
1235        // The android step carries its own ndk override.
1236        let step_tc = pipeline.steps[1]
1237            .toolchain
1238            .as_ref()
1239            .expect("step override present");
1240        assert_eq!(step_tc.pins.get("ndk").map(String::as_str), Some("r26d"));
1241        // Effective pins for the android step: pipeline rust/xcode + overridden ndk.
1242        let eff = crate::toolchain::effective_pins(
1243            pipeline.toolchain.as_ref(),
1244            pipeline.steps[1].toolchain.as_ref(),
1245        );
1246        assert_eq!(eff.get("ndk").map(String::as_str), Some("r26d"));
1247        assert_eq!(eff.get("rust").map(String::as_str), Some("1.84.0"));
1248    }
1249
1250    #[test]
1251    fn parses_schedule_trigger_from_toml() {
1252        use crate::types::Trigger;
1253
1254        let loader = PipelineLoader::new(".yah/qed");
1255        let toml = r#"
1256[pipeline]
1257name  = "nightly"
1258label = "Nightly CI run"
1259
1260[[pipeline.steps]]
1261name = "cargo-check"
1262argv = ["cargo", "check", "--workspace"]
1263
1264[[pipeline.triggers]]
1265kind = "schedule"
1266cron = "0 2 * * *"
1267
1268[[pipeline.triggers]]
1269kind = "manual"
1270"#;
1271        let pipeline = loader.load_from_str(toml).expect("should parse");
1272        assert_eq!(pipeline.triggers.len(), 2);
1273        assert!(matches!(
1274            &pipeline.triggers[0],
1275            Trigger::Schedule { cron } if cron == "0 2 * * *"
1276        ));
1277        assert!(matches!(&pipeline.triggers[1], Trigger::Manual));
1278    }
1279
1280    // R467-cleanup: the three serialize_builtin round-trip tests were deleted
1281    // alongside `builtins.rs` and `serialize_builtin_to_toml`. The pipelines
1282    // they exercised now live as ordinary `.yah/qed/P00*-<name>.toml` files
1283    // and are covered by the loader's general parse path below.
1284
1285    #[test]
1286    fn pipeline_without_triggers_defaults_to_empty_vec() {
1287        let loader = PipelineLoader::new(".yah/qed");
1288        let toml = r#"
1289[pipeline]
1290name  = "check"
1291label = "Quick check"
1292
1293[[pipeline.steps]]
1294name = "cargo-check"
1295argv = ["cargo", "check"]
1296"#;
1297        let pipeline = loader.load_from_str(toml).expect("should parse");
1298        assert!(pipeline.triggers.is_empty());
1299    }
1300
1301    #[test]
1302    fn parses_optional_runtime_per_step() {
1303        use velveteen::TaskRuntime;
1304
1305        let loader = PipelineLoader::new(".yah/qed");
1306        let toml = r#"
1307[pipeline]
1308name  = "mixed"
1309label = "Mixed runtime pipeline"
1310
1311[[pipeline.steps]]
1312name = "native-step"
1313argv = ["echo", "hi"]
1314
1315[[pipeline.steps]]
1316name = "container-step"
1317argv = ["echo", "hi"]
1318runtime = "container"
1319"#;
1320        let pipeline = loader.load_from_str(toml).expect("should parse");
1321        assert_eq!(pipeline.steps.len(), 2);
1322        assert!(
1323            pipeline.steps[0].runtime.is_none(),
1324            "no runtime ⇒ pipeline default"
1325        );
1326        assert_eq!(pipeline.steps[1].runtime, Some(TaskRuntime::Container));
1327    }
1328
1329    #[test]
1330    fn parses_build_image_step_from_toml() {
1331        use crate::types::StepKind;
1332
1333        // push=true requires a registries.toml entry — supply one inline.
1334        let registries = RegistryConfig {
1335            registries: vec![RegistryEntry {
1336                name: "ghcr".into(),
1337                host: "ghcr.io".into(),
1338                writable: true,
1339            }],
1340        };
1341        let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1342        let toml = r#"
1343[pipeline]
1344name  = "image"
1345label = "Bake an image"
1346
1347[[pipeline.steps]]
1348name  = "bake"
1349kind  = "build-image"
1350image = "yah-rust"
1351tag   = "ghcr.io/yah-ai/yah-rust:dev"
1352push  = true
1353runtime = "container"
1354"#;
1355        let pipeline = loader.load_from_str(toml).expect("valid build-image step");
1356        assert_eq!(pipeline.steps.len(), 1);
1357        let step = &pipeline.steps[0];
1358        assert_eq!(step.kind, StepKind::BuildImage);
1359        assert_eq!(step.image.as_deref(), Some("yah-rust"));
1360        assert_eq!(step.tag.as_deref(), Some("ghcr.io/yah-ai/yah-rust:dev"));
1361        assert!(step.push);
1362    }
1363
1364    // ── R381-T6 push validation ────────────────────────────────────────────
1365
1366    #[test]
1367    fn build_image_push_without_registry_rejected() {
1368        use crate::types::StepValidationError;
1369
1370        let loader = PipelineLoader::new(".yah/qed"); // no registries.toml
1371        let toml = r#"
1372[pipeline]
1373name  = "image"
1374label = "Bake an image"
1375
1376[[pipeline.steps]]
1377name  = "bake"
1378kind  = "build-image"
1379image = "yah-rust"
1380tag   = "ghcr.io/yah-ai/yah-rust:dev"
1381push  = true
1382"#;
1383        let err = loader.load_from_str(toml).expect_err("must reject");
1384        match err {
1385            ConfigError::InvalidStep(StepValidationError::PushRequiresWritableRegistry {
1386                step,
1387                host,
1388            }) => {
1389                assert_eq!(step, "bake");
1390                assert_eq!(host, "ghcr.io");
1391            }
1392            other => panic!("expected PushRequiresWritableRegistry, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn build_image_push_with_writable_registry_accepted() {
1398        let registries = RegistryConfig {
1399            registries: vec![RegistryEntry {
1400                name: "ghcr".into(),
1401                host: "ghcr.io".into(),
1402                writable: true,
1403            }],
1404        };
1405        let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1406        let toml = r#"
1407[pipeline]
1408name  = "image"
1409label = "Bake an image"
1410
1411[[pipeline.steps]]
1412name  = "bake"
1413kind  = "build-image"
1414image = "yah-rust"
1415tag   = "ghcr.io/yah-ai/yah-rust:dev"
1416push  = true
1417"#;
1418        loader
1419            .load_from_str(toml)
1420            .expect("writable registry should allow push");
1421    }
1422
1423    #[test]
1424    fn build_image_push_with_readonly_registry_rejected() {
1425        // Entry exists but writable=false → still rejected.
1426        let registries = RegistryConfig {
1427            registries: vec![RegistryEntry {
1428                name: "ghcr".into(),
1429                host: "ghcr.io".into(),
1430                writable: false,
1431            }],
1432        };
1433        let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1434        let toml = r#"
1435[pipeline]
1436name  = "image"
1437label = "Bake an image"
1438
1439[[pipeline.steps]]
1440name  = "bake"
1441kind  = "build-image"
1442image = "yah-rust"
1443tag   = "ghcr.io/yah-ai/yah-rust:dev"
1444push  = true
1445"#;
1446        loader
1447            .load_from_str(toml)
1448            .expect_err("readonly registry must reject push");
1449    }
1450
1451    #[test]
1452    fn build_image_push_false_ignores_registry_config() {
1453        // No registries.toml; push=false → no validation needed.
1454        let loader = PipelineLoader::new(".yah/qed");
1455        let toml = r#"
1456[pipeline]
1457name  = "image"
1458label = "Bake an image"
1459
1460[[pipeline.steps]]
1461name  = "bake"
1462kind  = "build-image"
1463image = "yah-rust"
1464tag   = "ghcr.io/yah-ai/yah-rust:dev"
1465# push omitted → default false → OCI archive fallback (R381-T4)
1466"#;
1467        loader
1468            .load_from_str(toml)
1469            .expect("push=false bypasses registry check");
1470    }
1471
1472    #[test]
1473    fn build_image_push_falls_back_to_image_when_tag_absent() {
1474        // A bare `image = "yah-rust"` with no tag and push=true: the host
1475        // derived from "yah-rust" is docker.io. No registry → rejected.
1476        use crate::types::StepValidationError;
1477
1478        let loader = PipelineLoader::new(".yah/qed");
1479        let toml = r#"
1480[pipeline]
1481name  = "image"
1482label = "Bake an image"
1483
1484[[pipeline.steps]]
1485name  = "bake"
1486kind  = "build-image"
1487image = "yah-rust"
1488push  = true
1489"#;
1490        let err = loader.load_from_str(toml).expect_err("must reject");
1491        match err {
1492            ConfigError::InvalidStep(StepValidationError::PushRequiresWritableRegistry {
1493                step,
1494                host,
1495            }) => {
1496                assert_eq!(step, "bake");
1497                assert_eq!(host, "docker.io", "no tag → docker.io fallback");
1498            }
1499            other => panic!("expected PushRequiresWritableRegistry, got {other:?}"),
1500        }
1501    }
1502
1503    #[test]
1504    fn build_image_step_without_image_field_rejected() {
1505        use crate::types::StepValidationError;
1506
1507        let loader = PipelineLoader::new(".yah/qed");
1508        let toml = r#"
1509[pipeline]
1510name  = "image"
1511label = "Bake an image"
1512
1513[[pipeline.steps]]
1514name = "bake"
1515kind = "build-image"
1516"#;
1517        let err = loader.load_from_str(toml).expect_err("must reject");
1518        match err {
1519            ConfigError::InvalidStep(StepValidationError::BuildImageMissingImage(name)) => {
1520                assert_eq!(name, "bake");
1521            }
1522            other => panic!("expected BuildImageMissingImage, got {other:?}"),
1523        }
1524    }
1525
1526    #[test]
1527    fn build_image_step_with_native_runtime_rejected() {
1528        use crate::types::StepValidationError;
1529
1530        let loader = PipelineLoader::new(".yah/qed");
1531        let toml = r#"
1532[pipeline]
1533name  = "image"
1534label = "Bake an image"
1535
1536[[pipeline.steps]]
1537name    = "bake"
1538kind    = "build-image"
1539image   = "yah-rust"
1540runtime = "native"
1541"#;
1542        let err = loader.load_from_str(toml).expect_err("must reject");
1543        match err {
1544            ConfigError::InvalidStep(StepValidationError::BuildImageNativeRuntime(name)) => {
1545                assert_eq!(name, "bake");
1546            }
1547            other => panic!("expected BuildImageNativeRuntime, got {other:?}"),
1548        }
1549    }
1550
1551    // ── R407-T2 package-native-tarball parse-time validation ───────────────
1552
1553    #[test]
1554    fn parses_package_native_tarball_step_from_toml() {
1555        use crate::types::StepKind;
1556
1557        let loader = PipelineLoader::new(".yah/qed");
1558        let toml = r#"
1559[pipeline]
1560name  = "pack-yubaba"
1561label = "Package native yubaba"
1562
1563[[pipeline.steps]]
1564name        = "pack"
1565kind        = "package-native-tarball"
1566image       = "yah-yubaba"
1567binary_path = "target/x86_64-unknown-linux-musl/release/yubaba"
1568triple      = "x86_64-unknown-linux-musl"
1569"#;
1570        let pipeline = loader.load_from_str(toml).expect("valid package step");
1571        assert_eq!(pipeline.steps.len(), 1);
1572        let step = &pipeline.steps[0];
1573        assert_eq!(step.kind, StepKind::PackageNativeTarball);
1574        assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1575        assert_eq!(
1576            step.binary_path.as_deref(),
1577            Some("target/x86_64-unknown-linux-musl/release/yubaba"),
1578        );
1579        assert_eq!(step.triple.as_deref(), Some("x86_64-unknown-linux-musl"));
1580    }
1581
1582    #[test]
1583    fn package_native_tarball_without_image_rejected_at_parse_time() {
1584        use crate::types::StepValidationError;
1585
1586        let loader = PipelineLoader::new(".yah/qed");
1587        let toml = r#"
1588[pipeline]
1589name  = "pack"
1590label = "pack"
1591
1592[[pipeline.steps]]
1593name        = "p"
1594kind        = "package-native-tarball"
1595binary_path = "target/release/yubaba"
1596"#;
1597        let err = loader.load_from_str(toml).expect_err("must reject");
1598        assert!(matches!(
1599            err,
1600            ConfigError::InvalidStep(StepValidationError::PackageNativeTarballMissingImage(ref n))
1601            if n == "p"
1602        ));
1603    }
1604
1605    #[test]
1606    fn package_native_tarball_without_binary_path_rejected_at_parse_time() {
1607        use crate::types::StepValidationError;
1608
1609        let loader = PipelineLoader::new(".yah/qed");
1610        let toml = r#"
1611[pipeline]
1612name  = "pack"
1613label = "pack"
1614
1615[[pipeline.steps]]
1616name  = "p"
1617kind  = "package-native-tarball"
1618image = "yah-yubaba"
1619"#;
1620        let err = loader.load_from_str(toml).expect_err("must reject");
1621        assert!(matches!(
1622            err,
1623            ConfigError::InvalidStep(StepValidationError::PackageNativeTarballMissingBinaryPath(ref n))
1624            if n == "p"
1625        ));
1626    }
1627
1628    #[test]
1629    fn package_native_tarball_with_container_runtime_rejected_at_parse_time() {
1630        use crate::types::StepValidationError;
1631
1632        let loader = PipelineLoader::new(".yah/qed");
1633        let toml = r#"
1634[pipeline]
1635name  = "pack"
1636label = "pack"
1637
1638[[pipeline.steps]]
1639name        = "p"
1640kind        = "package-native-tarball"
1641image       = "yah-yubaba"
1642binary_path = "target/release/yubaba"
1643runtime     = "container"
1644"#;
1645        let err = loader.load_from_str(toml).expect_err("must reject");
1646        assert!(matches!(
1647            err,
1648            ConfigError::InvalidStep(StepValidationError::PackageNativeTarballContainerRuntime(ref n))
1649            if n == "p"
1650        ));
1651    }
1652
1653    // ── R407-T3 musl-static-preflight parse-time validation ───────────────
1654
1655    #[test]
1656    fn parses_musl_static_preflight_step_from_toml() {
1657        use crate::types::StepKind;
1658
1659        let loader = PipelineLoader::new(".yah/qed");
1660        let toml = r#"
1661[pipeline]
1662name  = "yubaba-preflight"
1663label = "Gate yubaba against musl-static deps"
1664
1665[[pipeline.steps]]
1666name    = "musl-gate"
1667kind    = "musl-static-preflight"
1668package = "yubaba"
1669"#;
1670        let pipeline = loader.load_from_str(toml).expect("valid preflight step");
1671        assert_eq!(pipeline.steps.len(), 1);
1672        let step = &pipeline.steps[0];
1673        assert_eq!(step.kind, StepKind::MuslStaticPreflight);
1674        assert_eq!(step.package.as_deref(), Some("yubaba"));
1675    }
1676
1677    #[test]
1678    fn musl_static_preflight_without_package_rejected_at_parse_time() {
1679        use crate::types::StepValidationError;
1680
1681        let loader = PipelineLoader::new(".yah/qed");
1682        let toml = r#"
1683[pipeline]
1684name  = "preflight"
1685label = "preflight"
1686
1687[[pipeline.steps]]
1688name = "p"
1689kind = "musl-static-preflight"
1690"#;
1691        let err = loader.load_from_str(toml).expect_err("must reject");
1692        assert!(matches!(
1693            err,
1694            ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightMissingPackage(ref n))
1695            if n == "p"
1696        ));
1697    }
1698
1699    #[test]
1700    fn musl_static_preflight_with_container_runtime_rejected_at_parse_time() {
1701        use crate::types::StepValidationError;
1702
1703        let loader = PipelineLoader::new(".yah/qed");
1704        let toml = r#"
1705[pipeline]
1706name  = "preflight"
1707label = "preflight"
1708
1709[[pipeline.steps]]
1710name    = "p"
1711kind    = "musl-static-preflight"
1712package = "yubaba"
1713runtime = "container"
1714"#;
1715        let err = loader.load_from_str(toml).expect_err("must reject");
1716        assert!(matches!(
1717            err,
1718            ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightContainerRuntime(ref n))
1719            if n == "p"
1720        ));
1721    }
1722
1723    #[test]
1724    fn musl_static_preflight_with_argv_rejected_at_parse_time() {
1725        use crate::types::StepValidationError;
1726
1727        let loader = PipelineLoader::new(".yah/qed");
1728        let toml = r#"
1729[pipeline]
1730name  = "preflight"
1731label = "preflight"
1732
1733[[pipeline.steps]]
1734name    = "p"
1735kind    = "musl-static-preflight"
1736package = "yubaba"
1737argv    = ["cargo", "metadata"]
1738"#;
1739        let err = loader.load_from_str(toml).expect_err("must reject");
1740        assert!(matches!(
1741            err,
1742            ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightHasArgv(ref n))
1743            if n == "p"
1744        ));
1745    }
1746
1747    // ── R407-T5 sign-native-tarball parse-time validation ──────────────────
1748
1749    #[test]
1750    fn parses_sign_native_tarball_step_from_toml() {
1751        use crate::types::StepKind;
1752
1753        let loader = PipelineLoader::new(".yah/qed");
1754        let toml = r#"
1755[pipeline]
1756name  = "sign-yubaba"
1757label = "Sign native yubaba tarball"
1758
1759[[pipeline.steps]]
1760name   = "sign"
1761kind   = "sign-native-tarball"
1762image  = "yah-yubaba"
1763triple = "x86_64-unknown-linux-musl"
1764"#;
1765        let pipeline = loader.load_from_str(toml).expect("valid sign step");
1766        assert_eq!(pipeline.steps.len(), 1);
1767        let step = &pipeline.steps[0];
1768        assert_eq!(step.kind, StepKind::SignNativeTarball);
1769        assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1770        assert_eq!(step.triple.as_deref(), Some("x86_64-unknown-linux-musl"));
1771    }
1772
1773    #[test]
1774    fn sign_native_tarball_without_image_rejected_at_parse_time() {
1775        use crate::types::StepValidationError;
1776
1777        let loader = PipelineLoader::new(".yah/qed");
1778        let toml = r#"
1779[pipeline]
1780name  = "sign"
1781label = "sign"
1782
1783[[pipeline.steps]]
1784name = "s"
1785kind = "sign-native-tarball"
1786"#;
1787        let err = loader.load_from_str(toml).expect_err("must reject");
1788        assert!(matches!(
1789            err,
1790            ConfigError::InvalidStep(StepValidationError::SignNativeTarballMissingImage(ref n))
1791            if n == "s"
1792        ));
1793    }
1794
1795    #[test]
1796    fn sign_native_tarball_with_argv_rejected_at_parse_time() {
1797        use crate::types::StepValidationError;
1798
1799        let loader = PipelineLoader::new(".yah/qed");
1800        let toml = r#"
1801[pipeline]
1802name  = "sign"
1803label = "sign"
1804
1805[[pipeline.steps]]
1806name  = "s"
1807kind  = "sign-native-tarball"
1808image = "yah-yubaba"
1809argv  = ["cosign", "sign-blob"]
1810"#;
1811        let err = loader.load_from_str(toml).expect_err("must reject");
1812        assert!(matches!(
1813            err,
1814            ConfigError::InvalidStep(StepValidationError::SignNativeTarballHasArgv(ref n))
1815            if n == "s"
1816        ));
1817    }
1818
1819    #[test]
1820    fn sign_native_tarball_with_container_runtime_rejected_at_parse_time() {
1821        use crate::types::StepValidationError;
1822
1823        let loader = PipelineLoader::new(".yah/qed");
1824        let toml = r#"
1825[pipeline]
1826name  = "sign"
1827label = "sign"
1828
1829[[pipeline.steps]]
1830name    = "s"
1831kind    = "sign-native-tarball"
1832image   = "yah-yubaba"
1833runtime = "container"
1834"#;
1835        let err = loader.load_from_str(toml).expect_err("must reject");
1836        assert!(matches!(
1837            err,
1838            ConfigError::InvalidStep(StepValidationError::SignNativeTarballContainerRuntime(ref n))
1839            if n == "s"
1840        ));
1841    }
1842
1843    #[test]
1844    fn build_image_step_with_argv_rejected() {
1845        use crate::types::StepValidationError;
1846
1847        let loader = PipelineLoader::new(".yah/qed");
1848        let toml = r#"
1849[pipeline]
1850name  = "image"
1851label = "Bake an image"
1852
1853[[pipeline.steps]]
1854name  = "bake"
1855kind  = "build-image"
1856image = "yah-rust"
1857argv  = ["docker", "build", "."]
1858"#;
1859        let err = loader.load_from_str(toml).expect_err("must reject");
1860        match err {
1861            ConfigError::InvalidStep(StepValidationError::BuildImageHasArgv(name)) => {
1862                assert_eq!(name, "bake");
1863            }
1864            other => panic!("expected BuildImageHasArgv, got {other:?}"),
1865        }
1866    }
1867
1868    #[test]
1869    fn build_image_step_parses_context_and_load_fields() {
1870        use crate::types::StepKind;
1871        use std::path::PathBuf;
1872
1873        let loader = PipelineLoader::new(".yah/qed");
1874        let toml = r#"
1875[pipeline]
1876name  = "build-yubaba"
1877label = "Build yah-yubaba locally"
1878
1879[[pipeline.steps]]
1880name    = "image"
1881kind    = "build-image"
1882image   = "yah-yubaba"
1883tag     = "ghcr.io/yah-ai/yah-yubaba:latest"
1884context = "target/yah-yubaba-ctx"
1885load    = true
1886push    = false
1887"#;
1888        let pipeline = loader.load_from_str(toml).expect("valid build-image step");
1889        assert_eq!(pipeline.steps.len(), 1);
1890        let step = &pipeline.steps[0];
1891        assert_eq!(step.kind, StepKind::BuildImage);
1892        assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1893        assert_eq!(
1894            step.tag.as_deref(),
1895            Some("ghcr.io/yah-ai/yah-yubaba:latest")
1896        );
1897        assert_eq!(step.context, Some(PathBuf::from("target/yah-yubaba-ctx")));
1898        assert!(step.load);
1899        assert!(!step.push);
1900    }
1901
1902    /// R590-F4: the `rusty-v8-musl` pipeline shape parses — a subprocess step
1903    /// carrying a per-step `image`, `runtime = "container"`, and a
1904    /// `platform = { target = "…", native = true }` inline table — and that
1905    /// declaration resolves to Offload on an arm64 host (so `pipeline_needs_offload`
1906    /// tells the CLI to stand up the fleet path). Mirrors
1907    /// `.yah/qed/P018-rusty-v8-musl.toml`.
1908    #[test]
1909    fn native_container_run_step_parses_and_offloads() {
1910        let loader = PipelineLoader::new(".yah/qed");
1911        let toml = r#"
1912[pipeline]
1913name  = "rusty-v8-musl"
1914label = "Build rusty_v8 static lib for x86_64-unknown-linux-musl"
1915placement = "anywhere"
1916
1917[[pipeline.steps]]
1918name     = "build-v8-musl"
1919image    = "cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@sha256:a1fb9d9cc631dcb844fbbb949dc65a80be1d532fa80868c4df5ed4b21939f9a4"
1920runtime  = "container"
1921platform = { target = "x86_64-unknown-linux-musl", native = true }
1922argv     = ["build-v8.sh 'x86_64-unknown-linux-musl' '/tmp/out.tar.gz'"]
1923timeout  = 9000
1924"#;
1925        let pipeline = loader
1926            .load_from_str(toml)
1927            .expect("rusty-v8-musl pipeline shape must parse");
1928        assert_eq!(pipeline.steps.len(), 1);
1929        let step = &pipeline.steps[0];
1930        assert_eq!(
1931            step.image.as_deref(),
1932            Some(
1933                "cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64\
1934                 @sha256:a1fb9d9cc631dcb844fbbb949dc65a80be1d532fa80868c4df5ed4b21939f9a4"
1935            ),
1936            "the full digest-pinned ref survives the loader verbatim (R590-B5)",
1937        );
1938        let plat = step.platform.as_ref().expect("platform declared");
1939        assert_eq!(plat.target.as_deref(), Some("x86_64-unknown-linux-musl"));
1940        assert!(plat.native, "native flag must round-trip from the inline table");
1941
1942        // On an arm64 host the native x86 step offloads → the CLI needs the fleet.
1943        assert!(crate::runner::pipeline_needs_offload(
1944            &pipeline,
1945            "aarch64-apple-darwin"
1946        ));
1947        // On the x86 build-worker it's host-arch → no offload (runs there).
1948        assert!(!crate::runner::pipeline_needs_offload(
1949            &pipeline,
1950            "x86_64-unknown-linux-gnu"
1951        ));
1952    }
1953
1954    #[test]
1955    fn build_image_step_context_defaults_to_none_when_absent() {
1956        use crate::types::StepKind;
1957
1958        let loader = PipelineLoader::new(".yah/qed");
1959        let toml = r#"
1960[pipeline]
1961name  = "build-yubaba"
1962label = "Build image"
1963
1964[[pipeline.steps]]
1965name  = "image"
1966kind  = "build-image"
1967image = "yah-yubaba"
1968"#;
1969        let pipeline = loader.load_from_str(toml).expect("valid");
1970        let step = &pipeline.steps[0];
1971        assert_eq!(step.kind, StepKind::BuildImage);
1972        assert!(step.context.is_none(), "context should default to None");
1973        assert!(!step.load, "load should default to false");
1974    }
1975
1976    #[test]
1977    fn loader_resolver_synthesizes_pipeline_for_gha_workflow_target() {
1978        // W200-F9: a SubPipelineRef::GhaWorkflow target no longer returns
1979        // None — it resolves to a one-step Pipeline whose step kind is
1980        // GhaWorkflow + carries the path/event/inputs the parent declared.
1981        use crate::types::SubPipelineRef;
1982        let loader = PipelineLoader::new(".yah/qed");
1983        let resolver = LoaderSubPipelineResolver::new(loader);
1984        let mut inputs = std::collections::HashMap::new();
1985        inputs.insert("tag".into(), "v1.0.0".into());
1986        let target = SubPipelineRef::GhaWorkflow {
1987            path: std::path::PathBuf::from(".github/workflows/release.yml"),
1988            event: Some("workflow_dispatch".into()),
1989            inputs,
1990        };
1991        let pipeline = resolver.resolve(&target).expect("must resolve");
1992        assert_eq!(pipeline.steps.len(), 1);
1993        let step = &pipeline.steps[0];
1994        assert_eq!(step.kind, crate::types::StepKind::GhaWorkflow);
1995        let cfg = step.gha_workflow.as_ref().expect("gha_workflow block");
1996        assert_eq!(
1997            cfg.path,
1998            std::path::PathBuf::from(".github/workflows/release.yml")
1999        );
2000        assert_eq!(cfg.event.as_deref(), Some("workflow_dispatch"));
2001        assert_eq!(cfg.inputs.get("tag").map(|s| s.as_str()), Some("v1.0.0"));
2002    }
2003
2004    #[test]
2005    fn parses_tag_trigger_from_toml() {
2006        use crate::types::Trigger;
2007
2008        let loader = PipelineLoader::new(".yah/qed");
2009        let toml = r#"
2010[pipeline]
2011name  = "release"
2012label = "Release on tag"
2013
2014[[pipeline.steps]]
2015name = "build"
2016argv = ["cargo", "build", "--release"]
2017
2018[[pipeline.triggers]]
2019kind    = "tag"
2020pattern = "v*.*.*"
2021"#;
2022        let pipeline = loader.load_from_str(toml).expect("should parse");
2023        assert_eq!(pipeline.triggers.len(), 1);
2024        assert!(matches!(
2025            &pipeline.triggers[0],
2026            Trigger::Tag { pattern } if pattern == "v*.*.*"
2027        ));
2028    }
2029
2030    // ---- R494-F2: cross-camp Peer resolution ---------------------------
2031
2032    /// Build a fixture parent-camp + peer-camp pair under a tempdir.
2033    /// Layout:
2034    ///   <tmp>/parent/.yah/qed/peers.toml   (parent's peers registry)
2035    ///   <tmp>/peers/cheers/.yah/qed/publish.toml  (peer pipeline)
2036    /// Returns the parent's qed_dir for `PipelineLoader::new(...)`.
2037    fn fixture_peer_camp(
2038        tmp: &Path,
2039        peer_pipeline_toml: &str,
2040        peers_toml: &str,
2041    ) -> std::path::PathBuf {
2042        let parent_qed = tmp.join("parent/.yah/qed");
2043        fs::create_dir_all(&parent_qed).unwrap();
2044        fs::write(parent_qed.join("peers.toml"), peers_toml).unwrap();
2045
2046        let peer_qed = tmp.join("peers/cheers/.yah/qed");
2047        fs::create_dir_all(&peer_qed).unwrap();
2048        fs::write(peer_qed.join("publish.toml"), peer_pipeline_toml).unwrap();
2049
2050        parent_qed
2051    }
2052
2053    const PEER_PUBLISH_TOML: &str = r#"
2054[pipeline]
2055name  = "publish"
2056label = "Publish cheers"
2057
2058[[pipeline.steps]]
2059name = "build"
2060argv = ["cargo", "build", "--release"]
2061"#;
2062
2063    #[test]
2064    fn peer_resolver_loads_pipeline_from_sibling_camp() {
2065        let tmp = tempfile::tempdir().unwrap();
2066        let parent_qed = fixture_peer_camp(
2067            tmp.path(),
2068            PEER_PUBLISH_TOML,
2069            r#"
2070            [peer.cheers]
2071            path = "../peers/cheers"
2072            "#,
2073        );
2074        let loader = PipelineLoader::new(&parent_qed);
2075        let resolver = LoaderSubPipelineResolver::new(loader);
2076        let resolved = resolver
2077            .resolve(&SubPipelineRef::Peer {
2078                camp: "cheers".into(),
2079                pipeline: "publish".into(),
2080            })
2081            .expect("peer pipeline should resolve");
2082        assert_eq!(resolved.name, "publish");
2083        assert_eq!(resolved.steps.len(), 1);
2084        // No explicit concurrency_key on the peer pipeline → stamped to peer:<camp>
2085        // so two parent runs invoking different pipelines in the same peer camp
2086        // still serialize on that camp's shared `target/`.
2087        assert_eq!(resolved.concurrency_key.as_deref(), Some("peer:cheers"));
2088    }
2089
2090    #[test]
2091    fn peer_resolver_reports_peer_camp_root_for_subprocess_cwd() {
2092        // Regression: peer children must execute in the *peer* camp's
2093        // workspace, not the parent's. Without this, `peer-release` runs
2094        // yubaba's `cargo publish -p workload-spec` from yah's root and the
2095        // package isn't found. resolved_camp_root feeds the child runner's
2096        // camp_root, which is the cwd for subprocess steps.
2097        let tmp = tempfile::tempdir().unwrap();
2098        let parent_qed = fixture_peer_camp(
2099            tmp.path(),
2100            PEER_PUBLISH_TOML,
2101            r#"
2102            [peer.cheers]
2103            path = "../peers/cheers"
2104            "#,
2105        );
2106        let loader = PipelineLoader::new(&parent_qed);
2107        let resolver = LoaderSubPipelineResolver::new(loader);
2108        let root = resolver
2109            .resolved_camp_root(&SubPipelineRef::Peer {
2110                camp: "cheers".into(),
2111                pipeline: "publish".into(),
2112            })
2113            .expect("peer camp root should resolve");
2114        // qed_dir is `<tmp>/parent/.yah/qed`; pop twice → `<tmp>/parent`,
2115        // join the peer's `../peers/cheers`.
2116        assert_eq!(root, tmp.path().join("parent").join("../peers/cheers"));
2117        // Non-peer targets share the parent camp → inherit (None).
2118        assert!(resolver
2119            .resolved_camp_root(&SubPipelineRef::Builtin("check".into()))
2120            .is_none());
2121        // Unknown peer → no local root.
2122        assert!(resolver
2123            .resolved_camp_root(&SubPipelineRef::Peer {
2124                camp: "ghost".into(),
2125                pipeline: "publish".into(),
2126            })
2127            .is_none());
2128    }
2129
2130    #[test]
2131    fn peer_resolver_preserves_explicit_concurrency_key() {
2132        let tmp = tempfile::tempdir().unwrap();
2133        let parent_qed = fixture_peer_camp(
2134            tmp.path(),
2135            r#"
2136[pipeline]
2137name             = "publish"
2138label            = "Publish cheers"
2139concurrency_key  = "@parallel"
2140
2141[[pipeline.steps]]
2142name = "build"
2143argv = ["cargo", "build", "--release"]
2144"#,
2145            r#"
2146            [peer.cheers]
2147            path = "../peers/cheers"
2148            "#,
2149        );
2150        let loader = PipelineLoader::new(&parent_qed);
2151        let resolver = LoaderSubPipelineResolver::new(loader);
2152        let resolved = resolver
2153            .resolve(&SubPipelineRef::Peer {
2154                camp: "cheers".into(),
2155                pipeline: "publish".into(),
2156            })
2157            .expect("peer pipeline should resolve");
2158        // Explicit key wins — peer opts out of the camp-wide serialization.
2159        assert_eq!(resolved.concurrency_key.as_deref(), Some("@parallel"));
2160    }
2161
2162    #[test]
2163    fn peer_resolver_returns_none_for_unknown_camp() {
2164        let tmp = tempfile::tempdir().unwrap();
2165        let parent_qed = fixture_peer_camp(
2166            tmp.path(),
2167            PEER_PUBLISH_TOML,
2168            r#"
2169            [peer.cheers]
2170            path = "../peers/cheers"
2171            "#,
2172        );
2173        let loader = PipelineLoader::new(&parent_qed);
2174        let resolver = LoaderSubPipelineResolver::new(loader);
2175        let resolved = resolver.resolve(&SubPipelineRef::Peer {
2176            camp: "ghost".into(),
2177            pipeline: "publish".into(),
2178        });
2179        assert!(resolved.is_none());
2180    }
2181
2182    #[test]
2183    fn peer_resolver_returns_none_for_unknown_pipeline_in_known_camp() {
2184        let tmp = tempfile::tempdir().unwrap();
2185        let parent_qed = fixture_peer_camp(
2186            tmp.path(),
2187            PEER_PUBLISH_TOML,
2188            r#"
2189            [peer.cheers]
2190            path = "../peers/cheers"
2191            "#,
2192        );
2193        let loader = PipelineLoader::new(&parent_qed);
2194        let resolver = LoaderSubPipelineResolver::new(loader);
2195        let resolved = resolver.resolve(&SubPipelineRef::Peer {
2196            camp: "cheers".into(),
2197            pipeline: "no-such-pipeline".into(),
2198        });
2199        assert!(resolved.is_none());
2200    }
2201
2202    #[test]
2203    fn peer_resolver_remote_peer_surfaces_typed_unsupported_reason() {
2204        // R494-T5: when peers.toml carries a `rig = ...` field, the
2205        // resolver returns None *and* publishes a typed reason naming the
2206        // camp + rig so the runner's StepFailed.msg routes the operator
2207        // to either drop the rig field or wait for the kamaji hop.
2208        let tmp = tempfile::tempdir().unwrap();
2209        let parent_qed = fixture_peer_camp(
2210            tmp.path(),
2211            PEER_PUBLISH_TOML,
2212            r#"
2213            [peer.cheers]
2214            rig  = "rig-tokyo-1"
2215            path = "/srv/camps/cheers"
2216            "#,
2217        );
2218        let loader = PipelineLoader::new(&parent_qed);
2219        let resolver = LoaderSubPipelineResolver::new(loader);
2220        let target = SubPipelineRef::Peer {
2221            camp: "cheers".into(),
2222            pipeline: "publish".into(),
2223        };
2224        assert!(
2225            resolver.resolve(&target).is_none(),
2226            "remote peer should not resolve in v1"
2227        );
2228        let reason = resolver
2229            .unresolved_reason(&target)
2230            .expect("remote-peer miss should publish a typed reason");
2231        assert!(
2232            reason.contains("rig-tokyo-1"),
2233            "reason names the rig: {reason}"
2234        );
2235        assert!(reason.contains("cheers"), "reason names the camp: {reason}");
2236        assert!(
2237            reason.contains("R494-T5"),
2238            "reason cites the ticket: {reason}"
2239        );
2240    }
2241
2242    #[test]
2243    fn peer_resolver_unknown_camp_publishes_actionable_reason() {
2244        // Unknown camp: reason should mention peers.toml so operators
2245        // know where to declare the entry.
2246        let tmp = tempfile::tempdir().unwrap();
2247        let parent_qed = fixture_peer_camp(
2248            tmp.path(),
2249            PEER_PUBLISH_TOML,
2250            r#"
2251            [peer.cheers]
2252            path = "../peers/cheers"
2253            "#,
2254        );
2255        let loader = PipelineLoader::new(&parent_qed);
2256        let resolver = LoaderSubPipelineResolver::new(loader);
2257        let target = SubPipelineRef::Peer {
2258            camp: "ghost".into(),
2259            pipeline: "publish".into(),
2260        };
2261        assert!(resolver.resolve(&target).is_none());
2262        let reason = resolver
2263            .unresolved_reason(&target)
2264            .expect("reason for unknown camp");
2265        assert!(reason.contains("ghost"), "reason names the camp: {reason}");
2266        assert!(
2267            reason.contains("peers.toml"),
2268            "reason routes to peers.toml: {reason}"
2269        );
2270    }
2271
2272    #[test]
2273    fn peer_resolver_unknown_pipeline_in_known_camp_publishes_reason() {
2274        // Known camp, missing pipeline: reason names the pipeline and the
2275        // resolved peer-camp path so the operator can grep that directory.
2276        let tmp = tempfile::tempdir().unwrap();
2277        let parent_qed = fixture_peer_camp(
2278            tmp.path(),
2279            PEER_PUBLISH_TOML,
2280            r#"
2281            [peer.cheers]
2282            path = "../peers/cheers"
2283            "#,
2284        );
2285        let loader = PipelineLoader::new(&parent_qed);
2286        let resolver = LoaderSubPipelineResolver::new(loader);
2287        let target = SubPipelineRef::Peer {
2288            camp: "cheers".into(),
2289            pipeline: "no-such".into(),
2290        };
2291        assert!(resolver.resolve(&target).is_none());
2292        let reason = resolver
2293            .unresolved_reason(&target)
2294            .expect("reason for missing pipeline");
2295        assert!(
2296            reason.contains("no-such"),
2297            "reason names the pipeline: {reason}"
2298        );
2299        assert!(reason.contains("cheers"), "reason names the camp: {reason}");
2300    }
2301
2302    #[test]
2303    fn peer_resolver_unresolved_reason_is_none_for_non_peer_targets() {
2304        // Other SubPipelineRef shapes go through their own resolvers
2305        // (Builtin/Path/GhaWorkflow); LoaderSubPipelineResolver only
2306        // diagnoses peer misses.
2307        let tmp = tempfile::tempdir().unwrap();
2308        let parent_qed = fixture_peer_camp(
2309            tmp.path(),
2310            PEER_PUBLISH_TOML,
2311            r#"
2312            [peer.cheers]
2313            path = "../peers/cheers"
2314            "#,
2315        );
2316        let loader = PipelineLoader::new(&parent_qed);
2317        let resolver = LoaderSubPipelineResolver::new(loader);
2318        assert!(resolver
2319            .unresolved_reason(&SubPipelineRef::Builtin("missing".into()))
2320            .is_none());
2321        assert!(resolver
2322            .unresolved_reason(&SubPipelineRef::Path(".yah/qed/missing.toml".into()))
2323            .is_none());
2324    }
2325}