arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! UAG (Unified Application Graph) loading for the CLI's UAG-consuming
//! commands (`arc inspect`, `arc mcp`, and the future Inspector).
//!
//! This is the **read path** that consumes the canonical, serializable,
//! schema-versioned UAG produced by the app's `arcature-metadata` binary
//! (ADR-0006 §2: "Same graph everywhere"). The CLI deserializes the artifact
//! as [`arcature_build::uag::Uag`] — the one canonical schema type — rather
//! than the legacy [`crate::metadata::MetadataArtifact`] mirror, which is
//! retained only for the pre-AP2.1 commands (`arc routes` / `arc modules` /
//! `arc services` / `arc schedule`) that another lane is migrating.
//!
//! The cardinal rule (PROGRAM.md AP2.1-9): the UAG is the single source of
//! truth. The CLI **never** greps the project, walks the source tree, or
//! parses Rust to reconstruct what the graph already knows. Every read goes
//! through one of these two loaders:
//!
//! * [`load_uag`] — file-preferred. Reads `.arcature/app-manifest.json` if
//!   present (the dev-time artifact `arc dev` / `arcature-metadata` writes),
//!   and falls back to a fresh shell-out when it is missing or stale. This is
//!   the default for `arc inspect` and the MCP server: it is fast (no cargo
//!   build), side-effect-free, and works on a committed tree without a live
//!   app. It is what enables the "no-grep" invariant — MCP reads a serialized
//!   artifact, never the source.
//! * [`load_uag_fresh`] — shell-out only. Runs `cargo run --bin
//!   arcature-metadata` and deserializes the JSON it prints to stdout. This
//!   rebuilds the graph from the live application and is the authoritative
//!   producer invocation; `arc inspect` uses it when the on-disk manifest
//!   is absent.
//!
//! Both paths validate [`Uag::schema_version`] against
//! [`arcature_build::uag::SCHEMA_VERSION`] and reject a mismatch with a
//! typed error rather than guessing at an incompatible artifact
//! (ADR-0006 §2).

use std::path::Path;

use arcature_build::uag::{SCHEMA_VERSION, Uag};

use crate::process::{ProcessSpec, run_capture};
use crate::project::ProjectConfig;

use super::uag_schema::SchemaError;

/// The on-disk UAG manifest path written by `arcature-metadata`
/// (`.arcature/app-manifest.json` at the project root).
const MANIFEST_RELATIVE_PATH: &str = ".arcature/app-manifest.json";

/// Load the UAG, preferring the on-disk manifest and falling back to a fresh
/// shell-out when it is missing.
///
/// File-preferred is the default for `arc inspect` and `arc mcp`: it is fast
/// (no cargo build), side-effect-free, and works on a committed tree. A
/// missing manifest (e.g. before the first `arc dev` / `arcature-metadata`
/// run) triggers [`load_uag_fresh`].
pub(crate) fn load_uag(project: &ProjectConfig) -> Result<Uag, SchemaError> {
    let manifest_path = project.root().join(MANIFEST_RELATIVE_PATH);
    if manifest_path.is_file() {
        load_uag_from_file(&manifest_path)
    } else {
        load_uag_fresh(project)
    }
}

/// Load the UAG from a specific manifest file path, validating the schema
/// version. Exposed so tests can load a fixture manifest without a project
/// discovery round-trip, and so the MCP server can target an explicit
/// artifact.
pub(crate) fn load_uag_from_file(path: &Path) -> Result<Uag, SchemaError> {
    let bytes = std::fs::read(path).map_err(|source| SchemaError::ReadManifest {
        path: path.to_path_buf(),
        source,
    })?;
    let uag: Uag = serde_json::from_slice(&bytes).map_err(|source| SchemaError::ParseManifest {
        path: path.to_path_buf(),
        source,
    })?;
    if uag.schema_version != SCHEMA_VERSION {
        return Err(SchemaError::IncompatibleSchema {
            found: uag.schema_version,
            expected: SCHEMA_VERSION,
        });
    }
    Ok(uag)
}

