Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/dwarves-runa/tests/pipeline.rs

//! Red-when-broken integration tests for the `runa` authoring layer.
//!
//! These assert the load-time contract: the design doc's `korp-ci` RON parses
//! into the exact typed structure, RON round-trips, and both malformed-DAG
//! shapes (cycle, unknown-`needs`) are rejected *at load*.

use dwarves_runa::{
    DeployTarget, LintTool, NotifyChannel, NotifyOn, Pipeline, Profile, ProvisionBackend,
    SbomPolicy, ScanKind, Step, Trigger, ValidationError,
};

/// The `korp-ci` example from `.nornir/rust-cicd-design.md`, verbatim
/// (comments and all — RON tolerates them).
const KORP_CI: &str = r#"Pipeline(
  name: "korp-ci",
  triggers: [ Push(branch: "main"), Cron("0 3 * * *") ],
  stages: [
    Stage(name: "build", steps: [
      CargoBuild(features: ["iceberg", "containers"], profile: Release),
    ]),
    Stage(name: "test", needs: ["build"], steps: [
      Nextest(all_features: true),
      Lint(tool: Clippy, deny_warnings: true),
      Xtask(cmd: "verify-demo"),          // ← escape hatch to compiled Rust
    ]),
    Stage(name: "package", needs: ["test"], steps: [
      Build(variant: "holger-embedded"),  // ← build-thing variant by name
      Build(variant: "holger-full"),
      Sbom(policy: Block),                // ← modgunn gate
    ]),
  ],
)"#;

#[test]
fn parses_korp_ci_example_into_exact_structure() {
    let p = Pipeline::from_ron_str(KORP_CI).expect("korp-ci must load");

    assert_eq!(p.name, "korp-ci");

    // Triggers — both variants, in order.
    assert_eq!(
        p.triggers,
        vec![
            Trigger::Push {
                branch: "main".to_string()
            },
            Trigger::Cron("0 3 * * *".to_string()),
        ]
    );

    // Three stages, in order, with the right needs wiring.
    let names: Vec<&str> = p.stages.iter().map(|s| s.name.as_str()).collect();
    assert_eq!(names, vec!["build", "test", "package"]);

    let build = &p.stages[0];
    assert!(build.needs.is_empty(), "root stage has no needs");
    assert_eq!(
        build.steps,
        vec![Step::CargoBuild {
            features: vec!["iceberg".to_string(), "containers".to_string()],
            profile: Profile::Release,
            target: None,
        }]
    );

    let test = &p.stages[1];
    assert_eq!(test.needs, vec!["build".to_string()]);
    assert_eq!(
        test.steps,
        vec![
            Step::Nextest { all_features: true, only: dwarves_runa::TestScope::All },
            Step::Lint {
                tool: LintTool::Clippy,
                deny_warnings: true
            },
            // The pre-0.2.3 spelling, still valid: `args`/`repo` default, so an
            // existing file means exactly what it meant.
            Step::Xtask {
                cmd: "verify-demo".to_string(),
                args: Vec::new(),
                repo: String::new(),
                alias: String::new(),
            },
        ]
    );

    let package = &p.stages[2];
    assert_eq!(package.needs, vec!["test".to_string()]);
    assert_eq!(
        package.steps,
        vec![
            Step::Build {
                variant: "holger-embedded".to_string(),
                opts: Vec::new(),
                params: Vec::new()
            },
            Step::Build {
                variant: "holger-full".to_string(),
                opts: Vec::new(),
                params: Vec::new()
            },
            Step::Sbom {
                policy: SbomPolicy::Block
            },
        ]
    );
}

#[test]
fn ron_round_trips() {
    let parsed = Pipeline::from_ron_str(KORP_CI).expect("first load");
    let serialised = parsed.to_ron_string().expect("serialise");
    let reparsed = Pipeline::from_ron_str(&serialised)
        .expect("re-load of serialised pipeline must succeed");
    assert_eq!(parsed, reparsed, "parse -> serialise -> parse must be identity");
}

