1use 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
43const HEADER_TAG: &str = "# @qed:generated";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GeneratedHeader {
51 pub source: PathBuf,
53 pub source_hash: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum EjectFreshness {
61 Fresh,
63 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#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ValidateError {
77 NotGenerated,
80 SourceDrifted { pinned: String, actual: String },
82 HandEdited,
85}
86
87pub 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
100pub 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
113pub 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 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
142fn 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
151fn 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
178fn 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
190fn 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 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
215fn 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
224fn 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
232fn 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; } 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 assert!(doc.contains("# @qed:generated source="));
272 assert!(doc.contains("@qed:flag"));
274 assert!(doc.to_lowercase().contains("checkout"));
275 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 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}