arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `jobs` tool — the job/command/schedule bindings across modules.
//!
//! Returns three lists aggregated from the UAG's per-module bindings:
//! job handlers, application commands, and scheduled jobs. The UAG carries
//! these nested in each module; this tool flattens them deterministically
//! (module-name order from the `BTreeMap`). It reads the UAG, not the
//! source tree (cardinal rule). Takes no arguments.

use crate::commands::mcp::capability::CapabilitySet;
use crate::commands::mcp::error::McpError;
use crate::commands::mcp::registry::ToolContext;
use serde::Serialize;
use serde_json::Value;

#[derive(Serialize)]
struct JobsResult {
    jobs: Vec<JobRow>,
    commands: Vec<CommandRow>,
    schedules: Vec<ScheduleRow>,
}

#[derive(Serialize)]
struct JobRow {
    module: String,
    kind: String,
    version: i16,
    handler: String,
}

#[derive(Serialize)]
struct CommandRow {
    module: String,
    name: String,
    function: String,
}

#[derive(Serialize)]
struct ScheduleRow {
    module: String,
    job: String,
    version: i16,
    cadence: arcature_build::uag::schema::CadenceEntry,
}

pub(crate) fn call(
    _arguments: &Value,
    _capabilities: &CapabilitySet,
    context: &ToolContext,
) -> Result<Value, McpError> {
    let mut jobs = Vec::new();
    let mut commands = Vec::new();
    let mut schedules = Vec::new();
    for (module_name, module) in &context.uag.modules {
        for job in &module.jobs {
            jobs.push(JobRow {
                module: module_name.clone(),
                kind: job.kind.clone(),
                version: job.version,
                handler: job.handler.clone(),
            });
        }
        for command in &module.commands {
            commands.push(CommandRow {
                module: module_name.clone(),
                name: command.name.clone(),
                function: command.function.clone(),
            });
        }
        for schedule in &module.schedules {
            schedules.push(ScheduleRow {
                module: module_name.clone(),
                job: schedule.job.clone(),
                version: schedule.version,
                cadence: schedule.cadence.clone(),
            });
        }
    }
    let result = JobsResult {
        jobs,
        commands,
        schedules,
    };
    serde_json::to_value(result).map_err(McpError::from)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::inspection::tests::empty_module;
    use arcature_build::uag::Uag;
    use arcature_build::uag::schema::{CadenceEntry, CommandEntry, JobEntry, ScheduleEntry};
    use std::collections::BTreeMap;

    fn ctx_with_bindings() -> ToolContext {
        let mut modules = BTreeMap::new();
        let mut links = empty_module("Links");
        links.jobs.push(JobEntry {
            kind: "check_links".into(),
            version: 1,
            handler: "handle".into(),
        });
        links.commands.push(CommandEntry {
            name: "links:prune".into(),
            function: "prune".into(),
        });
        links.schedules.push(ScheduleEntry {
            job: "cleanup".into(),
            version: 1,
            cadence: CadenceEntry::Every { seconds: 300 },
        });
        modules.insert("Links".into(), links);
        modules.insert("Accounts".into(), empty_module("Accounts"));
        ToolContext {
            uag: Uag {
                schema_version: 1,
                application: "App".into(),
                framework_version: "2026.1.0".into(),
                modules,
                routes: vec![],
                services: vec![],
                pages: vec![],
            },
        }
    }

    #[test]
    fn aggregates_jobs_commands_schedules() {
        let ctx = ctx_with_bindings();
        let value = call(
            &Value::Null,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect("ok");
        assert_eq!(value["jobs"].as_array().expect("array").len(), 1);
        assert_eq!(value["jobs"][0]["module"], "Links");
        assert_eq!(value["jobs"][0]["kind"], "check_links");
        assert_eq!(value["commands"].as_array().expect("array").len(), 1);
        assert_eq!(value["commands"][0]["name"], "links:prune");
        assert_eq!(value["schedules"].as_array().expect("array").len(), 1);
        assert_eq!(value["schedules"][0]["cadence"]["kind"], "every");
        assert_eq!(value["schedules"][0]["cadence"]["seconds"], 300);
    }

    #[test]
    fn empty_uag_returns_empty_lists() {
        let ctx = ToolContext {
            uag: Uag {
                schema_version: 1,
                application: String::new(),
                framework_version: String::new(),
                modules: BTreeMap::new(),
                routes: vec![],
                services: vec![],
                pages: vec![],
            },
        };
        let value = call(
            &Value::Null,
            &CapabilitySet::from_options(&crate::cli::McpOptions::default()),
            &ctx,
        )
        .expect("ok");
        assert!(value["jobs"].as_array().expect("array").is_empty());
        assert!(value["commands"].as_array().expect("array").is_empty());
        assert!(value["schedules"].as_array().expect("array").is_empty());
    }
}