#[test]
fn cyclic_pipeline_is_rejected_at_load() {
    // a -> b -> c -> a  (via needs), a valid-looking but unschedulable graph.
    let cyclic = r#"Pipeline(
        name: "cyclic",
        stages: [
            Stage(name: "a", needs: ["c"], steps: [ CargoTest ]),
            Stage(name: "b", needs: ["a"], steps: [ CargoTest ]),
            Stage(name: "c", needs: ["b"], steps: [ CargoTest ]),
        ],
    )"#;

    let err = Pipeline::from_ron_str(cyclic).expect_err("a cyclic pipeline must not load");
    match err {
        dwarves_runa::RunaError::Validation(ValidationError::Cycle { nodes }) => {
            assert!(!nodes.is_empty(), "cycle error must name the implicated stage(s)");
        }
        other => panic!("expected a Cycle validation error, got: {other:?}"),
    }
}

#[test]
fn unknown_needs_is_rejected_at_load() {
    let dangling = r#"Pipeline(
        name: "dangling",
        stages: [
            Stage(name: "build", steps: [ CargoTest ]),
            Stage(name: "test", needs: ["nope"], steps: [ CargoTest ]),
        ],
    )"#;

    let err = Pipeline::from_ron_str(dangling).expect_err("unknown needs must not load");
    match err {
        dwarves_runa::RunaError::Validation(ValidationError::UnknownNeed { stage, needs }) => {
            assert_eq!(stage, "test");
            assert_eq!(needs, "nope");
        }
        other => panic!("expected an UnknownNeed validation error, got: {other:?}"),
    }
}

#[test]
fn triggers_default_to_empty_when_omitted() {
    // A pipeline with no triggers is invoke-only, not a parse error.
    let no_triggers = r#"Pipeline(
        name: "manual-only",
        stages: [ Stage(name: "only", steps: [ CargoTest ]) ],
    )"#;
    let p = Pipeline::from_ron_str(no_triggers).expect("must load");
    assert!(p.triggers.is_empty());
}

/// A full human-mode CI/CD that exercises **every** new Tier-B/C step kind, in
/// the recommended default palette order. Each new `Step` must parse, validate,
/// and (below) round-trip byte-stable.
const FULL_PALETTE: &str = r##"Pipeline(
  name: "full-palette",
  triggers: [ Push(branch: "main") ],
  stages: [
    Stage(name: "build", steps: [
      CargoBuild(profile: Release),
    ]),
    Stage(name: "verify", needs: ["build"], steps: [
      Lint(tool: Fmt),
      Lint(tool: Clippy, deny_warnings: true),
      SecurityScan(kind: Sast),
      SecurityScan(kind: Deps),
      SecurityScan(kind: Secret),
      SecurityScan(kind: Container),
      ProvisionTestEnv(backend: Draupnir(spec: "test-stack.draupnir.ron"), teardown: true),
      ProvisionTestEnv(backend: Terraform(module: "envs/pr.tf"), teardown: false),
    ]),
    Stage(name: "image", needs: ["verify"], steps: [
      ContainerBuild(file: "Containerfile"),
      PushImage(registry: "ghcr.io/nordisk", tags: ["latest", "1.2.3"]),
    ]),
    Stage(name: "approval", needs: ["image"], steps: [
      ApprovalGate(approvers: ["alice"], timeout: Some("24h")),
    ]),
    Stage(name: "deploy", needs: ["approval"], steps: [
      Deploy(target: Registry(name: "ghcr.io/nordisk")),
      Deploy(target: Env(name: "production")),
      Deploy(target: K8s(manifest: "k8s/deploy.yaml")),
      Notify(channel: Slack(target: "#releases"), on: Always),
      Notify(channel: Email(to: ["ops@example.com"]), on: Failure),
      Notify(channel: Webhook(url: "https://hooks.example/ci")),
    ]),
  ],
)"##;

