smix-cli 0.1.0

smix — AI-native iOS Simulator automation CLI (cement). v3.1 c12 MVP: doctor + sim subcommands. record/run/repl/watch land in c13/c-final.
//! v5.1 c7 — `smix selftest multi` CLI subcommand 实现。
//!
//! 调 smix-sdk 的 `selftest::multi::run_multi_selftest`。CLI 层只做:
//! 1. argv 解析(`<UDID>:<PORT>` × N)
//! 2. runs_root 计算(`.smix/selftest-multi/<RFC3339>/`)
//! 3. exit code 映射(0=all_pass / 1=some_fail / 2=IO Err / 64=usage)
//!
//! 前置假设(gate-multi.sh 打包):每个 UDID 已用 `smix capsule up <UDID>
//! --runner-port <PORT>` 起好。CLI 不自起 capsule(scope 控);要 gate
//! 三连就走 `scripts/v5/selftest-gate-multi.sh`。

use std::path::PathBuf;
use std::process::ExitCode;

use chrono::{SecondsFormat, Utc};

use smix_sdk::selftest::multi::{MultiSimStatus, MultiSimTarget, run_multi_selftest};

/// 把 `<UDID>:<PORT>` 字串解析成 MultiSimTarget。
pub fn parse_target(arg: &str) -> Result<MultiSimTarget, String> {
    let (udid, port) = arg
        .split_once(':')
        .ok_or_else(|| format!("expected `<UDID>:<PORT>`, got `{arg}`"))?;
    let port: u16 = port
        .parse()
        .map_err(|e| format!("port `{port}` not u16: {e}"))?;
    if udid.is_empty() {
        return Err(format!("UDID empty in `{arg}`"));
    }
    Ok(MultiSimTarget {
        udid: udid.to_string(),
        runner_port: port,
    })
}

/// 真跑入口 — 调 `run_multi_selftest`,打 summary,返 exit code。
pub async fn run(targets_argv: Vec<String>) -> ExitCode {
    if targets_argv.is_empty() {
        eprintln!(
            "usage: smix selftest multi <UDID1>:<PORT1> <UDID2>:<PORT2> [...]\n\
             (前置:每个 UDID 都用 `smix capsule up <UDID> --runner-port <PORT>` 起好)"
        );
        return ExitCode::from(64);
    }

    let mut targets = Vec::with_capacity(targets_argv.len());
    for a in &targets_argv {
        match parse_target(a) {
            Ok(t) => targets.push(t),
            Err(e) => {
                eprintln!("smix selftest multi: arg parse error: {e}");
                return ExitCode::from(64);
            }
        }
    }

    let run_id = Utc::now()
        .to_rfc3339_opts(SecondsFormat::Secs, true)
        .replace(':', "-");
    let runs_root = PathBuf::from(format!(".smix/selftest-multi/{run_id}"));

    eprintln!(
        "smix selftest multi: spawning {} parallel sim selftest jobs (runs_root={})",
        targets.len(),
        runs_root.display()
    );
    for t in &targets {
        eprintln!("  - {} @ port {}", t.udid, t.runner_port);
    }

    match run_multi_selftest(targets, runs_root.clone()).await {
        Ok(summary) => {
            eprintln!(
                "smix selftest multi: summary — worst={:?}, pass={}, fail={}, runs={}",
                summary.worst_status,
                summary.pass_count,
                summary.fail_count,
                summary.runs.len()
            );
            for row in &summary.runs {
                eprintln!(
                    "  - {} → status={} coverage={:.1}% caps={} capsule_unattributed={}",
                    row.udid,
                    row.status,
                    row.coverage_pct,
                    row.capability_count,
                    row.capsule_unattributed
                );
            }
            eprintln!(
                "smix selftest multi: summary.json → {}",
                runs_root.join("summary.json").display()
            );
            match summary.worst_status {
                MultiSimStatus::AllPass => ExitCode::from(0),
                MultiSimStatus::SomeFail => ExitCode::from(1),
            }
        }
        Err(e) => {
            eprintln!("smix selftest multi: run failed: {e}");
            ExitCode::from(2)
        }
    }
}

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

    #[test]
    fn parse_target_success() {
        let t = parse_target("ABCD-1234:22087").unwrap();
        assert_eq!(t.udid, "ABCD-1234");
        assert_eq!(t.runner_port, 22087);
    }

    #[test]
    fn parse_target_missing_colon() {
        let e = parse_target("ABCD-1234").unwrap_err();
        assert!(e.contains("expected `<UDID>:<PORT>`"), "got {e:?}");
    }

    #[test]
    fn parse_target_port_not_u16() {
        let e = parse_target("ABCD-1234:notaport").unwrap_err();
        assert!(e.contains("not u16"), "got {e:?}");
    }

    #[test]
    fn parse_target_empty_udid() {
        let e = parse_target(":22087").unwrap_err();
        assert!(e.contains("UDID empty"), "got {e:?}");
    }
}