arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Binary-subcommand dispatch integration tests (AP2.1-10).
//!
//! Exercises `arcature::cli::dispatch` end-to-end: parses subcommands from
//! argv and dispatches to a test `Operations` impl, proving:
//!
//! - Each subcommand (`serve`, `migrate`, `queue`, `schedule`, `doctor`,
//!   `about`) routes to the right method.
//! - Trailing argv is forwarded to the operation.
//! - An unknown subcommand produces a typed `SubcommandError::Unknown` (not
//!   a silent fallback to `serve`).
//! - A missing subcommand produces a typed `SubcommandError::Missing`.
//! - An operation that returns `Err` surfaces the typed `EngineError`
//!   (not a panic — AGENTS.md §17).
//!
//! The test does NOT build the `arc` CLI binary or spawn a process; it calls
//! the dispatch library directly so it runs with no features (the dispatch
//! is runtime-agnostic; the `run` runtime wrapper is `macros`-gated and
//! tested by the templates, which AP2.1-S owns).

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use arcature::cli::{Operations, Subcommand, SubcommandError, dispatch, parse};

/// A recording `Operations` that captures which method ran and the args it
/// received, so the tests can prove routing.
struct RecordingOps {
    method: Arc<AtomicUsize>,
    args: Arc<Mutex<Vec<String>>>,
}

impl Operations for RecordingOps {
    fn serve(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(1, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
    fn migrate(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(2, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
    fn queue(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(3, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
    fn schedule(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(4, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
    fn doctor(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(5, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
    fn about(
        &self,
        args: &[String],
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
    {
        let method = self.method.clone();
        let captured = self.args.clone();
        let args = args.to_vec();
        Box::pin(async move {
            method.store(6, Ordering::SeqCst);
            *captured.lock().expect("lock") = args;
            Ok(())
        })
    }
}

#[tokio::test]
async fn dispatch_routes_each_subcommand_to_the_right_method() {
    for (sub, marker) in [
        (Subcommand::Serve, 1usize),
        (Subcommand::Migrate, 2),
        (Subcommand::Queue, 3),
        (Subcommand::Schedule, 4),
        (Subcommand::Doctor, 5),
        (Subcommand::About, 6),
    ] {
        let ops = RecordingOps {
            method: Arc::new(AtomicUsize::new(0)),
            args: Arc::new(Mutex::new(Vec::new())),
        };
        dispatch(&ops, sub.clone(), &[]).await.expect("dispatch ok");
        assert_eq!(
            ops.method.load(Ordering::SeqCst),
            marker,
            "dispatch routed {sub:?} to the wrong operation"
        );
    }
}

#[tokio::test]
async fn dispatch_forwards_trailing_args() {
    let ops = RecordingOps {
        method: Arc::new(AtomicUsize::new(0)),
        args: Arc::new(Mutex::new(Vec::new())),
    };
    let args = vec!["up".to_owned(), "--steps".to_owned(), "3".to_owned()];
    dispatch(&ops, Subcommand::Migrate, &args)
        .await
        .expect("migrate ok");
    assert_eq!(
        ops.args.lock().expect("lock").clone(),
        args,
        "trailing args forwarded"
    );
}

#[tokio::test]
async fn dispatch_surfaces_operation_errors_without_panicking() {
    struct FailingOps;
    impl Operations for FailingOps {
        fn serve(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Err(arcature::EngineError::InvalidPort("nope".to_owned())) })
        }
        fn migrate(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Ok(()) })
        }
        fn queue(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Ok(()) })
        }
        fn schedule(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Ok(()) })
        }
        fn doctor(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Ok(()) })
        }
        fn about(
            &self,
            _args: &[String],
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = arcature::Result<()>> + Send + '_>>
        {
            Box::pin(async { Ok(()) })
        }
    }
    let result = dispatch(&FailingOps, Subcommand::Serve, &[]).await;
    assert!(
        matches!(result, Err(arcature::EngineError::InvalidPort(_))),
        "operation error surfaced, got: {result:?}"
    );
}

#[test]
fn parse_routes_each_known_subcommand() {
    assert_eq!(parse(["serve"]), Ok(Subcommand::Serve));
    assert_eq!(parse(["migrate"]), Ok(Subcommand::Migrate));
    assert_eq!(parse(["queue"]), Ok(Subcommand::Queue));
    assert_eq!(parse(["schedule"]), Ok(Subcommand::Schedule));
    assert_eq!(parse(["doctor"]), Ok(Subcommand::Doctor));
    assert_eq!(parse(["about"]), Ok(Subcommand::About));
}

#[test]
fn parse_missing_is_typed_error() {
    let result: Result<Subcommand, SubcommandError> = parse(std::iter::empty::<String>());
    assert_eq!(result, Err(SubcommandError::Missing));
}

#[test]
fn parse_unknown_is_typed_error_with_name() {
    assert_eq!(
        parse(["deploy"]),
        Err(SubcommandError::Unknown {
            name: "deploy".to_owned()
        })
    );
    // A flag-like argument is an unknown subcommand (not a silent fallback).
    assert_eq!(
        parse(["--help"]),
        Err(SubcommandError::Unknown {
            name: "--help".to_owned()
        })
    );
}

#[test]
fn parse_consumes_only_first_positional() {
    // `migrate up --steps 3` parses the selector; the trailing args are
    // forwarded by the dispatcher, not parsed here.
    assert_eq!(
        parse(["migrate", "up", "--steps", "3"]),
        Ok(Subcommand::Migrate)
    );
}