#[test]
fn full_palette_parses_and_validates_every_new_step() {
    let p = Pipeline::from_ron_str(FULL_PALETTE).expect("full-palette must load & validate");

    // Spot-check that each new Step variant landed as the exact typed value.
    let verify = &p.stages[1];
    assert_eq!(
        verify.steps[0],
        Step::Lint {
            tool: LintTool::Fmt,
            deny_warnings: false
        }
    );
    assert_eq!(
        verify.steps[2],
        Step::SecurityScan {
            kind: ScanKind::Sast
        }
    );
    assert_eq!(
        verify.steps[6],
        Step::ProvisionTestEnv {
            backend: ProvisionBackend::Draupnir {
                spec: "test-stack.draupnir.ron".to_string()
            },
            teardown: true,
        }
    );

    let image = &p.stages[2];
    assert_eq!(
        image.steps[1],
        Step::PushImage {
            registry: "ghcr.io/nordisk".to_string(),
            tags: vec!["latest".to_string(), "1.2.3".to_string()],
        }
    );

    let approval = &p.stages[3];
    assert_eq!(
        approval.steps[0],
        Step::ApprovalGate {
            approvers: vec!["alice".to_string()],
            timeout: Some("24h".to_string()),
        }
    );

    let deploy = &p.stages[4];
    assert_eq!(
        deploy.steps[1],
        Step::Deploy {
            target: DeployTarget::Env {
                name: "production".to_string()
            }
        }
    );
    assert_eq!(
        deploy.steps[3],
        Step::Notify {
            channel: NotifyChannel::Slack {
                target: "#releases".to_string()
            },
            on: NotifyOn::Always,
        }
    );
    // Defaulted `on` must fall back to Failure when omitted.
    assert_eq!(
        deploy.steps[5],
        Step::Notify {
            channel: NotifyChannel::Webhook {
                url: "https://hooks.example/ci".to_string()
            },
            on: NotifyOn::Failure,
        }
    );
}

#[test]
fn full_palette_ron_round_trips() {
    // parse -> serialise -> parse must be an identity for every new step.
    let parsed = Pipeline::from_ron_str(FULL_PALETTE).expect("first load");
    let serialised = parsed.to_ron_string().expect("serialise");
    let reparsed = Pipeline::from_ron_str(&serialised).expect("re-load of serialised pipeline");
    assert_eq!(parsed, reparsed, "new-step round-trip must be an identity");
}

#[test]
fn build_test_deploy_with_approval_gate_is_a_valid_dag() {
    // The canonical human-mode shape: build -> test -> approval-gate -> deploy.
    // It must validate as a well-formed (acyclic, fully-wired) DAG.
    let src = r##"Pipeline(
        name: "release-flow",
        triggers: [ Push(branch: "main") ],
        stages: [
            Stage(name: "build", steps: [ CargoBuild(profile: Release) ]),
            Stage(name: "test", needs: ["build"], steps: [ Nextest(all_features: true) ]),
            Stage(name: "gate", needs: ["test"], steps: [
                ApprovalGate(approvers: ["alice"], timeout: Some("12h")),
            ]),
            Stage(name: "deploy", needs: ["gate"], steps: [
                Deploy(target: Env(name: "production")),
                Notify(channel: Slack(target: "#ops"), on: Always),
            ]),
        ],
    )"##;

    let p = Pipeline::from_ron_str(src).expect("build->test->gate->deploy must be a valid DAG");
    let names: Vec<&str> = p.stages.iter().map(|s| s.name.as_str()).collect();
    assert_eq!(names, vec!["build", "test", "gate", "deploy"]);
    assert_eq!(p.stages[3].needs, vec!["gate".to_string()]);
}

/// **The bare `Build(variant: "x")` form STILL PARSES** — the compatibility half
/// of the 2026-08-27 option/param axis on [`Step::Build`].
///
/// Every shipped pipeline spells it bare: `dwarves.runa.ron:54`
/// (`Build(variant: "*")`), `holger.runa.ron:32-34`, `pipelines/release.runa.ron:49`
/// and edda's `fleet.runa.ron:44-48` — nine occurrences, not one of which names an
/// `opts` or a `params` field. Adding two required fields to the variant would have
/// broken all nine at LOAD, which is the one thing a schema extension must not do.
///
/// It is asserted rather than assumed because the two fields are `Vec<String>`, and
/// serde's `default` on a struct-variant field is exactly the kind of attribute that
/// is easy to write and easy to leave off one of the two.
#[test]
fn bare_build_step_still_parses_without_opts_or_params() {
    let src = r##"Pipeline(
        name: "bare-build",
        stages: [
            Stage(name: "package", steps: [
                Build(variant: "gunnar-appliance-iso"),
            ]),
        ],
    )"##;
    let p = Pipeline::from_ron_str(src).expect("the bare Build form must still load");
    assert_eq!(
        p.stages[0].steps,
        vec![Step::Build {
            variant: "gunnar-appliance-iso".to_string(),
            opts: Vec::new(),
            params: Vec::new(),
        }],
        "a bare Build must default BOTH axes to empty"
    );
}