/// Load a freshly-produced UAG by shelling out to the app's
/// `arcature-metadata` binary (the producer), and validate its schema
/// version.
///
/// This rebuilds the graph from the live application — it is the
/// authoritative invocation `arc inspect` uses when no on-disk manifest
/// exists. It runs `cargo run --quiet --package <backend> --bin
/// arcature-metadata`, captures the JSON the binary prints to stdout, and
/// deserializes it as a [`Uag`]. Side-effect-free from the CLI's
/// perspective: the metadata binary itself must not boot infrastructure
/// (PROGRAM.md "Side-effect-free inspection").
pub(crate) fn load_uag_fresh(project: &ProjectConfig) -> Result<Uag, SchemaError> {
    let output = run_capture(&ProcessSpec::new("cargo", project.root()).args([
        "run",
        "--quiet",
        "--package",
        &project.backend_package,
        "--bin",
        "arcature-metadata",
    ]))
    .map_err(|error| SchemaError::RunMetadata(error.to_string()))?;
    let uag: Uag =
        serde_json::from_slice(&output).map_err(|source| SchemaError::ParseManifest {
            path: project.root().join(MANIFEST_RELATIVE_PATH),
            source,
        })?;
    if uag.schema_version != SCHEMA_VERSION {
        return Err(SchemaError::IncompatibleSchema {
            found: uag.schema_version,
            expected: SCHEMA_VERSION,
        });
    }
    Ok(uag)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::metadata::uag_schema::tests as schema_tests;
    use arcature_build::uag::SCHEMA_VERSION;
    use arcature_build::uag::schema::{
        CadenceEntry, CommandEntry, JobEntry, ListenerEntry, ModuleEntry, RouteEntry,
        ScheduleEntry, ServiceEntry,
    };
    use std::collections::BTreeMap;
    use tempfile::tempdir;

    fn fixture_uag() -> Uag {
        let mut modules = BTreeMap::new();
        modules.insert(
            "Links".to_owned(),
            ModuleEntry {
                name: "Links".to_owned(),
                imports: vec!["Accounts".to_owned()],
                exports: vec![],
                controllers: vec!["LinksController".to_owned()],
                services: vec![],
                policies: vec![],
                routes: vec![RouteEntry {
                    method: "get".to_owned(),
                    path: "/links".to_owned(),
                    name: "links.index".to_owned(),
                    handler: "LinksController::index".to_owned(),
                    pages: vec![],
                    action_fields: vec![],
                    action_type: String::new(),
                    query_fields: vec![],
                    query_type: String::new(),
                    query_array: false,
                    query_string_fields: vec![],
                    query_string_type: String::new(),
                }],
                listeners: vec![ListenerEntry {
                    event: "LinkCreated".to_owned(),
                    listener: "log_creation".to_owned(),
                }],
                jobs: vec![JobEntry {
                    kind: "check_links".to_owned(),
                    version: 1,
                    handler: "handle_check_links".to_owned(),
                }],
                commands: vec![CommandEntry {
                    name: "links:prune".to_owned(),
                    function: "prune_links".to_owned(),
                }],
                schedules: vec![ScheduleEntry {
                    job: "cleanup_sessions".to_owned(),
                    version: 1,
                    cadence: CadenceEntry::Every { seconds: 300 },
                }],
            },
        );
        Uag {
            schema_version: SCHEMA_VERSION,
            application: "App".to_owned(),
            framework_version: "2026.1.0".to_owned(),
            modules,
            routes: vec![RouteEntry {
                method: "get".to_owned(),
                path: "/links/{link}".to_owned(),
                name: "links.show".to_owned(),
                handler: "LinksController::show".to_owned(),
                pages: vec![],
                action_fields: vec![],
                action_type: String::new(),
                query_fields: vec![],
                query_type: String::new(),
                query_array: false,
                query_string_fields: vec![],
                query_string_type: String::new(),
            }],
            services: vec![ServiceEntry {
                name: "LinkService".to_owned(),
                deps: vec!["Db".to_owned(), "Cache".to_owned()],
            }],
            pages: vec![],
        }
    }

    #[test]
    fn load_uag_from_file_reads_and_validates_schema() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("app-manifest.json");
        let uag = fixture_uag();
        let json = serde_json::to_string(&uag).expect("serialize");
        std::fs::write(&path, json).expect("write");
        let loaded = load_uag_from_file(&path).expect("load");
        assert_eq!(loaded, uag);
        assert_eq!(loaded.schema_version, SCHEMA_VERSION);
        assert_eq!(loaded.application, "App");
        assert_eq!(loaded.routes.len(), 1);
        assert_eq!(loaded.modules.len(), 1);
    }

    #[test]
    fn load_uag_from_file_rejects_incompatible_schema() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("app-manifest.json");
        let mut uag = fixture_uag();
        uag.schema_version = SCHEMA_VERSION + 1;
        let json = serde_json::to_string(&uag).expect("serialize");
        std::fs::write(&path, json).expect("write");
        let err = load_uag_from_file(&path).expect_err("should reject");
        assert!(matches!(err, SchemaError::IncompatibleSchema { .. }));
    }

    #[test]
    fn load_uag_from_file_rejects_missing_file() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("does-not-exist.json");
        let err = load_uag_from_file(&path).expect_err("should fail");
        assert!(matches!(err, SchemaError::ReadManifest { .. }));
    }

    #[test]
    fn load_uag_from_file_rejects_malformed_json() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("app-manifest.json");
        std::fs::write(&path, b"{ not valid json").expect("write");
        let err = load_uag_from_file(&path).expect_err("should fail");
        assert!(matches!(err, SchemaError::ParseManifest { .. }));
    }

    // Re-export the schema error tests so the crate's `--lib` suite covers the
    // error type alongside the loader (keeps test discovery in one place).
    #[test]
    fn schema_error_display_is_stable() {
        schema_tests::assert_display_stable();
    }
}