use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::import::content_hash;
use crate::transform::{transform_workflow, FlagKind, TransformReport};
use crate::types::{Pipeline, Placement};
use yah_qed_gha::Workflow;
const HEADER_TAG: &str = "# @qed:generated";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedHeader {
pub source: PathBuf,
pub source_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EjectFreshness {
Fresh,
StaleSource { pinned: String, actual: String },
}
impl EjectFreshness {
pub fn is_fresh(&self) -> bool {
matches!(self, EjectFreshness::Fresh)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidateError {
NotGenerated,
SourceDrifted { pinned: String, actual: String },
HandEdited,
}
pub fn eject(source: &Path, source_bytes: &[u8], workflow: &Workflow) -> String {
let report = transform_workflow(workflow);
let header = GeneratedHeader { source: source.to_path_buf(), source_hash: content_hash(source_bytes) };
render_document(&header, &report)
}
pub fn generated_header(generated_toml: &str) -> Option<GeneratedHeader> {
parse_header(generated_toml)
}
pub fn freshness(generated_toml: &str, current_source_bytes: &[u8]) -> Option<EjectFreshness> {
let header = parse_header(generated_toml)?;
let actual = content_hash(current_source_bytes);
Some(if actual == header.source_hash {
EjectFreshness::Fresh
} else {
EjectFreshness::StaleSource { pinned: header.source_hash, actual }
})
}
pub fn validate_ejected(
generated_toml: &str,
current_source_bytes: &[u8],
workflow: &Workflow,
) -> Result<(), ValidateError> {
let header = parse_header(generated_toml).ok_or(ValidateError::NotGenerated)?;
let actual = content_hash(current_source_bytes);
if actual != header.source_hash {
return Err(ValidateError::SourceDrifted { pinned: header.source_hash, actual });
}
let expected = eject(&header.source, current_source_bytes, workflow);
if strip_header(&expected) != strip_header(generated_toml) {
return Err(ValidateError::HandEdited);
}
Ok(())
}
fn render_document(header: &GeneratedHeader, report: &TransformReport) -> String {
let pipeline = report_to_pipeline(header, report);
let body = toml::to_string_pretty(&pipeline)
.unwrap_or_else(|e| panic!("serialize ejected pipeline: {e}"));
format!("{}\n{body}", render_header(header, report))
}
fn render_header(header: &GeneratedHeader, report: &TransformReport) -> String {
let mut out = String::new();
out.push_str(&format!(
"{HEADER_TAG} source=\"{}\" hash=\"{}\"\n",
header.source.display(),
header.source_hash
));
out.push_str("# Generated by `qed eject` (R533-F6, W224). Do not hand-edit: re-eject the\n");
out.push_str("# source, or delete the source and own this file. `qed validate` re-expands\n");
out.push_str("# and fails if this body drifts from its source.\n");
for step in &report.steps {
for flag in &step.flags {
out.push_str(&format!(
"# @qed:flag job={} step={} severity={} -- {}\n",
step.job,
step.step_index,
flag.severity().label(),
flag_summary(flag),
));
}
}
out
}
fn flag_summary(flag: &FlagKind) -> String {
let what = match flag {
FlagKind::ReplaceWithNative(nr) => format!("tier-3 {}", nr.label()),
FlagKind::EmbeddedServiceTouch(_) => "embedded service touch".to_string(),
FlagKind::ToolkitAction { slug, .. } => format!("toolkit action {slug}"),
FlagKind::Unknown { slug } => format!("unknown action {slug}"),
FlagKind::UnresolvedExpression => "unresolved expression".to_string(),
FlagKind::UnbridgedSecret { names } => format!("unbridged secret {}", names.join(", ")),
};
format!("{what}: {}", flag.stanza_hint())
}
fn report_to_pipeline(header: &GeneratedHeader, report: &TransformReport) -> Pipeline {
Pipeline {
description: None,
name: report.name.clone(),
label: report.label.clone(),
tags: Vec::new(),
steps: report.collect_native(),
params: HashMap::new(),
on_success: Vec::new(),
on_fail: Vec::new(),
triggers: Vec::new(),
concurrency_key: None,
placement: Placement::default(),
workspace: crate::types::WorkspaceMode::default(),
wraps: Some(format!("gha:{}", header.source.display())),
matrix: None,
toolchain: None,
binds: Vec::new(),
on_change: Vec::new(),
finally: Vec::new(),
}
}
fn parse_header(toml_text: &str) -> Option<GeneratedHeader> {
let line = toml_text.lines().find(|l| l.trim_start().starts_with(HEADER_TAG))?;
let source = scan_quoted_field(line, "source=")?;
let source_hash = scan_quoted_field(line, "hash=")?;
Some(GeneratedHeader { source: PathBuf::from(source), source_hash })
}
fn scan_quoted_field(line: &str, key: &str) -> Option<String> {
let after = &line[line.find(key)? + key.len()..];
let rest = after.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn strip_header(text: &str) -> &str {
let mut idx = 0;
for line in text.lines() {
let t = line.trim_start();
if t.starts_with('#') || t.is_empty() {
idx += line.len() + 1; } else {
break;
}
}
text[idx.min(text.len())..].trim_start_matches('\n')
}
#[cfg(test)]
mod tests {
use super::*;
const WF: &str = r#"
name: Release Flow
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --release
"#;
fn wf(src: &str) -> Workflow {
yah_qed_gha::parse_workflow(src).expect("parse")
}
#[test]
fn ejected_body_is_loadable_pipeline_toml() {
let doc = eject(Path::new(".github/workflows/release.yml"), WF.as_bytes(), &wf(WF));
assert!(doc.contains("# @qed:generated source="));
assert!(doc.contains("@qed:flag"));
assert!(doc.to_lowercase().contains("checkout"));
let body = strip_header(&doc);
let pipeline: Pipeline = toml::from_str(body).expect("ejected body is valid Pipeline TOML");
assert_eq!(pipeline.name, "release-flow");
assert_eq!(pipeline.steps.len(), 1, "only the run step is native; checkout is flagged");
assert_eq!(pipeline.steps[0].name, "build: Build");
}
#[test]
fn wraps_records_the_source_path_so_the_run_tab_suppression_matches() {
let src = Path::new(".github/workflows/release.yml");
let doc = eject(src, WF.as_bytes(), &wf(WF));
let pipeline: Pipeline = toml::from_str(strip_header(&doc)).expect("valid Pipeline TOML");
assert_eq!(
pipeline.wraps.as_deref(),
Some("gha:.github/workflows/release.yml")
);
assert_ne!(pipeline.wraps.as_deref(), Some("gha:release-flow"));
}
#[test]
fn header_round_trips_through_parse() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
let h = parse_header(&doc).expect("header parses");
assert_eq!(h.source, PathBuf::from("wf.yml"));
assert_eq!(h.source_hash, content_hash(WF.as_bytes()));
assert_eq!(h.source_hash.len(), 64);
}
#[test]
fn freshness_is_fresh_for_unchanged_source() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
assert_eq!(freshness(&doc, WF.as_bytes()), Some(EjectFreshness::Fresh));
}
#[test]
fn freshness_is_stale_when_source_drifts() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
let drifted = format!("{WF}\n# a comment that changes the bytes\n");
match freshness(&doc, drifted.as_bytes()) {
Some(EjectFreshness::StaleSource { pinned, actual }) => {
assert_eq!(pinned, content_hash(WF.as_bytes()));
assert_eq!(actual, content_hash(drifted.as_bytes()));
assert_ne!(pinned, actual);
}
other => panic!("expected StaleSource, got {other:?}"),
}
}
#[test]
fn freshness_none_without_header() {
assert_eq!(freshness("name = \"x\"\nlabel = \"x\"\n", WF.as_bytes()), None);
}
#[test]
fn validate_passes_for_a_fresh_unedited_eject() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
assert_eq!(validate_ejected(&doc, WF.as_bytes(), &wf(WF)), Ok(()));
}
#[test]
fn validate_flags_a_hand_edited_body() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
let tampered = doc.replace("cargo build --release", "cargo build --release --tampered");
assert_ne!(tampered, doc);
assert_eq!(
validate_ejected(&tampered, WF.as_bytes(), &wf(WF)),
Err(ValidateError::HandEdited),
);
}
#[test]
fn validate_reports_source_drift_distinctly_from_hand_edit() {
let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
let drifted = format!("{WF}\n# drift\n");
match validate_ejected(&doc, drifted.as_bytes(), &wf(&drifted)) {
Err(ValidateError::SourceDrifted { pinned, actual }) => {
assert_eq!(pinned, content_hash(WF.as_bytes()));
assert_eq!(actual, content_hash(drifted.as_bytes()));
}
other => panic!("expected SourceDrifted, got {other:?}"),
}
}
#[test]
fn validate_rejects_a_non_generated_file() {
assert_eq!(
validate_ejected("name = \"hand\"\nlabel = \"hand\"\n", WF.as_bytes(), &wf(WF)),
Err(ValidateError::NotGenerated),
);
}
#[test]
fn eject_is_deterministic() {
let a = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
let b = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
assert_eq!(a, b, "same source → byte-identical eject (validate relies on this)");
}
}