/// **THE RED PROBE for the test above.** A field that is not `#[serde(default)]`
/// makes the bare form fail to load — so the guard is proven able to report red by
/// showing that a pipeline naming an UNKNOWN field is refused, i.e. that this parse
/// path is strict and the green above is not simply serde ignoring everything.
///
/// Without this, `bare_build_step_still_parses…` could pass against a deserializer
/// that silently accepts any input, which is the identity-value trap one door over.
#[test]
fn a_misspelled_build_field_is_refused_at_load() {
    let src = r##"Pipeline(
        name: "typo-build",
        stages: [
            Stage(name: "package", steps: [
                Build(variant: "x", optz: ["wireguard=off"]),
            ]),
        ],
    )"##;
    let e = Pipeline::from_ron_str(src)
        .expect_err("a misspelled field must be refused, or the parse path proves nothing");
    let msg = format!("{e:#}");
    assert!(
        msg.contains("optz") || msg.to_lowercase().contains("unknown"),
        "the refusal must name the field it did not understand; got: {msg}"
    );
}

/// **The CUSTOMER CHOICE reaches the typed step** — a `*.runa.ron` can now express
/// `wireguard=off` and `admin_key=…` on the CI door, which is what
/// `Step::Build`'s two new fields exist for.
#[test]
fn build_step_carries_opts_and_params_from_ron() {
    let src = r##"Pipeline(
        name: "per-site-iso",
        stages: [
            Stage(name: "package", steps: [
                Build(
                    variant: "gunnar-appliance-iso",
                    opts: ["wireguard=off"],
                    params: ["admin_key=ssh-ed25519 AAAAC3Nz"],
                ),
            ]),
        ],
    )"##;
    let p = Pipeline::from_ron_str(src).expect("the option/param form must load");
    let Step::Build { variant, opts, params } = &p.stages[0].steps[0] else {
        panic!("expected a Build step, got {:?}", p.stages[0].steps[0]);
    };
    assert_eq!(variant, "gunnar-appliance-iso");
    assert_eq!(opts, &vec!["wireguard=off".to_string()]);
    assert_eq!(params, &vec!["admin_key=ssh-ed25519 AAAAC3Nz".to_string()]);
    // And it survives the serialize road too: a generated pipeline that carries a
    // choice must be able to write itself back out.
    let round = Pipeline::from_ron_str(&p.to_ron_string().expect("serialise"))
        .expect("re-load of a serialised choice-carrying pipeline");
    assert_eq!(round, p, "the two axes must survive parse -> serialise -> parse");
}

// ── `Xtask` grows an argv and a checkout (0.2.3) ────────────────────────────
//
// The step that makes a runa something a developer writes BESIDE their `xtask`.
// Before this, `Xtask` carried a bare `cmd`, so `cargo xtask build --variant
// korp-ui` — the constellation's most-typed verb — was unspellable, and every
// step ran in the forge's own checkout. The two tests below are the pair that
// matters: the new fields must be READ, and the old files must not break.

#[test]
fn an_xtask_step_carries_its_argv_and_its_checkout() {
    let p = dwarves_runa::Pipeline::from_ron_str(
        r#"Pipeline(
            name: "beside-xtask",
            stages: [
                Stage(name: "build", steps: [
                    Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp"),
                ]),
            ],
        )"#,
    )
    .expect("parses");
    assert_eq!(
        p.stages[0].steps,
        vec![Step::Xtask {
            cmd: "build".to_string(),
            // Each element is ONE argv slot. A single "--variant korp-ui" string
            // would be a shell split waiting to happen, and this catalog has no
            // shell by construction.
            args: vec!["--variant".to_string(), "korp-ui".to_string()],
            repo: "korp".to_string(),
            alias: String::new(),
        }]
    );
}

