Skip to main content

yah_qed/
import.rs

1//! The W224 import primitive's pure core (R533-F1).
2//!
3//! W224 settles "what is a GitHub Actions workflow to QED?" as **import, not
4//! emulate**: a `workflow.yml` is an *import source* QED expands into its own
5//! native subgraph, not a foreign runtime QED faithfully reproduces forever.
6//! This module holds the side-effect-free heart of that primitive:
7//!
8//! 1. [`content_hash`] — the blake3 pin of a source yml's raw bytes. The pin
9//!    lives in [`ImportConfig::hash`](crate::types::ImportConfig::hash); on
10//!    every run the runner recomputes the source's hash and compares.
11//! 2. [`ImportFreshness`] + [`ImportConfig::freshness`] — the staleness
12//!    decision the pin enables. There are never two editable canonical copies
13//!    at once (W224): while the yml is canonical the expansion is *virtual*
14//!    (recomputed at plan time, never stored — zero drift by construction), so
15//!    a drifted source is benign (re-expand + re-pin). Once a generated TOML is
16//!    materialized (`eject`, R533-F6) the pin instead marks that on-disk
17//!    derivative stale.
18//! 3. [`expand_import`] — the plan-time expansion seam: parsed workflow →
19//!    native QED subgraph.
20//!
21//! ## F1 scope of the expansion
22//!
23//! The mechanical tier-1/2 → native step mapping is **R533-F4** (the assisted
24//! transformer). Until it lands, [`expand_import`] produces the single-node
25//! [`ImportExpansion::Delegated`] form: route the whole workflow through the
26//! recast W200 GHA front-end (the `qed-gha` parser + tier-1/2 executor, which
27//! W224 keeps and re-points). This is the migration ramp — while GHA is
28//! canonical the import step still *runs* — and it keeps the runner seam,
29//! freshness check, and re-pin loop settled here so F4 swaps only the
30//! expansion body, not the surrounding machinery.
31
32use crate::types::{GhaWorkflowConfig, ImportConfig};
33
34/// blake3 content hash of a source workflow's raw bytes, hex-encoded. The pin
35/// stored in [`ImportConfig::hash`](crate::types::ImportConfig::hash) is
36/// exactly this string.
37///
38/// Hashing the raw bytes (not the parsed AST) is deliberate: it catches every
39/// edit — including comment / whitespace churn that a re-serialized AST would
40/// erase — so "is this byte-for-byte the yml I pinned?" is answered without
41/// re-parsing, and a hand-edit can never be silently honored.
42pub fn content_hash(bytes: &[u8]) -> String {
43    blake3::hash(bytes).to_hex().to_string()
44}
45
46/// Freshness of an imported source relative to its pinned hash.
47///
48/// The disposition of [`Stale`](ImportFreshness::Stale) depends on the import's
49/// `materialize` toggle, not on this enum: virtual expansion re-expands and
50/// re-pins (benign); a materialized eject treats it as a stale derivative
51/// (R533-F6). This type only reports the comparison.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ImportFreshness {
54    /// No hash pinned yet — first import, or a hand-authored `[import]` block.
55    /// The caller should expand and adopt the freshly-computed hash as the pin.
56    Unpinned,
57    /// The source's current hash matches the pin. Safe to expand.
58    Fresh,
59    /// The source drifted since it was pinned. Carries both hashes so a
60    /// caller (or `qed validate`, R533-F6) can report the divergence.
61    Stale { pinned: String, actual: String },
62}
63
64impl ImportFreshness {
65    /// Whether the on-disk source still matches its pin (or was never pinned).
66    /// `false` only for [`Stale`](ImportFreshness::Stale).
67    pub fn is_current(&self) -> bool {
68        !matches!(self, ImportFreshness::Stale { .. })
69    }
70}
71
72impl ImportConfig {
73    /// Compare a freshly-computed source hash against the pinned one.
74    ///
75    /// `actual` is the [`content_hash`] of the bytes currently on disk; the
76    /// caller computes it (the runner has just read the file, so it owns the
77    /// bytes). Pure — no I/O here.
78    pub fn freshness(&self, actual: &str) -> ImportFreshness {
79        match self.hash.as_deref() {
80            None => ImportFreshness::Unpinned,
81            Some(pinned) if pinned == actual => ImportFreshness::Fresh,
82            Some(pinned) => ImportFreshness::Stale {
83                pinned: pinned.to_string(),
84                actual: actual.to_string(),
85            },
86        }
87    }
88}
89
90/// The result of expanding an imported workflow at plan time.
91///
92/// Modeled as an enum from the start so the runner seam stays stable across the
93/// F1 → F4 transition: F1 only ever yields [`Delegated`](ImportExpansion::Delegated);
94/// R533-F4 adds a native-steps variant carrying the mechanical tier-1/2 map,
95/// and the runner's `match` grows one arm rather than changing the call.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ImportExpansion {
98    /// Single-node delegation: run the whole workflow through the recast W200
99    /// GHA front-end. The F1 default and the migration ramp while GHA is
100    /// canonical. R533-F4 introduces the native-steps form alongside this.
101    Delegated(GhaWorkflowConfig),
102}
103
104/// Expand an imported workflow into a QED subgraph at plan time (W224 "import,
105/// don't emulate").
106///
107/// F1 SCOPE: returns [`ImportExpansion::Delegated`] — the single-node form that
108/// routes through the W200 GHA front-end. The `event` / `inputs` carried on the
109/// [`ImportConfig`] are forwarded into the synthesized [`GhaWorkflowConfig`] so
110/// the expansion impersonates the same trigger the source declares. R533-F4
111/// replaces this body with the mechanical tier-1/2 native map (and, for tier-3
112/// steps, native-replacement stanzas); the pin + virtual/eject toggle around it
113/// are already owned by the caller, so nothing else moves.
114pub fn expand_import(cfg: &ImportConfig) -> ImportExpansion {
115    ImportExpansion::Delegated(GhaWorkflowConfig {
116        path: cfg.source.clone(),
117        event: cfg.event.clone(),
118        inputs: cfg.inputs.clone(),
119    })
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::path::PathBuf;
126
127    fn cfg(hash: Option<&str>) -> ImportConfig {
128        ImportConfig {
129            source: PathBuf::from(".github/workflows/release.yml"),
130            hash: hash.map(str::to_string),
131            materialize: false,
132            event: None,
133            inputs: Default::default(),
134        }
135    }
136
137    #[test]
138    fn content_hash_is_stable_and_byte_sensitive() {
139        let a = content_hash(b"name: release\n");
140        let b = content_hash(b"name: release\n");
141        let c = content_hash(b"name: release \n"); // one extra space
142        assert_eq!(a, b, "same bytes hash identically");
143        assert_ne!(a, c, "a one-byte edit changes the pin");
144        // blake3 hex is 64 chars.
145        assert_eq!(a.len(), 64);
146    }
147
148    #[test]
149    fn freshness_unpinned_when_no_hash() {
150        assert_eq!(cfg(None).freshness("deadbeef"), ImportFreshness::Unpinned);
151    }
152
153    #[test]
154    fn freshness_fresh_on_match() {
155        let h = content_hash(b"on: push\n");
156        assert_eq!(cfg(Some(&h)).freshness(&h), ImportFreshness::Fresh);
157    }
158
159    #[test]
160    fn freshness_stale_on_drift_carries_both_hashes() {
161        let pinned = content_hash(b"on: push\n");
162        let actual = content_hash(b"on: workflow_dispatch\n");
163        let f = cfg(Some(&pinned)).freshness(&actual);
164        assert_eq!(
165            f,
166            ImportFreshness::Stale {
167                pinned: pinned.clone(),
168                actual: actual.clone(),
169            }
170        );
171        assert!(!f.is_current(), "stale is not current");
172        assert!(ImportFreshness::Fresh.is_current());
173        assert!(ImportFreshness::Unpinned.is_current());
174    }
175
176    #[test]
177    fn expand_forwards_source_event_and_inputs() {
178        let mut c = cfg(None);
179        c.event = Some("workflow_dispatch".into());
180        c.inputs.insert("tag".into(), "v1.2.3".into());
181        let ImportExpansion::Delegated(gha) = expand_import(&c);
182        assert_eq!(gha.path, c.source);
183        assert_eq!(gha.event.as_deref(), Some("workflow_dispatch"));
184        assert_eq!(gha.inputs.get("tag").map(String::as_str), Some("v1.2.3"));
185    }
186}