Skip to main content

yah_qed/
export.rs

1//! Export-with-degradation: QED → GitHub Actions (R533-F8, W224).
2//!
3//! W224 settles the QED↔GHA boundary as **import, not emulate**, and is
4//! explicit that the *reverse* direction is asymmetric: "QED→GHA can only emit
5//! GHA that shells out to `yah` in `run:` steps (content-addressed /
6//! atomic-release features don't map back). Useful for *keep GitHub as a
7//! fallback CI*; build it second; **never call it lossless**."
8//!
9//! This module is that second direction, and it is honest about the loss:
10//! [`export_pipeline`] returns an [`ExportReport`] whose `degradations` list
11//! enumerates every feature that did not survive the round-trip, and the
12//! emitted YAML carries the same notes as `# degraded:` comments. The caller
13//! (and a future `qed export` CLI) surfaces them; nothing here pretends the
14//! export is faithful.
15//!
16//! ## Two modes, picked automatically
17//!
18//! - **Portable** — every step is a plain [`StepKind::Subprocess`]. These map
19//!   cleanly onto GHA `run:` steps, so the workflow runs the *real* build/test
20//!   commands on a GitHub runner. This is the high-value case (a `check` /
21//!   `smoke` pipeline becomes a usable GitHub workflow).
22//! - **Wholesale shim** — the pipeline has any native-only step kind
23//!   (`build-image`, `package-native-tarball`, `sub-pipeline`, `import`, …).
24//!   GitHub has no native equivalent and the `yah qed` CLI runs *whole*
25//!   pipelines (not single steps), so the only honest emission is a workflow
26//!   that checks out the repo and shells `yah qed run <name>` — GitHub as a
27//!   trigger that delegates to QED. Every native feature is recorded as a
28//!   degradation.
29//!
30//! Outcomes ([`Outcome::Publish`] and friends), pipeline-chain triggers,
31//! `LocalOnly` placement, `OnFail::Retry`, and step `produces` are recorded as
32//! degradations in *either* mode — a portable workflow still builds and tests,
33//! it just doesn't publish (content-addressed release stays native; the note
34//! points the operator at `yah qed run <name>`).
35
36use crate::types::{
37    Outcome, OnFail, Pipeline, Placement, QedStep, StepKind, Trigger,
38};
39
40/// One feature that did not survive the QED → GHA export. The `feature` tag is
41/// a stable short identifier (`outcome:publish`, `step-kind:build-image`, …);
42/// `detail` is the human explanation that also rides the YAML as a comment.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Degradation {
45    /// Where the loss occurred — a step name, or `"triggers"` / `"placement"` /
46    /// `"on_success"` / `"on_fail"`.
47    pub site: String,
48    /// Stable short tag for the lossy feature.
49    pub feature: String,
50    /// Human-readable explanation of what GHA can't do and the native fallback.
51    pub detail: String,
52}
53
54/// The result of exporting a [`Pipeline`] to GitHub Actions.
55#[derive(Debug, Clone)]
56pub struct ExportReport {
57    /// The emitted `.github/workflows/<name>.yml` text. Always valid YAML; a
58    /// best-effort fallback CI, **never a faithful mirror** of the pipeline.
59    pub yaml: String,
60    /// Every feature that did not map. Empty only for a fully-portable pipeline
61    /// with no outcomes, no special triggers, and `Anywhere` placement.
62    pub degradations: Vec<Degradation>,
63    /// `true` when the pipeline degraded to the wholesale `yah qed run <name>`
64    /// shim (it had native-only step kinds), `false` when steps rendered
65    /// faithfully.
66    pub shimmed: bool,
67}
68
69impl ExportReport {
70    /// Whether anything was lost. The export is **never** claimed lossless when
71    /// this is `true`; callers should surface [`Self::degradations`].
72    pub fn is_lossy(&self) -> bool {
73        !self.degradations.is_empty()
74    }
75}
76
77/// Export a QED [`Pipeline`] to a GitHub Actions workflow, degrading every
78/// native-only feature explicitly. See the module docs for the two modes.
79pub fn export_pipeline(pipeline: &Pipeline) -> ExportReport {
80    let mut degradations: Vec<Degradation> = Vec::new();
81
82    // Triggers + placement degrade identically in both modes.
83    let on_block = render_on(&pipeline.triggers, &mut degradations);
84    if pipeline.placement == Placement::LocalOnly {
85        degradations.push(Degradation {
86            site: "placement".into(),
87            feature: "placement:local-only".into(),
88            detail: "pipeline is local-only (its output is meaningless on a clean CI runner); \
89                     exported anyway, but a GitHub run likely produces nothing useful"
90                .into(),
91        });
92    }
93
94    // Outcomes never map — content-addressed publish / atomic release / vendor
95    // adapters are native. Record them regardless of mode; the GHA workflow
96    // builds + tests but does not publish.
97    record_outcome_degradations("on_success", &pipeline.on_success, &mut degradations);
98    record_outcome_degradations("on_fail", &pipeline.on_fail, &mut degradations);
99
100    let native_steps: Vec<&QedStep> = pipeline
101        .steps
102        .iter()
103        .filter(|s| !is_portable_kind(&s.kind))
104        .collect();
105    let shimmed = !native_steps.is_empty();
106
107    let job_id = sanitize_job_id(&pipeline.name);
108    let steps_yaml = if shimmed {
109        // Record every native step as a degradation, then emit the single
110        // delegating shim.
111        for s in &native_steps {
112            degradations.push(Degradation {
113                site: s.name.clone(),
114                feature: format!("step-kind:{}", kind_tag(&s.kind)),
115                detail: format!(
116                    "`{}` is a native QED step ({}) with no GitHub-native equivalent; \
117                     the whole pipeline runs via the `yah qed run` shim instead",
118                    s.name,
119                    kind_tag(&s.kind),
120                ),
121            });
122        }
123        render_shim_steps(&pipeline.name)
124    } else {
125        render_faithful_steps(&pipeline.steps, &mut degradations)
126    };
127
128    let yaml = render_workflow(pipeline, &job_id, &on_block, &steps_yaml, &degradations, shimmed);
129    ExportReport { yaml, degradations, shimmed }
130}
131
132/// Only a plain subprocess maps to a GHA `run:` step. Every other kind is a
133/// native QED facility (image build, native packaging/signing, composition,
134/// import) that GitHub can't reproduce.
135fn is_portable_kind(kind: &StepKind) -> bool {
136    matches!(kind, StepKind::Subprocess)
137}
138
139fn kind_tag(kind: &StepKind) -> &'static str {
140    match kind {
141        StepKind::Subprocess => "subprocess",
142        StepKind::BuildImage => "build-image",
143        StepKind::PackageNativeTarball => "package-native-tarball",
144        StepKind::MuslStaticPreflight => "musl-static-preflight",
145        StepKind::SignNativeTarball => "sign-native-tarball",
146        StepKind::SubPipeline => "sub-pipeline",
147        StepKind::GhaWorkflow => "gha-workflow",
148        StepKind::Import => "import",
149        StepKind::WaitFor => "wait-for",
150        StepKind::ManifestStitch => "manifest-stitch",
151    }
152}
153
154/// Render the `on:` trigger block (already indented two spaces under `on:`).
155/// Pipeline-chain triggers have no faithful GHA mapping — best-effort to
156/// `workflow_run` with a degradation; an empty trigger list defaults to
157/// `workflow_dispatch` (QED's implicit `Manual`).
158fn render_on(triggers: &[Trigger], degradations: &mut Vec<Degradation>) -> String {
159    let mut out = String::new();
160    let mut emitted_dispatch = false;
161    let effective = if triggers.is_empty() {
162        std::slice::from_ref(&Trigger::Manual)
163    } else {
164        triggers
165    };
166    let mut tags: Vec<&str> = Vec::new();
167    let mut crons: Vec<&str> = Vec::new();
168    for t in effective {
169        match t {
170            Trigger::Manual => {
171                if !emitted_dispatch {
172                    out.push_str("  workflow_dispatch:\n");
173                    emitted_dispatch = true;
174                }
175            }
176            Trigger::Tag { pattern } => tags.push(pattern),
177            Trigger::Schedule { cron } => crons.push(cron),
178            Trigger::Pipeline { id, status } => {
179                degradations.push(Degradation {
180                    site: "triggers".into(),
181                    feature: "trigger:pipeline-chain".into(),
182                    detail: format!(
183                        "pipeline-completion trigger (`{id}` = {status:?}) has no GHA equivalent; \
184                         emitted as a best-effort `workflow_run` (fires on ANY completion of \
185                         `{id}` — the {status:?} status filter is not expressible in GHA)"
186                    ),
187                });
188                out.push_str("  workflow_run:\n");
189                out.push_str(&format!("    workflows: [{}]\n", yaml_flow_scalar(id)));
190                out.push_str("    types: [completed]\n");
191            }
192        }
193    }
194    if !tags.is_empty() {
195        out.push_str("  push:\n    tags:\n");
196        for pat in tags {
197            out.push_str(&format!("      - {}\n", yaml_flow_scalar(pat)));
198        }
199    }
200    if !crons.is_empty() {
201        out.push_str("  schedule:\n");
202        for cron in crons {
203            out.push_str(&format!("    - cron: {}\n", yaml_flow_scalar(cron)));
204        }
205    }
206    out
207}
208
209fn record_outcome_degradations(site: &str, outcomes: &[Outcome], degradations: &mut Vec<Degradation>) {
210    for o in outcomes {
211        let (feature, detail) = match o {
212            Outcome::Publish { .. } => (
213                "outcome:publish",
214                "content-addressed release publish is a native QED facility (atomic-release \
215                 model); GHA does not publish — run `yah qed run` to publish",
216            ),
217            Outcome::WardenDeploy { .. } => (
218                "outcome:yubaba-deploy",
219                "yubaba deploy is native; GHA cannot perform it — delegate to `yah qed run`",
220            ),
221            Outcome::AlmanacRun { .. } => (
222                "outcome:almanac-run",
223                "almanac dispatch is native; GHA cannot perform it — delegate to `yah qed run`",
224            ),
225            Outcome::Provider { .. } => (
226                "outcome:provider",
227                "vendor release adapter (notarize/sparkle/…) is native; GHA cannot perform it — \
228                 delegate to `yah qed run`",
229            ),
230        };
231        degradations.push(Degradation {
232            site: site.into(),
233            feature: feature.into(),
234            detail: detail.into(),
235        });
236    }
237}
238
239/// Faithful per-step rendering for a portable pipeline. Each subprocess step
240/// becomes a `run:` step; `produces` / `OnFail::Retry` are recorded as
241/// degradations (the step still runs).
242fn render_faithful_steps(steps: &[QedStep], degradations: &mut Vec<Degradation>) -> String {
243    let mut out = String::new();
244    // GHA runners start empty; QED runs in the camp with the source already
245    // present. A fallback CI needs an explicit checkout.
246    out.push_str("      # QED runs in-camp with the source already present; GHA needs checkout.\n");
247    out.push_str("      - name: Checkout\n");
248    out.push_str("        uses: actions/checkout@v4\n");
249
250    for step in steps {
251        if !step.produces.is_empty() {
252            degradations.push(Degradation {
253                site: step.name.clone(),
254                feature: "produces".into(),
255                detail: format!(
256                    "step `{}` declares release artifacts; GHA builds them but does not publish \
257                     (content-addressed release is native) — run `yah qed run` to publish",
258                    step.name
259                ),
260            });
261        }
262
263        out.push_str(&format!("      - name: {}\n", yaml_flow_scalar(&step.name)));
264        out.push_str(&render_run(&step.argv));
265        if let Some(cwd) = &step.cwd {
266            out.push_str(&format!("        working-directory: {}\n", yaml_flow_scalar(cwd)));
267        }
268        if let Some(timeout) = step.timeout {
269            // QED timeouts are seconds; GHA `timeout-minutes` is minutes — round up.
270            let minutes = timeout.div_ceil(60).max(1);
271            out.push_str(&format!("        timeout-minutes: {minutes}\n"));
272        }
273        match &step.on_fail {
274            OnFail::Continue => out.push_str("        continue-on-error: true\n"),
275            OnFail::Retry { max } => degradations.push(Degradation {
276                site: step.name.clone(),
277                feature: "on-fail:retry".into(),
278                detail: format!(
279                    "step `{}` retries up to {max}× on failure; GHA has no built-in step retry \
280                     (the step runs once)",
281                    step.name
282                ),
283            }),
284            OnFail::Abort => {}
285        }
286        if !step.env.is_empty() {
287            out.push_str("        env:\n");
288            // Deterministic order — HashMap iteration isn't stable.
289            let mut keys: Vec<&String> = step.env.keys().collect();
290            keys.sort();
291            for k in keys {
292                out.push_str(&format!("          {}: {}\n", k, yaml_flow_scalar(&step.env[k])));
293            }
294        }
295    }
296    out
297}
298
299/// The wholesale shim: checkout + `yah qed run <name>`. GitHub becomes a
300/// trigger that delegates the whole native pipeline to the `yah` CLI.
301fn render_shim_steps(pipeline_name: &str) -> String {
302    let mut out = String::new();
303    out.push_str("      - name: Checkout\n");
304    out.push_str("        uses: actions/checkout@v4\n");
305    out.push_str("      # This pipeline has native QED steps with no GHA equivalent; GitHub\n");
306    out.push_str("      # delegates the whole run to the `yah` CLI (requires `yah` on PATH).\n");
307    out.push_str("      - name: Run QED pipeline\n");
308    out.push_str(&render_run(&[
309        "yah".to_string(),
310        "qed".to_string(),
311        "run".to_string(),
312        pipeline_name.to_string(),
313    ]));
314    out
315}
316
317/// Render a `run:` field from an argv list. Single-token-per-line is not GHA's
318/// model; we join the argv into one shell command, quoting tokens that need it.
319fn render_run(argv: &[String]) -> String {
320    if argv.is_empty() {
321        // A subprocess with no argv is degenerate; emit a no-op so the YAML
322        // stays valid (the degradation, if any, is recorded by the caller).
323        return "        run: ':'\n".to_string();
324    }
325    let cmd = argv.iter().map(|a| sh_quote(a)).collect::<Vec<_>>().join(" ");
326    format!("        run: {}\n", yaml_flow_scalar(&cmd))
327}
328
329/// Assemble the full workflow document: degradation header, `name:`, `on:`, and
330/// the single job.
331fn render_workflow(
332    pipeline: &Pipeline,
333    job_id: &str,
334    on_block: &str,
335    steps_yaml: &str,
336    degradations: &[Degradation],
337    shimmed: bool,
338) -> String {
339    let mut out = String::new();
340    out.push_str("# @qed:exported (R533-F8, W224) — QED → GitHub Actions, export-with-degradation.\n");
341    out.push_str("# This is a best-effort FALLBACK CI, NOT a faithful mirror of the QED pipeline.\n");
342    if shimmed {
343        out.push_str("# Mode: wholesale shim (native steps delegate to `yah qed run`).\n");
344    } else {
345        out.push_str("# Mode: faithful (portable subprocess steps mapped to run: steps).\n");
346    }
347    if degradations.is_empty() {
348        out.push_str("# No degradations: this pipeline is fully portable.\n");
349    } else {
350        out.push_str(&format!("# {} degradation(s):\n", degradations.len()));
351        for d in degradations {
352            out.push_str(&format!("#   - [{}] {}: {}\n", d.feature, d.site, d.detail));
353        }
354    }
355    out.push('\n');
356
357    out.push_str(&format!("name: {}\n", yaml_flow_scalar(&pipeline.label)));
358    out.push_str("on:\n");
359    out.push_str(on_block);
360    out.push_str("jobs:\n");
361    out.push_str(&format!("  {job_id}:\n"));
362    out.push_str("    runs-on: ubuntu-latest\n");
363    out.push_str("    steps:\n");
364    out.push_str(steps_yaml);
365    out
366}
367
368/// GHA job ids must match `[A-Za-z_][A-Za-z0-9_-]*`. Slug the pipeline name and
369/// prefix a letter if it would otherwise start with a digit.
370fn sanitize_job_id(name: &str) -> String {
371    let mut s: String = name
372        .chars()
373        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
374        .collect();
375    if s.is_empty() {
376        return "qed".to_string();
377    }
378    let first = s.chars().next().unwrap();
379    if !(first.is_ascii_alphabetic() || first == '_') {
380        s.insert(0, 'j');
381        s.insert(1, '-');
382    }
383    s
384}
385
386/// Shell-quote one argv token for embedding in a `run:` command. Single-quote
387/// (POSIX literal) when it contains anything beyond a safe shell-word charset.
388fn sh_quote(token: &str) -> String {
389    let safe = !token.is_empty()
390        && token
391            .chars()
392            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=' | ':' | '@' | '+' | ','));
393    if safe {
394        token.to_string()
395    } else {
396        // POSIX single-quote: close, escaped literal quote, reopen.
397        format!("'{}'", token.replace('\'', "'\\''"))
398    }
399}
400
401/// Render a YAML scalar in flow position (after `key: ` or inside `[ ]`). Plain
402/// when it's a safe word that can't be misread as a bool/number/null/indicator,
403/// otherwise single-quoted (YAML literal: internal `'` is doubled).
404fn yaml_flow_scalar(s: &str) -> String {
405    if is_plain_safe(s) {
406        s.to_string()
407    } else {
408        format!("'{}'", s.replace('\'', "''"))
409    }
410}
411
412fn is_plain_safe(s: &str) -> bool {
413    if s.is_empty() {
414        return false;
415    }
416    // Reserved scalars that YAML would interpret as non-strings.
417    const RESERVED: &[&str] = &[
418        "true", "false", "yes", "no", "on", "off", "null", "~", "True", "False",
419        "Yes", "No", "On", "Off", "Null", "NULL", "TRUE", "FALSE",
420    ];
421    if RESERVED.contains(&s) {
422        return false;
423    }
424    // Anything number-shaped quotes (so `1.0`, `0755`, `1e3` stay strings).
425    if s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) {
426        return false;
427    }
428    let first = s.chars().next().unwrap();
429    // YAML indicator characters that aren't allowed to start a plain scalar.
430    if matches!(
431        first,
432        '!' | '&' | '*' | '-' | '?' | '{' | '}' | '[' | ']' | ',' | '#' | '|'
433            | '>' | '@' | '`' | '"' | '\'' | '%' | ' '
434    ) {
435        return false;
436    }
437    if s.ends_with(' ') {
438        return false;
439    }
440    // Reject anything containing structural / comment indicators that change
441    // meaning mid-scalar (`: ` mapping sep, ` #` comment, flow punctuation).
442    if s.contains(": ") || s.ends_with(':') || s.contains(" #") || s.contains('\n') {
443        return false;
444    }
445    s.chars().all(|c| {
446        c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=' | ':' | '@' | '+' | ' ')
447    })
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::types::{ProducedArtifact, RunStatus};
454    use std::collections::HashMap;
455
456    fn step(name: &str, argv: &[&str]) -> QedStep {
457        QedStep {
458            background: false,
459            background_until: None,
460            wait_for: None,
461            name: name.into(),
462            argv: argv.iter().map(|s| s.to_string()).collect(),
463            ..base_step()
464        }
465    }
466
467    fn base_step() -> QedStep {
468        // Construct via TOML so we don't have to track every QedStep field.
469        toml::from_str(r#"name = "x""#).expect("minimal QedStep")
470    }
471
472    fn pipeline(name: &str, steps: Vec<QedStep>) -> Pipeline {
473        Pipeline {
474            name: name.into(),
475            label: name.into(),
476            steps,
477            params: HashMap::new(),
478            on_success: Vec::new(),
479            on_fail: Vec::new(),
480            triggers: Vec::new(),
481            concurrency_key: None,
482            placement: Placement::Anywhere,
483            workspace: crate::types::WorkspaceMode::default(),
484            wraps: None,
485            matrix: None,
486            toolchain: None,
487            binds: Vec::new(),
488            on_change: Vec::new(),
489            finally: Vec::new(),
490        }
491    }
492
493    /// A YAML sanity check: the emitted document parses as a mapping with the
494    /// expected top-level keys. (qed-gha bundles serde_yaml; reuse its parser
495    /// transitively is not available here, so we assert structurally on text.)
496    fn assert_well_formed(yaml: &str) {
497        assert!(yaml.contains("\nname: "), "has name:");
498        assert!(yaml.contains("\non:\n"), "has on:");
499        assert!(yaml.contains("\njobs:\n"), "has jobs:");
500        assert!(yaml.contains("runs-on: ubuntu-latest"), "has runs-on");
501        // Header always present and honest about not being lossless.
502        assert!(yaml.contains("NOT a faithful mirror"), "never-lossless header");
503    }
504
505    #[test]
506    fn portable_pipeline_renders_faithfully() {
507        let p = pipeline("check", vec![
508            step("Typecheck", &["cargo", "check", "--workspace"]),
509            step("Test", &["cargo", "test"]),
510        ]);
511        let r = export_pipeline(&p);
512        assert!(!r.shimmed, "all-subprocess pipeline renders faithfully");
513        assert!(r.degradations.is_empty(), "no degradations for a portable pipeline: {:?}", r.degradations);
514        assert!(!r.is_lossy());
515        assert_well_formed(&r.yaml);
516        // Real commands present; checkout prepended; manual → workflow_dispatch.
517        assert!(r.yaml.contains("run: cargo check --workspace"));
518        assert!(r.yaml.contains("uses: actions/checkout@v4"));
519        assert!(r.yaml.contains("workflow_dispatch:"));
520        assert!(r.yaml.contains("\n  check:\n"), "job id from pipeline name");
521    }
522
523    #[test]
524    fn native_kind_degrades_to_wholesale_shim() {
525        let mut img = step("Build image", &[]);
526        img.kind = StepKind::BuildImage;
527        let p = pipeline("release-build", vec![
528            step("Compile", &["cargo", "build", "--release"]),
529            img,
530        ]);
531        let r = export_pipeline(&p);
532        assert!(r.shimmed, "a build-image step forces the wholesale shim");
533        assert!(r.is_lossy());
534        // The shim delegates the whole pipeline; the faithful Compile step is
535        // NOT rendered (the shim re-runs everything via yah).
536        assert!(r.yaml.contains("run: yah qed run release-build"));
537        assert!(!r.yaml.contains("cargo build --release"), "shim mode does not render subprocess steps");
538        assert!(r.degradations.iter().any(|d| d.feature == "step-kind:build-image"));
539    }
540
541    #[test]
542    fn outcomes_degrade_but_steps_still_render() {
543        let mut p = pipeline("release", vec![step("Build", &["cargo", "build"])]);
544        p.on_success = vec![Outcome::Publish {
545            provider: "r2".into(),
546            bucket: "yah-releases".into(),
547            prefix: None,
548            base_url: None,
549        }];
550        let r = export_pipeline(&p);
551        assert!(!r.shimmed, "subprocess-only steps still render faithfully");
552        assert!(r.yaml.contains("run: cargo build"));
553        assert!(r.is_lossy(), "the publish outcome is a degradation");
554        assert!(r.degradations.iter().any(|d| d.feature == "outcome:publish"));
555        // The loss is visible in the YAML, not just the report.
556        assert!(r.yaml.to_lowercase().contains("publish"));
557    }
558
559    #[test]
560    fn triggers_map_and_pipeline_chain_degrades() {
561        let mut p = pipeline("nightly", vec![step("Smoke", &["./smoke.sh"])]);
562        p.triggers = vec![
563            Trigger::Tag { pattern: "v*.*.*".into() },
564            Trigger::Schedule { cron: "0 0 * * *".into() },
565            Trigger::Pipeline { id: "build".into(), status: RunStatus::Success },
566        ];
567        let r = export_pipeline(&p);
568        assert!(r.yaml.contains("push:"));
569        assert!(r.yaml.contains("tags:"));
570        assert!(r.yaml.contains("- 'v*.*.*'"), "glob tag is quoted");
571        assert!(r.yaml.contains("- cron: '0 0 * * *'"));
572        assert!(r.yaml.contains("workflow_run:"));
573        assert!(r.degradations.iter().any(|d| d.feature == "trigger:pipeline-chain"));
574    }
575
576    #[test]
577    fn empty_triggers_default_to_workflow_dispatch() {
578        let p = pipeline("check", vec![step("Lint", &["cargo", "clippy"])]);
579        let r = export_pipeline(&p);
580        assert!(r.yaml.contains("workflow_dispatch:"));
581    }
582
583    #[test]
584    fn local_only_placement_is_a_degradation() {
585        let mut p = pipeline("install", vec![step("Install", &["./install.sh"])]);
586        p.placement = Placement::LocalOnly;
587        let r = export_pipeline(&p);
588        assert!(r.degradations.iter().any(|d| d.feature == "placement:local-only"));
589    }
590
591    #[test]
592    fn retry_and_continue_on_fail_handled() {
593        let mut retry = step("Flaky", &["./flaky.sh"]);
594        retry.on_fail = OnFail::Retry { max: 3 };
595        let mut cont = step("Best effort", &["./maybe.sh"]);
596        cont.on_fail = OnFail::Continue;
597        let p = pipeline("ci", vec![retry, cont]);
598        let r = export_pipeline(&p);
599        assert!(r.yaml.contains("continue-on-error: true"), "Continue maps natively");
600        assert!(r.degradations.iter().any(|d| d.feature == "on-fail:retry"), "Retry degrades");
601    }
602
603    #[test]
604    fn produces_degrades_but_step_renders() {
605        let mut s = step("Build", &["cargo", "build", "--release"]);
606        s.produces = vec![ProducedArtifact {
607            binary: "yah".into(),
608            path: "target/release/yah".into(),
609            triple: None,
610        }];
611        let p = pipeline("build", vec![s]);
612        let r = export_pipeline(&p);
613        assert!(!r.shimmed);
614        assert!(r.yaml.contains("run: cargo build --release"));
615        assert!(r.degradations.iter().any(|d| d.feature == "produces"));
616    }
617
618    #[test]
619    fn env_cwd_timeout_render_on_steps() {
620        let mut s = step("Build", &["make"]);
621        s.cwd = Some("crates/app".into());
622        s.timeout = Some(90); // 90s → 2 minutes (ceil)
623        s.env.insert("RUSTFLAGS".into(), "-D warnings".into());
624        let p = pipeline("build", vec![s]);
625        let r = export_pipeline(&p);
626        assert!(r.yaml.contains("working-directory: crates/app"));
627        assert!(r.yaml.contains("timeout-minutes: 2"));
628        assert!(r.yaml.contains("RUSTFLAGS: '-D warnings'"), "value with space + leading - is quoted");
629    }
630
631    #[test]
632    fn yaml_quoting_protects_reserved_and_special_scalars() {
633        assert_eq!(yaml_flow_scalar("cargo"), "cargo");
634        assert_eq!(yaml_flow_scalar("on"), "'on'");
635        assert_eq!(yaml_flow_scalar("true"), "'true'");
636        assert_eq!(yaml_flow_scalar("1.0"), "'1.0'");
637        assert_eq!(yaml_flow_scalar("a: b"), "'a: b'");
638        assert_eq!(yaml_flow_scalar("v*.*.*"), "'v*.*.*'");
639        assert_eq!(yaml_flow_scalar("it's"), "'it''s'");
640    }
641
642    /// The hardest correctness check: parse the emitted YAML back through the
643    /// real GHA parser (qed-gha) and confirm it's a well-formed workflow — not
644    /// just text that *looks* like YAML. Covers both modes.
645    #[test]
646    fn emitted_yaml_parses_as_a_real_gha_workflow() {
647        // Faithful mode.
648        let p = pipeline("check", vec![
649            step("Build", &["cargo", "build"]),
650            step("Test", &["cargo", "test", "--workspace"]),
651        ]);
652        let r = export_pipeline(&p);
653        let wf = yah_qed_gha::parse_workflow(&r.yaml).expect("faithful export is valid GHA");
654        let job = wf.jobs.get("check").expect("job named after pipeline");
655        assert_eq!(job.steps.len(), 3, "checkout + 2 run steps");
656
657        // Shim mode.
658        let mut img = step("Build image", &[]);
659        img.kind = StepKind::BuildImage;
660        let p2 = pipeline("release-build", vec![step("Compile", &["cargo", "build"]), img]);
661        let r2 = export_pipeline(&p2);
662        let wf2 = yah_qed_gha::parse_workflow(&r2.yaml).expect("shim export is valid GHA");
663        let job2 = wf2.jobs.get("release-build").expect("job");
664        assert_eq!(job2.steps.len(), 2, "checkout + the yah shim");
665    }
666
667    /// Triggers + env quoting survive the real parser too — a regression guard
668    /// on the YAML quoting helper against an actual YAML reader.
669    #[test]
670    fn emitted_triggers_and_env_parse_back() {
671        let mut s = step("Build", &["make"]);
672        s.env.insert("RUSTFLAGS".into(), "-D warnings".into());
673        let mut p = pipeline("nightly", vec![s]);
674        p.triggers = vec![
675            Trigger::Tag { pattern: "v*.*.*".into() },
676            Trigger::Schedule { cron: "0 0 * * *".into() },
677        ];
678        let r = export_pipeline(&p);
679        let wf = yah_qed_gha::parse_workflow(&r.yaml).expect("valid GHA");
680        assert!(wf.triggers.push.as_ref().map(|p| !p.tags.is_empty()).unwrap_or(false));
681        assert_eq!(wf.triggers.schedule.len(), 1);
682    }
683
684    #[test]
685    fn job_id_is_sanitized() {
686        assert_eq!(sanitize_job_id("release-build"), "release-build");
687        assert_eq!(sanitize_job_id("123go"), "j-123go");
688        assert_eq!(sanitize_job_id("a b/c"), "a-b-c");
689    }
690}