#[test]
fn an_xtask_step_written_before_0_2_3_still_means_what_it_meant() {
    // `deny_unknown_fields` is on `Step`, so a defaulted field is the ONLY way to
    // add one without breaking every committed pipeline. This is that assertion:
    // the old spelling parses, and it parses to the forge's own checkout with no
    // arguments — not to some new default nobody wrote down.
    let p = dwarves_runa::Pipeline::from_ron_str(
        r#"Pipeline(name: "old", stages: [Stage(name: "s", steps: [Xtask(cmd: "fmt-check")])])"#,
    )
    .expect("the pre-0.2.3 spelling still parses");
    assert_eq!(
        p.stages[0].steps,
        vec![Step::Xtask {
            cmd: "fmt-check".to_string(),
            args: Vec::new(),
            repo: String::new(),
            alias: String::new(),
        }]
    );
}

#[test]
fn a_misspelled_field_on_an_xtask_step_is_refused_not_dropped() {
    // The reason `deny_unknown_fields` is on this enum at all: a dropped field is
    // a step that ran with different arguments than the file says. `argz` must be
    // an error, never an empty `args`.
    let e = dwarves_runa::Pipeline::from_ron_str(
        r#"Pipeline(name: "typo", stages: [Stage(name: "s", steps: [Xtask(cmd: "b", argz: ["x"])])])"#,
    )
    .expect_err("a misspelled field must not parse");
    let msg = format!("{e:#}");
    assert!(msg.contains("argz") || msg.to_lowercase().contains("unknown"), "{msg}");
}

#[test]
fn an_xtask_step_can_name_the_alias_its_verb_moved_behind() {
    // THE LAW gives xtask ONE dependency; facett's non-build verbs moved to
    // `cargo ops`. The step names that alias, and the default stays `xtask`.
    let p = dwarves_runa::Pipeline::from_ron_str(
        r#"Pipeline(name: "ops", stages: [Stage(name: "s", steps: [Xtask(cmd: "publish-holger", alias: "ops")])])"#,
    )
    .expect("an alias parses");
    assert_eq!(
        p.stages[0].steps,
        vec![Step::Xtask {
            cmd: "publish-holger".to_string(),
            args: Vec::new(),
            repo: String::new(),
            alias: "ops".to_string(),
        }]
    );
    assert_eq!(dwarves_runa::xtask_alias("ops"), "ops");
    assert_eq!(dwarves_runa::xtask_alias(""), "xtask");
}

#[test]
fn an_xtask_alias_that_is_not_a_name_is_refused_at_load() {
    for bad in ["../x", "/bin/sh", "ops publish", "OPS", "a;b"] {
        let src = format!(
            r#"Pipeline(name: "bad", stages: [Stage(name: "s", steps: [Xtask(cmd: "c", alias: "{bad}")])])"#
        );
        let e = dwarves_runa::Pipeline::from_ron_str(&src).err().unwrap_or_else(|| panic!("`{bad}` was accepted"));
        assert!(e.to_string().contains("not a cargo alias name"), "{bad}: {e}");
    }
}

/// Builder 1 (owner 2026-10-15: "prepare its builder to be callable from
/// RUNA"): `BuildWorkspace` beside `Build(variant)`. `only` defaults to `All`
/// and `repos` to empty, so the bare step reads the whole workspace; it
/// round-trips through RON.
#[test]
fn a_build_workspace_step_reads_a_workspace_and_round_trips() {
    use dwarves_runa::WorkspaceScope;
    let src = r#"Pipeline(name: "read", stages: [
        Stage(name: "all", steps: [BuildWorkspace(workspace: "nordisk")]),
        Stage(name: "some", needs: ["all"], steps: [BuildWorkspace(workspace: "nordisk", only: Affected, repos: ["skade", "nornir"])]),
    ])"#;
    let p = Pipeline::from_ron_str(src).expect("loads");
    assert_eq!(p.stages[0].steps, vec![Step::BuildWorkspace { workspace: "nordisk".into(), only: WorkspaceScope::All, repos: vec![] }]);
    assert_eq!(p.stages[1].steps, vec![Step::BuildWorkspace { workspace: "nordisk".into(), only: WorkspaceScope::Affected, repos: vec!["skade".into(), "nornir".into()] }]);
    let again = Pipeline::from_ron_str(&p.to_ron_string().expect("serialise")).expect("reloads");
    assert_eq!(p, again, "parse -> serialise -> parse is the identity");
}

