cratefield-cli 0.7.5

The fz CLI for Cratefield ventures: migrations collect/apply, doctor, modules, push
Documentation
//! Generates `docs/COMPATIBILITY.md` (issue #17): the contract-version
//! rule, the 1.0 rule, and the per-crate table of supported
//! `cratefield-core` ranges, read from `cargo metadata`. Run without
//! arguments to write the file, or with `--check` to verify the
//! checked-in copy has no drift (CI fails the run on drift).
//!
//! ```text
//! cargo run -p cratefield-cli --example compatibility-doc          # write
//! cargo run -p cratefield-cli --example compatibility-doc -- --check
//! ```
//!
//! Host-only tooling: like the CLI's `doctor`, this shells out to cargo;
//! it never runs inside a Worker.

use cratefield_core::HARNESS_API;
use serde_json::Value;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

/// One row: a workspace crate that depends on `cratefield-core`.
struct Consumer {
    name: String,
    version: String,
    /// The `cratefield-core` requirement as declared (e.g. `0.1`).
    core_req: String,
    /// False for `publish = false` crates (examples, test-only).
    published: bool,
}

fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn consumers(root: &Path) -> Vec<Consumer> {
    let output =
        std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
            .args(["metadata", "--no-deps", "--format-version", "1"])
            .current_dir(root)
            .output()
            .expect("cargo metadata runs");
    assert!(
        output.status.success(),
        "cargo metadata failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let metadata: Value =
        serde_json::from_slice(&output.stdout).expect("cargo metadata emits JSON");

    let mut out = Vec::new();
    let empty: Vec<Value> = Vec::new();
    for package in metadata["packages"].as_array().unwrap_or(&empty) {
        let name = package["name"].as_str().unwrap_or_default().to_string();
        if name == "cratefield-core" {
            continue;
        }
        let Some(req) = package["dependencies"]
            .as_array()
            .unwrap_or(&empty)
            .iter()
            .filter(|dep| dep["name"].as_str() == Some("cratefield-core"))
            // `kind` is null for a normal dependency; dev/build
            // requirements are irrelevant to consumers of the crate.
            .find(|dep| dep["kind"].is_null())
            .and_then(|dep| dep["req"].as_str())
        else {
            continue;
        };
        out.push(Consumer {
            version: package["version"].as_str().unwrap_or_default().to_string(),
            // `cargo metadata` renders `publish = false` as an empty array
            // and an unrestricted crate as `null` — never as the boolean
            // `false`, which is what this compared against until 2026-09-13.
            // The marker below had therefore never rendered for any of the
            // 20+ unpublished crates in the table. A non-empty array is a
            // registry allow-list, which still counts as published.
            published: package["publish"]
                .as_array()
                .is_none_or(|registries| !registries.is_empty()),
            core_req: req.to_string(),
            name,
        });
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

/// Human form of a requirement: `0.1` covers `>=0.1.0, <0.2.0`; `1.2`
/// covers `>=1.2.0, <2.0.0` (caret semantics, the only form the
/// workspace uses).
fn range(req: &str) -> String {
    let nums: Vec<u32> = req
        .trim_start_matches('^')
        .split('.')
        .map(|part| part.parse().unwrap_or(0))
        .collect();
    let (major, minor) = (
        nums.first().copied().unwrap_or(0),
        nums.get(1).copied().unwrap_or(0),
    );
    if major == 0 {
        format!(">=0.{minor}.0, <0.{}.0", minor + 1)
    } else {
        format!(">={major}.{minor}.0, <{}.0.0", major + 1)
    }
}

fn markdown(rows: &[Consumer]) -> String {
    let mut out = String::new();
    out.push_str("# Compatibility\n\n");
    out.push_str(
        "Generated from the workspace manifests by `cargo run -p cratefield-cli --example\n\
         compatibility-doc` and checked in CI for drift. Do not edit by hand.\n\n",
    );

    out.push_str("## The contract version\n\n");
    let _ = write!(
        out,
        "- `HARNESS_API` is {HARNESS_API}. Every module and adapter is compiled\n\
         \x20 against a `cratefield-core` whose `HARNESS_API` matches; `Harness::build`\n\
         \x20 and `fz doctor` refuse a mismatch, naming the module, its version and\n\
         \x20 the core crate.\n\
         - **The 1.0 rule:** `HARNESS_API` is bumped only for breaking changes to\n\
         \x20 the module contract (the `Module` trait, `ModuleContext`, ports).\n\
         \x20 `cratefield-core`'s major version follows `HARNESS_API`: a core 2.x is\n\
         \x20 the first that accepts API 2, a core 1.x never does. Anything else —\n\
         \x20 new optional trait methods, new ports, new error slugs — ships in a\n\
         \x20 minor bump with the API unchanged.\n\
         - **Dependency ranges:** while pre-1.0, modules and adapters depend on\n\
         \x20 `cratefield-core` with a caret on the current minor (`\"0.1\"` accepts\n\
         \x20 0.1.x only), so a new core minor can never silently mix with older\n\
         \x20 modules. From 1.0 the range is `\"^1\"`-style: compatible within the\n\
         \x20 major. Ventures pin exact versions; the supported range per release\n\
         \x20 is the table below.\n\
         - **Problem `type` URIs (cratefield-core 0.7.0):** every venture now\n\
         \x20 names its problem types under its own base —\n\
         \x20 `<public_url>/problems/<slug>`, `about:blank` when it has no public\n\
         \x20 URL — instead of one fixed base. Clients must match on the slug,\n\
         \x20 the part after `/problems/` (`auth/…` namespaces included), never\n\
         \x20 on the full URI: the URI names whichever venture served the\n\
         \x20 answer. docs/ERRORS.md is the slug list. Every other\n\
         \x20 source-breaking change since 0.5.0, per minor, is in\n\
         \x20 docs/UPGRADING.md.\n\n"
    );

    out.push_str("## Supported core ranges\n\n");
    out.push_str("| Crate | Version | HARNESS_API | `cratefield-core` range |\n");
    out.push_str("|---|---|---|---|\n");
    for row in rows {
        // The marker sits outside the backticks: inside them the asterisks
        // are literal text in a code span, not italics.
        let mark = if row.published {
            ""
        } else {
            " *(not published)*"
        };
        let _ = writeln!(
            out,
            "| `{}`{mark} | {} | {HARNESS_API} | `{}` — `{}` |",
            row.name,
            row.version,
            row.core_req,
            range(&row.core_req),
        );
    }
    out.push_str(
        "\nA module row means: that module version was built and conformance-tested\n\
         against every `cratefield-core` its range accepts at the time of release\n\
         (the caret keeps it to one pre-1.0 minor). The conformance suite runs\n\
         per module crate via `.github/workflows/conformance.yml`, which is also\n\
         exported as a reusable workflow for modules built out of tree.\n",
    );
    out
}

fn main() {
    let root = workspace_root();
    let path = root.join("docs/COMPATIBILITY.md");
    let rows = consumers(&root);
    assert!(
        !rows.is_empty(),
        "no cratefield-core consumers in workspace"
    );
    let generated = markdown(&rows);
    let check = std::env::args().any(|arg| arg == "--check");

    if check {
        let current = std::fs::read_to_string(&path).unwrap_or_default();
        if current != generated {
            eprintln!(
                "docs/COMPATIBILITY.md is stale; regenerate with `cargo run -p cratefield-cli \
                 --example compatibility-doc` and commit it"
            );
            std::process::exit(1);
        }
        println!(
            "docs/COMPATIBILITY.md is up to date ({} crates)",
            rows.len()
        );
        return;
    }

    std::fs::write(&path, generated).expect("write docs/COMPATIBILITY.md");
    println!("wrote {} ({} crates)", path.display(), rows.len());
}