verctl 0.3.9

Stack-agnostic version PRs from Changesets-format fragments
Documentation
//! `examples/ver.yaml` is documentation that has to keep parsing. It is a
//! real config, not a commented one, so the schema is what validates it.

use indoc::indoc;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use verctl::config::Config;
use verctl::{assets, ci, pins};

fn example() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/ver.yaml")
}

/// The example declares manifests it does not ship. Give it the tree it
/// describes so both planners can run against the file as written.
#[allow(clippy::expect_used)]
fn planted() -> TempDir {
    let root = TempDir::new().expect("tmp");
    let config = root.path().join(verctl::config::FILE);
    fs::create_dir_all(config.parent().expect("parent")).expect("dir");
    fs::copy(example(), &config).expect("copy");
    fs::write(
        root.path().join("Cargo.toml"),
        indoc! {r#"
            [package]
            name = "verctl"
            version = "4.5.6"
        "#},
    )
    .expect("cargo");
    fs::write(
        root.path().join("package.json"),
        indoc! {r#"
            { "name": "@org/pkg", "version": "4.5.6" }
        "#},
    )
    .expect("npm");
    fs::write(root.path().join("VERSION"), "4.5.6\n").expect("plain");
    let skill = root.path().join("skills/verctl/SKILL.md");
    fs::create_dir_all(skill.parent().expect("parent")).expect("skill dir");
    fs::write(
        &skill,
        indoc! {"
            ---
            name: verctl
            version: 0.0.1
            ---

            A bundled skill. Writing version: 9.9.9 here is prose, not the key.
        "},
    )
    .expect("skill");
    root
}

#[test]
fn the_example_config_parses() {
    let config = Config::load(&example()).expect("the shipped example must parse");
    let names: Vec<&str> = config.packages.iter().map(|p| p.name.as_str()).collect();
    assert_eq!(names, ["verctl", "@org/pkg", "py-tool"]);
    assert_eq!(config.runners.len(), 2, "two machines are declared");
    assert_eq!(config.publishers.len(), 1, "twine is declared");
    assert_eq!(
        config.pins.len(),
        4,
        "one structural pin and three textual ones"
    );
    assert_eq!(config.patterns.len(), 3, "three spellings, named once each");
}

/// What makes the plan below sufficient: a table no package names is never
/// rendered, so it could document anything. `cargo`, `npm`, and `bun` are stock
/// names a manifest implies on its own; any other table has to be claimed by a
/// package explicitly, or it is documentation nothing runs.
#[test]
fn the_example_declares_nothing_a_package_does_not_use() {
    let config = Config::load(&example()).expect("parse");
    let claimed: Vec<&str> = config
        .packages
        .iter()
        .filter_map(|package| package.driver.as_deref())
        .collect();
    for name in config.drivers.keys() {
        assert!(
            matches!(name.as_str(), "cargo" | "npm") || claimed.contains(&name.as_str()),
            "[drivers.{name}] is declared but no package names it, so nothing renders it"
        );
    }
    let claimed: Vec<&str> = config
        .packages
        .iter()
        .filter_map(|package| package.publisher.as_deref())
        .collect();
    for name in config.publishers.keys() {
        assert!(
            matches!(name.as_str(), "cargo" | "bun") || claimed.contains(&name.as_str()),
            "[publishers.{name}] is declared but no package names it"
        );
    }
}

/// Resolving each declared table by hand would skip the stock merge production
/// applies, so a valid `[drivers.npm] after = "…"` — stock supplying the rest —
/// would fail here while working in the field. Plan the publish instead: it is
/// the path that resolves every driver, reads the version through it, and
/// resolves every publisher. With the test above, no declared table escapes.
#[test]
fn the_example_resolves_every_driver_and_publisher_it_declares() {
    let root = planted();
    let config = Config::load(&root.path().join(verctl::config::FILE)).expect("parse");
    let plan = verctl::publish::plan(&config, root.path()).expect("plan the publish");
    let resolved: Vec<(&str, &str, &str, Vec<&str>)> = plan
        .packages
        .iter()
        .map(|entry| {
            (
                entry.name.as_str(),
                entry.version.as_str(),
                entry.noun.as_str(),
                entry.argv.iter().map(String::as_str).collect(),
            )
        })
        .collect();
    assert_eq!(
        resolved,
        [
            (
                "verctl",
                "4.5.6",
                "crate",
                vec![
                    "cargo",
                    "publish",
                    "--locked",
                    "--manifest-path",
                    root.path().join("Cargo.toml").to_str().expect("utf-8"),
                ],
            ),
            // `registry = "github"` is what puts --registry here.
            (
                "@org/pkg",
                "4.5.6",
                "package",
                vec![
                    "bun",
                    "publish",
                    "--tolerate-republish",
                    "--cwd",
                    root.path().to_str().expect("utf-8"),
                    "--registry",
                    "https://npm.pkg.github.com",
                ],
            ),
            // The `plain` driver read this version off stdin, and `twine` is
            // the declared publisher rendering it.
            ("py-tool", "4.5.6", "package", vec!["uv", "publish"]),
        ]
    );
}

/// The comment above `[drivers.plain]` promises both directions: stdin is the
/// file, stdout is the version on read and the whole new file on write, and a
/// write also gets `VERCTL_VERSION`. Run both — an argv that opens its own file
/// or ignores the variable would document a protocol that does not exist.
#[test]
fn the_example_command_driver_reads_and_writes_through_stdio() {
    let config = Config::load(&example()).expect("parse");
    let spec = config.drivers["plain"].clone();
    let driver = spec.into_driver("plain").expect("resolve");
    assert_eq!(
        driver.read("4.5.6\n").expect("read the manifest on stdin"),
        "4.5.6"
    );
    assert_eq!(
        driver
            .write("4.5.6\n", "5.0.0")
            .expect("write the whole new file to stdout"),
        "5.0.0\n"
    );
}

#[test]
fn the_example_ci_table_plans_the_checks_it_documents() {
    let config = Config::load(&example()).expect("parse");
    let plan = ci::plan(&config).expect("plan");
    let checks: Vec<(&str, String)> = plan
        .matrix
        .include
        .iter()
        .map(|job| (job.name.as_str(), job.labels.join(", ")))
        .collect();
    assert_eq!(
        checks,
        [
            ("audit", "nscloud-ubuntu-24.04-amd64-4x8".to_owned()),
            ("verify (ns)", "nscloud-ubuntu-24.04-amd64-4x8".to_owned()),
            ("verify (big)", "self-hosted, linux, x64".to_owned()),
        ],
        "the comments above [ci] promise exactly these checks"
    );
}

#[test]
fn the_example_assets_table_plans_the_targets_it_documents() {
    let root = planted();
    let config = Config::load(&root.path().join(verctl::config::FILE)).expect("parse");
    let plan = assets::plan(&config, root.path()).expect("plan");
    let targets: Vec<(&str, String, &str, &str)> = plan
        .matrix
        .include
        .iter()
        .map(|target| {
            (
                target.id.as_str(),
                target.labels.join(", "),
                target.asset.as_str(),
                // The triple is what `cargo build --target` gets. Omit it here
                // and the example could document a wrong one silently.
                target.triple.as_str(),
            )
        })
        .collect();
    assert_eq!(
        targets,
        [
            (
                "darwin-arm64",
                "macos-latest".to_owned(),
                "verctl_4.5.6_macos_arm64.tar.gz",
                "aarch64-apple-darwin"
            ),
            (
                "linux-arm64",
                "nscloud-ubuntu-24.04-amd64-4x8".to_owned(),
                "verctl_4.5.6_linux_arm64.tar.gz",
                "aarch64-unknown-linux-gnu"
            ),
            (
                "linux-x64",
                "self-hosted, linux, x64".to_owned(),
                "verctl_4.5.6_linux_x64.tar.gz",
                "x86_64-unknown-linux-gnu"
            ),
        ],
        "a built-in machine, a declared one, and a user-defined platform"
    );
}

/// `config.pins.len()` proves a pin was parsed, not that it points anywhere.
/// Run the rewrite: a wrong `file` cannot be read, a wrong `tool` is rejected,
/// and a `package` naming nothing is skipped without touching the file.
#[test]
fn the_example_pin_rewrites_the_file_it_names() {
    let root = planted();
    let config = Config::load(&root.path().join(verctl::config::FILE)).expect("parse");
    fs::create_dir_all(root.path().join("examples")).expect("examples dir");
    fs::write(
        root.path().join("examples/mise.toml"),
        indoc! {r#"
            [tools]
            "github:victor-software-house/verctl" = "0.0.1"

            [task_config]
            includes = [
              "git::https://github.com/victor-software-house/verctl.git//tasks/ver?ref=v0.0.1",
            ]
        "#},
    )
    .expect("pin file");
    fs::write(
        root.path().join("README.md"),
        indoc! {"
            Install: mise x github:victor-software-house/verctl@0.0.1 -- verctl
            Pin verctl@0.0.1 in CI, and verctl@0.0.1 locally.
        "},
    )
    .expect("doc");
    fs::create_dir_all(root.path().join("docs")).expect("docs dir");
    fs::write(
        root.path().join("docs/install.md"),
        indoc! {"
            One line: github:victor-software-house/verctl@0.0.1
        "},
    )
    .expect("second doc");
    let versions = pins::current_versions(root.path(), &config).expect("read versions");
    let written = pins::write(root.path(), &config.pins, &versions).expect("rewrite the pins");
    assert_eq!(
        written,
        [
            root.path().join("examples/mise.toml"),
            root.path().join("README.md"),
            root.path().join("docs/install.md"),
            root.path().join("skills/verctl/SKILL.md"),
        ]
    );
    assert_eq!(
        fs::read_to_string(root.path().join("README.md")).expect("reread"),
        indoc! {"
            Install: mise x github:victor-software-house/verctl@4.5.6 -- verctl
            Pin verctl@4.5.6 in CI, and verctl@4.5.6 locally.
        "},
        "every spelling this file lists moves, at the arity the pattern declares"
    );
    assert_eq!(
        fs::read_to_string(root.path().join("docs/install.md")).expect("reread"),
        indoc! {"
            One line: github:victor-software-house/verctl@4.5.6
        "},
        "the shared pattern moves in the second file that lists it too"
    );
    let body = fs::read_to_string(root.path().join("examples/mise.toml")).expect("reread");
    assert_eq!(
        body,
        indoc! {r#"
            [tools]
            "github:victor-software-house/verctl" = "4.5.6"

            [task_config]
            includes = [
              "git::https://github.com/victor-software-house/verctl.git//tasks/ver?ref=v4.5.6",
            ]
        "#},
        "the tool pin and the ?ref= move together, and nothing else moves"
    );
    assert_eq!(
        fs::read_to_string(root.path().join("skills/verctl/SKILL.md")).expect("reread"),
        indoc! {"
            ---
            name: verctl
            version: 4.5.6
            ---

            A bundled skill. Writing version: 9.9.9 here is prose, not the key.
        "},
        "a whole-line pattern moves its own line and leaves the same words in a sentence alone"
    );
}