/// A workspace or a member is a NAME: a path is refused at load, by name.
#[test]
fn a_build_workspace_step_with_a_path_is_refused_at_load() {
    for (ws, repos) in [("../x", "[]"), ("/srv/ws", "[]"), ("nordisk", r#"["a/b"]"#), ("nordisk", r#"[".."]"#), ("", "[]")] {
        let src = format!(r#"Pipeline(name: "bad", stages: [Stage(name: "s", steps: [BuildWorkspace(workspace: "{ws}", repos: {repos})])])"#);
        let e = Pipeline::from_ron_str(&src).err().unwrap_or_else(|| panic!("`{ws}` {repos} was accepted"));
        assert!(matches!(e, dwarves_runa::RunaError::Validation(ValidationError::BadName { .. })), "{ws} {repos}: {e}");
    }
}

/// The measuring steps: `Bench` and `Coverage` load with every field defaulted,
/// carry their fields, round-trip, and a path where a NAME belongs is refused at load.
#[test]
fn the_bench_and_coverage_steps_load_round_trip_and_refuse_a_path() {
    let src = r#"Pipeline(
        name: "measure",
        stages: [
            Stage(name: "test", steps: [ Nextest(), Coverage(), Coverage(all_features: true) ]),
            Stage(name: "bench", needs: ["test"], steps: [ Bench(), Bench(package: "xtask"), Bench(dir: "bench", features: ["s3"]) ]),
        ],
    )"#;
    let p = Pipeline::from_ron_str(src).expect("loads");
    assert_eq!(p.stages[0].steps[1], Step::Coverage { all_features: false, only: dwarves_runa::TestScope::All });
    assert_eq!(p.stages[0].steps[2], Step::Coverage { all_features: true, only: dwarves_runa::TestScope::All });
    assert_eq!(p.stages[1].steps[0], Step::Bench { package: String::new(), dir: String::new(), features: Vec::new() });
    assert_eq!(p.stages[1].steps[1], Step::Bench { package: "xtask".into(), dir: String::new(), features: Vec::new() });
    assert_eq!(p.stages[1].steps[2], Step::Bench { package: String::new(), dir: "bench".into(), features: vec!["s3".into()] });
    let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
    assert_eq!(back, p);

    for bad in [r#"Bench(dir: "../x")"#, r#"Bench(dir: "/etc")"#, r#"Bench(package: "a b")"#] {
        let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {bad} ]) ])"#);
        assert!(Pipeline::from_ron_str(&src).is_err(), "{bad} must be refused at load");
    }
    let typo = r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ Coverage(all_feature: true) ]) ])"#;
    assert!(Pipeline::from_ron_str(typo).is_err(), "a misspelled field is refused, not dropped");
}

