a3s 0.10.0

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use crate::cli::args::{
    OutputMode, TopArgs, TopConnector, TopEventKind, TopRisk, TopSort, TopView,
};
use crate::cli::context::InvocationContext;
use crate::cli::output::{usage_error, CliError, ExitClass};

pub(crate) async fn run(args: TopArgs, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let argv = normalize(args, output)?;
    let result = match output {
        OutputMode::Human => crate::top::run(argv).await,
        OutputMode::Json => {
            crate::top::run_machine(
                argv,
                crate::top::MachineOutput::Json,
                context.cancellation.clone(),
            )
            .await
        }
        OutputMode::Jsonl => {
            crate::top::run_machine(
                argv,
                crate::top::MachineOutput::Jsonl,
                context.cancellation.clone(),
            )
            .await
        }
    };
    match result {
        Err(error) => {
            if let Some(interrupted) = error.downcast_ref::<crate::top::TopInterrupted>() {
                return Err(CliError::new(
                    "operation.cancelled",
                    "top monitoring cancelled",
                    ExitClass::Cancelled,
                )
                .with_jsonl_sequence(interrupted.next_sequence())
                .into());
            }
            Err(error)
        }
        Ok(()) => Ok(()),
    }
}

fn normalize(mut args: TopArgs, output: OutputMode) -> anyhow::Result<Vec<String>> {
    let legacy_views = [
        (args.agents, TopView::Agents, "--agents"),
        (args.view_sessions, TopView::Sessions, "--sessions"),
        (args.view_containers, TopView::Containers, "--containers"),
        (args.view_processes, TopView::Processes, "--processes"),
        (args.view_events, TopView::Events, "--events"),
    ]
    .into_iter()
    .filter(|(selected, _, _)| *selected)
    .collect::<Vec<_>>();
    if legacy_views.len() > 1 || (args.view.is_some() && !legacy_views.is_empty()) {
        return Err(usage_error(
            "select exactly one monitor view with `--view <view>`",
        ));
    }
    let view = args.view.or_else(|| {
        legacy_views.first().map(|(_, view, spelling)| {
            if output == OutputMode::Human {
                eprintln!(
                    "warning: `{spelling}` is deprecated; use `--view {}`",
                    view_name(*view)
                );
            }
            *view
        })
    });

    let mut container = args.container.take();
    if let Some(value) = args.legacy_container.take() {
        if args.watch
            && args.interval.is_none()
            && looks_like_duration(&value)
            && container.is_none()
        {
            if output == OutputMode::Human {
                eprintln!(
                    "warning: `--watch <duration>` is deprecated; use `--watch --interval <duration>`"
                );
            }
            args.interval = Some(value);
        } else if container.is_none() {
            if output == OutputMode::Human {
                eprintln!(
                    "warning: positional container targets are deprecated; use `--container <container>`"
                );
            }
            container = Some(value);
        } else {
            return Err(usage_error("only one container target may be selected"));
        }
    }
    if output == OutputMode::Jsonl && !args.watch {
        return Err(usage_error("JSONL output requires `--watch`"));
    }
    if output == OutputMode::Json && (args.watch || args.interval.is_some()) {
        return Err(usage_error(
            "JSON output returns one snapshot; use JSONL with `--watch` for a stream",
        ));
    }
    if args.count == Some(0) {
        return Err(usage_error("--count must be greater than zero"));
    }
    if args.count.is_some() && output == OutputMode::Human {
        return Err(usage_error("--count is available only with JSONL output"));
    }

    let mut argv = Vec::new();
    if let Some(container) = container {
        argv.extend(["--container".to_string(), container]);
    }
    if let Some(view) = view {
        argv.push(format!("--{}", view_name(view)));
    }
    if let Some(connector) = args.connector {
        argv.extend([
            "--connector".to_string(),
            connector_name(connector).to_string(),
        ]);
    }
    if args.active {
        argv.push("--active".to_string());
    }
    if args.all {
        argv.push("--all".to_string());
    }
    if let Some(filter) = args.filter {
        argv.extend(["--filter".to_string(), filter]);
    }
    if let Some(sort) = args.sort {
        argv.extend(["--sort".to_string(), sort_name(sort).to_string()]);
    }
    if args.reverse {
        argv.push("--reverse".to_string());
    }
    if let Some(risk) = args.risk {
        argv.extend(["--risk".to_string(), risk_name(risk).to_string()]);
    }
    if let Some(kind) = args.kind {
        argv.extend(["--kind".to_string(), kind_name(kind).to_string()]);
    }
    if args.compact {
        argv.push("--compact".to_string());
    }
    if args.no_header {
        argv.push("--no-header".to_string());
    }
    if args.invert {
        argv.push("--invert".to_string());
    }
    if args.watch || args.interval.is_some() {
        argv.extend([
            "--watch".to_string(),
            args.interval.unwrap_or_else(|| "1500ms".to_string()),
        ]);
    }
    if let Some(count) = args.count {
        argv.extend(["--count".to_string(), count.to_string()]);
    }
    Ok(argv)
}

fn looks_like_duration(value: &str) -> bool {
    let value = value.trim();
    value.ends_with("ms")
        || value.ends_with('s')
        || (!value.is_empty() && value.chars().all(|character| character.is_ascii_digit()))
}

fn view_name(value: TopView) -> &'static str {
    match value {
        TopView::Agents => "agents",
        TopView::Sessions => "sessions",
        TopView::Containers => "containers",
        TopView::Processes => "processes",
        TopView::Events => "events",
    }
}

fn connector_name(value: TopConnector) -> &'static str {
    match value {
        TopConnector::A3sBox => "a3s-box",
        TopConnector::Docker => "docker",
        TopConnector::Runc => "runc",
    }
}

fn sort_name(value: TopSort) -> &'static str {
    match value {
        TopSort::Cpu => "cpu",
        TopSort::Mem => "mem",
        TopSort::Net => "net",
        TopSort::Block => "block",
        TopSort::Pids => "pids",
        TopSort::State => "state",
        TopSort::Id => "id",
        TopSort::Uptime => "uptime",
        TopSort::Name => "name",
        TopSort::Tokens => "tokens",
    }
}

fn risk_name(value: TopRisk) -> &'static str {
    match value {
        TopRisk::All => "all",
        TopRisk::Medium => "medium",
        TopRisk::High => "high",
    }
}

fn kind_name(value: TopEventKind) -> &'static str {
    match value {
        TopEventKind::All => "all",
        TopEventKind::Tool => "tool",
        TopEventKind::Security => "security",
        TopEventKind::File => "file",
        TopEventKind::Egress => "egress",
        TopEventKind::Llm => "llm",
        TopEventKind::Other => "other",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonical_watch_is_adapted_to_the_existing_snapshot_engine() {
        let args = TopArgs {
            view: Some(TopView::Events),
            watch: true,
            interval: Some("2s".to_string()),
            count: Some(3),
            ..TopArgs::default()
        };
        assert_eq!(
            normalize(args, OutputMode::Jsonl).unwrap(),
            ["--events", "--watch", "2s", "--count", "3"]
        );
    }
}