arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc inspect` — inspect the Unified Application Graph (side-effect-free).
//!
//! Reads the application metadata artifact (the canonical UAG produced by the
//! app's `arcature-metadata` binary) and presents a focused view:
//!
//! ```text
//! arc inspect app              — the whole application graph summary
//! arc inspect route links.show — a single named route's details
//! arc inspect model Links      — a single module's metadata
//! ```
//!
//! All views are side-effect-free: no port bind, no DB/cache/SMTP/S3
//! connection, no worker start, no migration. The artifact is loaded via
//! `metadata::load` (shelling out to `arcature-metadata`), exactly like
//! `arc routes` / `arc modules` / `arc services` (PROGRAM.md
//! §"Side-effect-free inspection"; ADR-0006 §2). Deserializing the canonical
//! `arcature_build::uag::Uag` (the single schema `arcature-build` owns) means
//! `arc inspect route` now surfaces the typed action/query/query-string field
//! shapes the previous hand-maintained mirror silently dropped.

use crate::cli::{InspectTarget, OutputFormat};
use crate::error::CommandError;
use crate::metadata::{FieldShapeEntry, ModuleEntry, RouteEntry, Uag};
use crate::project;
use serde::Serialize;

pub(crate) fn execute(target: InspectTarget) -> Result<(), CommandError> {
    let project = project::discover()?;
    let artifact = crate::metadata::load(&project).map_err(CommandError::Metadata)?;
    match target {
        InspectTarget::App(format) => print_app(&artifact, format),
        InspectTarget::Route { name, format } => print_route(&artifact, &name, format),
        InspectTarget::Module { name, format } => print_module(&artifact, &name, format),
    }
}

fn print_app(artifact: &Uag, format: OutputFormat) -> Result<(), CommandError> {
    match format {
        OutputFormat::Human => {
            println!(
                "Application: {} modules, {} routes, {} services",
                artifact.modules.len(),
                artifact.routes.len(),
                artifact.services.len(),
            );
            for module in artifact.modules.values() {
                println!(
                    "  {}{} controllers, {} services, {} routes",
                    module.name,
                    module.controllers.len(),
                    module.services.len(),
                    module.routes.len(),
                );
            }
        }
        OutputFormat::Json => {
            // The whole canonical UAG: modules (as a map), routes, services,
            // pages, and the schema/application/framework version metadata.
            println!("{}", serde_json::to_string_pretty(artifact)?);
        }
    }
    Ok(())
}

fn print_route(artifact: &Uag, name: &str, format: OutputFormat) -> Result<(), CommandError> {
    let route: Vec<&RouteEntry> = artifact.routes.iter().filter(|r| r.name == name).collect();
    if route.is_empty() {
        return Err(CommandError::Metadata(format!(
            "no route named `{name}` in the application graph"
        )));
    }
    match format {
        OutputFormat::Human => {
            for r in &route {
                println!(
                    "{:<8} {:<20} {:<20} {}",
                    r.method.to_uppercase(),
                    r.path,
                    r.name,
                    r.handler,
                );
                // Additive route-detail section: surface the typed
                // action/query/query-string field shapes and the declared
                // page identities the previous hand-maintained mirror
                // silently dropped. Each line is printed only when the route
                // declares the corresponding contract, so a plain route's
                // output is unchanged (additive-only).
                print_route_detail(r);
            }
        }
        OutputFormat::Json => {
            #[derive(Serialize)]
            struct Out<'a> {
                routes: &'a [&'a RouteEntry],
            }
            println!("{}", serde_json::to_string_pretty(&Out { routes: &route })?,);
        }
    }
    Ok(())
}

/// Prints the additive route-detail section for `arc inspect route <name>`:
/// the declared page identities, the action request type + input fields, the
/// query resource type + response fields (and whether the response is a
/// collection), and the query-string request type + fields. Each block prints
/// only when the route declares the corresponding contract — a plain route
/// produces no detail lines, so its output is identical to the pre-refactor
/// table row.
fn print_route_detail(route: &RouteEntry) {
    if !route.pages.is_empty() {
        println!("  pages:         {}", route.pages.join(", "));
    }
    if !route.action_type.is_empty() {
        println!(
            "  action:         {type_name}",
            type_name = route.action_type
        );
        if !route.action_fields.is_empty() {
            println!(
                "    fields:       {}",
                field_shape_lines(&route.action_fields)
            );
        }
    }
    if !route.query_type.is_empty() {
        let kind = if route.query_array {
            "collection"
        } else {
            "single"
        };
        println!(
            "  query:          {type_name} ({kind})",
            type_name = route.query_type
        );
        if !route.query_fields.is_empty() {
            println!(
                "    fields:       {}",
                field_shape_lines(&route.query_fields)
            );
        }
    }
    if !route.query_string_type.is_empty() {
        println!(
            "  query-string:   {type_name}",
            type_name = route.query_string_type
        );
        if !route.query_string_fields.is_empty() {
            println!(
                "    fields:       {}",
                field_shape_lines(&route.query_string_fields)
            );
        }
    }
}

/// Renders a list of field shapes as `name: ty [validates, …]` entries joined
/// by `, ` (each entry's optional `[…]` validation rules are omitted when
/// empty). Kept on one line so the detail block is compact and grep-friendly.
fn field_shape_lines(fields: &[FieldShapeEntry]) -> String {
    fields
        .iter()
        .map(|f| {
            if f.validates.is_empty() {
                format!("{}: {}", f.name, f.ty)
            } else {
                format!("{}: {} [{}]", f.name, f.ty, f.validates.join(", "))
            }
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn print_module(artifact: &Uag, name: &str, format: OutputFormat) -> Result<(), CommandError> {
    let module: Option<&ModuleEntry> = artifact.modules.values().find(|m| m.name == name);
    let module = match module {
        Some(m) => m,
        None => {
            return Err(CommandError::Metadata(format!(
                "no module named `{name}` in the application graph"
            )));
        }
    };
    match format {
        OutputFormat::Human => {
            println!("Module: {}", module.name);
            if !module.imports.is_empty() {
                println!("  imports:  {}", module.imports.join(", "));
            }
            if !module.exports.is_empty() {
                println!("  exports:  {}", module.exports.join(", "));
            }
            if !module.controllers.is_empty() {
                println!("  controllers: {}", module.controllers.join(", "));
            }
            if !module.services.is_empty() {
                println!("  services:    {}", module.services.join(", "));
            }
            if !module.policies.is_empty() {
                println!("  policies: {}", module.policies.join(", "));
            }
            if !module.routes.is_empty() {
                println!("  routes:");
                for r in &module.routes {
                    println!("    {:<8} {}", r.method.to_uppercase(), r.path);
                }
            }
            if !module.listeners.is_empty() {
                println!("  listeners:");
                for l in &module.listeners {
                    println!("    {} -> {}", l.event, l.listener);
                }
            }
            if !module.jobs.is_empty() {
                println!("  jobs:");
                for j in &module.jobs {
                    println!("    {} (v{}) -> {}", j.kind, j.version, j.handler);
                }
            }
            if !module.commands.is_empty() {
                println!("  commands:");
                for c in &module.commands {
                    println!("    {} -> {}", c.name, c.function);
                }
            }
            if !module.schedules.is_empty() {
                println!("  schedules:");
                for s in &module.schedules {
                    println!("    {} (v{}) {:?}", s.job, s.version, s.cadence);
                }
            }
        }
        OutputFormat::Json => {
            println!("{}", serde_json::to_string_pretty(module)?);
        }
    }
    Ok(())
}