/// `only: Affected` on the three test-running steps: Mímir's graph picks the tests
/// a change reaches. `All` is the default, and a bare `CargoTest` (every shipped
/// pipeline's spelling) still loads — as `All`.
#[test]
fn test_steps_take_only_affected_and_a_bare_cargo_test_still_loads() {
    use dwarves_runa::TestScope;
    let src = r#"Pipeline(
        name: "inc",
        stages: [
            Stage(name: "t", steps: [
                Nextest(only: Affected), CargoTest(only: Affected), Coverage(only: Affected),
                CargoTest, CargoTest(), CargoTest , Nextest(), Coverage(all_features: true),
            ]),
        ],
    )"#;
    let p = Pipeline::from_ron_str(src).expect("loads");
    let s = &p.stages[0].steps;
    assert_eq!(s[0], Step::Nextest { all_features: false, only: TestScope::Affected });
    assert_eq!(s[1], Step::CargoTest { only: TestScope::Affected });
    assert_eq!(s[2], Step::Coverage { all_features: false, only: TestScope::Affected });
    assert_eq!(s[3], Step::CargoTest { only: TestScope::All }, "bare CargoTest = All");
    assert_eq!(s[4], Step::CargoTest { only: TestScope::All });
    assert_eq!(s[5], Step::CargoTest { only: TestScope::All });
    assert_eq!(s[6], Step::Nextest { all_features: false, only: TestScope::All });
    assert_eq!(s[7], Step::Coverage { all_features: true, only: TestScope::All });
    let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
    assert_eq!(back, p);

    // a NAME "CargoTest" in a string stays a string (the bare-variant rewrite
    // touches identifiers only, never literals or comments)
    let named = r#"Pipeline(name: "CargoTest", stages: [ Stage(name: "CargoTest", steps: [ CargoTest /* CargoTest */ ]) ]) // CargoTest"#;
    let p = Pipeline::from_ron_str(named).expect("loads");
    assert_eq!(p.name, "CargoTest");
    assert_eq!(p.stages[0].name, "CargoTest");
    assert_eq!(p.stages[0].steps, vec![Step::CargoTest { only: TestScope::All }]);

    for typo in ["Nextest(onyl: Affected)", "CargoTest(only: Afected)", "Coverage(only: Some)"] {
        let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {typo} ]) ])"#);
        assert!(Pipeline::from_ron_str(&src).is_err(), "{typo} must be refused at load");
    }
}

/// The run-watching steps: `Trace` (XRay), `Profile` (perf) and `LiveTrace`
/// (uprobes) load with their defaults, round-trip, and refuse a path where a NAME
/// belongs, a nameless Bin / Bench, and a probe that is not a function path.
#[test]
fn the_trace_profile_and_live_trace_steps_load_round_trip_and_refuse_bad_names() {
    use dwarves_runa::RunTarget;
    let src = r#"Pipeline(
        name: "watch",
        stages: [
            Stage(name: "trace", steps: [
                Trace(),
                Trace(target: Test, name: "code_room", args: ["map_"], max_events: 500000),
                Trace(target: Bin, name: "dwarves", args: ["--help"], package: "dwarves"),
                Profile(target: Bench, name: "zip"),
                LiveTrace(target: Bin, name: "dwarves", functions: ["dwarves::serve::*", "dwarves::main"], seconds: 30),
            ]),
        ],
    )"#;
    let p = Pipeline::from_ron_str(src).expect("loads");
    let s = &p.stages[0].steps;
    assert_eq!(s[0], Step::Trace { target: RunTarget::Test, name: String::new(), args: vec![], package: String::new(), max_events: 0 });
    assert_eq!(s[1], Step::Trace { target: RunTarget::Test, name: "code_room".into(), args: vec!["map_".into()], package: String::new(), max_events: 500_000 });
    assert_eq!(s[3], Step::Profile { target: RunTarget::Bench, name: "zip".into(), args: vec![], package: String::new() });
    assert!(matches!(&s[4], Step::LiveTrace { functions, seconds: 30, .. } if functions.len() == 2));
    let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
    assert_eq!(back, p);

    for bad in [
        r#"Trace(name: "../x")"#,
        r#"Trace(package: "a b")"#,
        r#"Trace(target: Bin)"#,
        r#"Profile(target: Bench)"#,
        r#"LiveTrace(target: Bin, name: "d", functions: [])"#,
        r#"LiveTrace(target: Bin, name: "d", functions: ["a\"); system(\"x"])"#,
        r#"LiveTrace(target: Bin, name: "d", functions: ["a*b"])"#,
        r#"LiveTrace(target: Bin, name: "d", functions: ["a"], seconds: 9999)"#,
    ] {
        let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {bad} ]) ])"#);
        assert!(Pipeline::from_ron_str(&src).is_err(), "{bad} must be refused at load");
    }
    let typo = r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ Trace(max_event: 1) ]) ])"#;
    assert!(Pipeline::from_ron_str(typo).is_err(), "a misspelled field is refused, not dropped");
}