Skip to main content

yah_qed/
eject.rs

1//! Eject / materialize an imported workflow to hash-stamped TOML (R533-F6, W224).
2//!
3//! W224's import primitive has one toggle with two states, and that toggle *is*
4//! the migration ramp:
5//!
6//! - **Virtual** (default, R533-F1): expand the `workflow.yml` into the in-memory
7//!   subgraph at plan time, persist nothing. Zero drift by construction — there
8//!   is no stored derivative to diverge.
9//! - **Eject / materialize** (this module): write the F4 transform's native
10//!   steps as a generated, **hash-stamped** TOML pipeline. A one-time directional
11//!   move — after ejecting, the TOML is canonical and hand-editable and the
12//!   source yml can be deleted. The "sync button" is an *eject* button.
13//!
14//! The hard rule W224 sets is **never two editable canonical copies at once**.
15//! While the yml is canonical the TOML is virtual; once ejected the yml is gone.
16//! If a materialized TOML must coexist with its yml during an overlap window, the
17//! pinned source hash is the guardrail:
18//!
19//! - [`freshness`] recomputes the source hash on demand; a mismatch means the
20//!   source drifted since the eject ([`EjectFreshness::StaleSource`]).
21//! - [`validate_ejected`] **re-expands** the source and compares it byte-for-byte
22//!   against the on-disk generated body, so a hand-edit of a generated file is
23//!   caught and never silently honored — and a drifted source is reported
24//!   distinctly from a hand-edit.
25//!
26//! ## Provenance lives in a comment header, not the pipeline body
27//!
28//! The generated body is a **100%-valid normal [`Pipeline`] TOML** — the existing
29//! loader runs an ejected pipeline with no special-casing. Provenance (source
30//! path + pinned hash) and the F4 flags ride in a leading `# @qed:generated …`
31//! comment header that the loader ignores and this module parses. That keeps the
32//! eject reversible-by-inspection and avoids both a 46-site `Pipeline` field add
33//! and TOML's table-after-array ordering trap.
34
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37
38use crate::import::content_hash;
39use crate::transform::{transform_workflow, FlagKind, TransformReport};
40use crate::types::{Pipeline, Placement};
41use yah_qed_gha::Workflow;
42
43/// Marker beginning the provenance comment line. The whole header is a run of
44/// leading `#` comments; only the `@qed:generated` line carries the pin.
45const HEADER_TAG: &str = "# @qed:generated";
46
47/// Parsed provenance of an ejected pipeline — what it was generated from and the
48/// source hash pinned at eject time.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GeneratedHeader {
51    /// The `workflow.yml` this TOML was ejected from (camp-relative).
52    pub source: PathBuf,
53    /// blake3 [`content_hash`] of the source bytes at eject time — the pin the
54    /// freshness / validate guards compare against.
55    pub source_hash: String,
56}
57
58/// Freshness of an on-disk ejected pipeline relative to its source.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum EjectFreshness {
61    /// The source's current hash matches the pin — the eject is up to date.
62    Fresh,
63    /// The source drifted since the eject. Under materialization this marks the
64    /// eject "dirty" (re-eject needed); carries both hashes for reporting.
65    StaleSource { pinned: String, actual: String },
66}
67
68impl EjectFreshness {
69    pub fn is_fresh(&self) -> bool {
70        matches!(self, EjectFreshness::Fresh)
71    }
72}
73
74/// Why an on-disk ejected pipeline failed [`validate_ejected`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ValidateError {
77    /// No `# @qed:generated` header — the file isn't an ejected pipeline (or the
78    /// header was stripped), so there's nothing to re-expand against.
79    NotGenerated,
80    /// The source drifted since the eject (pin mismatch). Re-eject to refresh.
81    SourceDrifted { pinned: String, actual: String },
82    /// The on-disk generated body no longer matches what re-expanding the source
83    /// produces — a hand-edit of a generated file. W224: caught, never honored.
84    HandEdited,
85}
86
87/// Eject an imported workflow to a hash-stamped, generated TOML string.
88///
89/// `source` is the camp-relative path recorded in the header; `source_bytes` are
90/// the exact bytes hashed for the pin (the caller owns the file read — this stays
91/// pure). The body is the F4 [`transform`](crate::transform) of `workflow`
92/// rendered as a [`Pipeline`]; tier-3 / unknown flags are surfaced as header
93/// comments so the human sees what still needs a native replacement.
94pub fn eject(source: &Path, source_bytes: &[u8], workflow: &Workflow) -> String {
95    let report = transform_workflow(workflow);
96    let header = GeneratedHeader { source: source.to_path_buf(), source_hash: content_hash(source_bytes) };
97    render_document(&header, &report)
98}
99
100/// Recompute the source hash and compare against an ejected pipeline's pin.
101/// `current_source_bytes` are the bytes on disk now; returns [`EjectFreshness`].
102/// `None` when `generated_toml` carries no `# @qed:generated` header.
103pub fn freshness(generated_toml: &str, current_source_bytes: &[u8]) -> Option<EjectFreshness> {
104    let header = parse_header(generated_toml)?;
105    let actual = content_hash(current_source_bytes);
106    Some(if actual == header.source_hash {
107        EjectFreshness::Fresh
108    } else {
109        EjectFreshness::StaleSource { pinned: header.source_hash, actual }
110    })
111}
112
113/// The `qed validate` re-expansion guard. Given the on-disk generated TOML, the
114/// current source bytes, and the freshly-parsed source workflow:
115///
116/// 1. require a provenance header ([`ValidateError::NotGenerated`] otherwise);
117/// 2. fail if the source drifted from the pin ([`ValidateError::SourceDrifted`]);
118/// 3. re-eject the source and fail if the generated *body* differs from disk
119///    ([`ValidateError::HandEdited`]) — a hand-edit of a generated file.
120///
121/// On success the on-disk file faithfully reflects its source.
122pub fn validate_ejected(
123    generated_toml: &str,
124    current_source_bytes: &[u8],
125    workflow: &Workflow,
126) -> Result<(), ValidateError> {
127    let header = parse_header(generated_toml).ok_or(ValidateError::NotGenerated)?;
128
129    let actual = content_hash(current_source_bytes);
130    if actual != header.source_hash {
131        return Err(ValidateError::SourceDrifted { pinned: header.source_hash, actual });
132    }
133
134    // Re-expand and compare bodies (header stripped — comments aren't canonical).
135    let expected = eject(&header.source, current_source_bytes, workflow);
136    if strip_header(&expected) != strip_header(generated_toml) {
137        return Err(ValidateError::HandEdited);
138    }
139    Ok(())
140}
141
142/// Render the full ejected document: provenance + flag comment header, then the
143/// native pipeline body.
144fn render_document(header: &GeneratedHeader, report: &TransformReport) -> String {
145    let pipeline = report_to_pipeline(report);
146    let body = toml::to_string_pretty(&pipeline)
147        .unwrap_or_else(|e| panic!("serialize ejected pipeline: {e}"));
148    format!("{}\n{body}", render_header(header, report))
149}
150
151/// The leading comment block: the machine-readable pin line, a provenance note,
152/// and one `# @qed:flag …` line per F4 flag (so tier-3 replacements travel with
153/// the generated file).
154fn render_header(header: &GeneratedHeader, report: &TransformReport) -> String {
155    let mut out = String::new();
156    out.push_str(&format!(
157        "{HEADER_TAG} source=\"{}\" hash=\"{}\"\n",
158        header.source.display(),
159        header.source_hash
160    ));
161    out.push_str("# Generated by `qed eject` (R533-F6, W224). Do not hand-edit: re-eject the\n");
162    out.push_str("# source, or delete the source and own this file. `qed validate` re-expands\n");
163    out.push_str("# and fails if this body drifts from its source.\n");
164    for step in &report.steps {
165        for flag in &step.flags {
166            out.push_str(&format!(
167                "# @qed:flag job={} step={} severity={} -- {}\n",
168                step.job,
169                step.step_index,
170                flag.severity().label(),
171                flag_summary(flag),
172            ));
173        }
174    }
175    out
176}
177
178/// One-line summary of a flag for the header: what it is + the native stanza.
179fn flag_summary(flag: &FlagKind) -> String {
180    let what = match flag {
181        FlagKind::ReplaceWithNative(nr) => format!("tier-3 {}", nr.label()),
182        FlagKind::EmbeddedServiceTouch(_) => "embedded service touch".to_string(),
183        FlagKind::ToolkitAction { slug, .. } => format!("toolkit action {slug}"),
184        FlagKind::Unknown { slug } => format!("unknown action {slug}"),
185        FlagKind::UnresolvedExpression => "unresolved expression".to_string(),
186    };
187    format!("{what}: {}", flag.stanza_hint())
188}
189
190/// Build a native [`Pipeline`] from a transform report — the ejected body.
191fn report_to_pipeline(report: &TransformReport) -> Pipeline {
192    Pipeline {
193        name: report.name.clone(),
194        label: report.label.clone(),
195        steps: report.collect_native(),
196        params: HashMap::new(),
197        on_success: Vec::new(),
198        on_fail: Vec::new(),
199        triggers: Vec::new(),
200        concurrency_key: None,
201        placement: Placement::default(),
202        workspace: crate::types::WorkspaceMode::default(),
203        // Record that this pipeline exists *because* it composes a workflow, so
204        // the daemon suppresses the source's auto-ingest (no double catalog
205        // entry). Advisory only.
206        wraps: Some(format!("gha:{}", report.name)),
207        matrix: None,
208        toolchain: None,
209        binds: Vec::new(),
210        on_change: Vec::new(),
211        finally: Vec::new(),
212    }
213}
214
215/// Parse the `# @qed:generated source="…" hash="…"` provenance line out of a
216/// document's leading comment header. `None` when absent.
217fn parse_header(toml_text: &str) -> Option<GeneratedHeader> {
218    let line = toml_text.lines().find(|l| l.trim_start().starts_with(HEADER_TAG))?;
219    let source = scan_quoted_field(line, "source=")?;
220    let source_hash = scan_quoted_field(line, "hash=")?;
221    Some(GeneratedHeader { source: PathBuf::from(source), source_hash })
222}
223
224/// Extract a `key="value"` field's value from a header line.
225fn scan_quoted_field(line: &str, key: &str) -> Option<String> {
226    let after = &line[line.find(key)? + key.len()..];
227    let rest = after.strip_prefix('"')?;
228    let end = rest.find('"')?;
229    Some(rest[..end].to_string())
230}
231
232/// Drop the leading run of comment / blank lines — the non-canonical header —
233/// leaving the pipeline body for byte-comparison.
234fn strip_header(text: &str) -> &str {
235    let mut idx = 0;
236    for line in text.lines() {
237        let t = line.trim_start();
238        if t.starts_with('#') || t.is_empty() {
239            idx += line.len() + 1; // +1 for the '\n'
240        } else {
241            break;
242        }
243    }
244    text[idx.min(text.len())..].trim_start_matches('\n')
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    const WF: &str = r#"
252name: Release Flow
253on: push
254jobs:
255  build:
256    runs-on: ubuntu-latest
257    steps:
258      - uses: actions/checkout@v4
259      - name: Build
260        run: cargo build --release
261"#;
262
263    fn wf(src: &str) -> Workflow {
264        yah_qed_gha::parse_workflow(src).expect("parse")
265    }
266
267    #[test]
268    fn ejected_body_is_loadable_pipeline_toml() {
269        let doc = eject(Path::new(".github/workflows/release.yml"), WF.as_bytes(), &wf(WF));
270        // Header present and machine-readable.
271        assert!(doc.contains("# @qed:generated source="));
272        // The tier-3 checkout flag travels in the header.
273        assert!(doc.contains("@qed:flag"));
274        assert!(doc.to_lowercase().contains("checkout"));
275        // The body (header stripped) parses as a normal Pipeline.
276        let body = strip_header(&doc);
277        let pipeline: Pipeline = toml::from_str(body).expect("ejected body is valid Pipeline TOML");
278        assert_eq!(pipeline.name, "release-flow");
279        assert_eq!(pipeline.steps.len(), 1, "only the run step is native; checkout is flagged");
280        assert_eq!(pipeline.steps[0].name, "build: Build");
281    }
282
283    #[test]
284    fn header_round_trips_through_parse() {
285        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
286        let h = parse_header(&doc).expect("header parses");
287        assert_eq!(h.source, PathBuf::from("wf.yml"));
288        assert_eq!(h.source_hash, content_hash(WF.as_bytes()));
289        assert_eq!(h.source_hash.len(), 64);
290    }
291
292    #[test]
293    fn freshness_is_fresh_for_unchanged_source() {
294        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
295        assert_eq!(freshness(&doc, WF.as_bytes()), Some(EjectFreshness::Fresh));
296    }
297
298    #[test]
299    fn freshness_is_stale_when_source_drifts() {
300        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
301        let drifted = format!("{WF}\n# a comment that changes the bytes\n");
302        match freshness(&doc, drifted.as_bytes()) {
303            Some(EjectFreshness::StaleSource { pinned, actual }) => {
304                assert_eq!(pinned, content_hash(WF.as_bytes()));
305                assert_eq!(actual, content_hash(drifted.as_bytes()));
306                assert_ne!(pinned, actual);
307            }
308            other => panic!("expected StaleSource, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn freshness_none_without_header() {
314        assert_eq!(freshness("name = \"x\"\nlabel = \"x\"\n", WF.as_bytes()), None);
315    }
316
317    #[test]
318    fn validate_passes_for_a_fresh_unedited_eject() {
319        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
320        assert_eq!(validate_ejected(&doc, WF.as_bytes(), &wf(WF)), Ok(()));
321    }
322
323    #[test]
324    fn validate_flags_a_hand_edited_body() {
325        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
326        // Tamper with the generated body (not the header).
327        let tampered = doc.replace("cargo build --release", "cargo build --release --tampered");
328        assert_ne!(tampered, doc);
329        assert_eq!(
330            validate_ejected(&tampered, WF.as_bytes(), &wf(WF)),
331            Err(ValidateError::HandEdited),
332        );
333    }
334
335    #[test]
336    fn validate_reports_source_drift_distinctly_from_hand_edit() {
337        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
338        let drifted = format!("{WF}\n# drift\n");
339        match validate_ejected(&doc, drifted.as_bytes(), &wf(&drifted)) {
340            Err(ValidateError::SourceDrifted { pinned, actual }) => {
341                assert_eq!(pinned, content_hash(WF.as_bytes()));
342                assert_eq!(actual, content_hash(drifted.as_bytes()));
343            }
344            other => panic!("expected SourceDrifted, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn validate_rejects_a_non_generated_file() {
350        assert_eq!(
351            validate_ejected("name = \"hand\"\nlabel = \"hand\"\n", WF.as_bytes(), &wf(WF)),
352            Err(ValidateError::NotGenerated),
353        );
354    }
355
356    #[test]
357    fn eject_is_deterministic() {
358        let a = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
359        let b = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
360        assert_eq!(a, b, "same source → byte-identical eject (validate relies on this)");
361    }
362}