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}