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 c4 — Cap3 硬胶囊 `smix capsule up/down` 子命令(纯逻辑层)。
//!
//! v5.0 cold plan capsule 段双轨设计:
//!   硬胶囊 = Simulator.app 不运行时 simctl boot 的 sim 物理无窗口入口
//!     (实测事实库 ③ 2026-06-10: Simulator.app 退出后 simctl boot 不会拉起
//!     它,无头 boot 零窗口,screenshot/capture 设备级 API 照常)
//!   软胶囊 = Simulator.app 在跑时靠 EventRecorder + SDK ledger 对账兜底
//!     (v5.1 c3 ✅ 已落地)
//!
//! `capsule up <DEVICE>` 哨兵检查:`pgrep -x Simulator` → 若退出码 == 0 表示
//! Simulator.app 在场,默认拒绝(返回 [`CapsuleGuardRejected`]),user 必须
//! 显式 `--soft` flag 才允许带窗硬胶囊条件失守。

use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CapsuleMode {
    Hard,
    Soft,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CapsuleState {
    pub mode: CapsuleMode,
    pub udid: String,
    /// RFC3339 timestamp(`chrono::Utc::now().to_rfc3339()`)。
    pub started_at: String,
    pub runner_port: u16,
    /// e.g. `http://127.0.0.1:8787` — smix-server 入口,capsule down 时
    /// `POST {capture_endpoint}/api/capture/stop` 反向收。
    pub capture_endpoint: String,
    /// Simulator.app 在 `capsule up` 时是否在跑(true 即 mode = Soft,
    /// false 即 mode = Hard)。down 时不需要重新读,留痕用于 audit。
    pub simulator_app_was_running: bool,
    /// v5.6 c2 — true iff `capsule up --no-capture` 起的,即跳过了
    /// smix-server 的 /api/capture/start 调用(同时也跳过 `simctl io
    /// recordVideo` 长跑 host capture pipeline)。set 以便 [`down`] 知道
    /// **不需要** POST `/api/capture/stop`。selftest gate 用此 flag 避开
    /// capture endpoint 占住 simctl recordVideo 的 EBUSY 互斥问题
    /// (v5.6 c1 root cause: scratch/v5.6-c1-recording-flake/analyze.md)。
    #[serde(default)]
    pub no_capture: bool,
}

#[derive(Debug)]
pub struct CapsuleGuardRejected {
    pub hint: String,
}

pub const GUARD_HINT: &str = "Simulator.app 已运行 → simctl boot 会自动开窗,违反硬胶囊条件。\n\
     关掉它后重试(`pkill -INT Simulator`),\n\
     或加 `--soft` flag 显式接受降级到软胶囊(带窗,仅事件对账兜底,\n\
     不保证物理无窗口入口)。";

/// `.smix/capsule/<UDID>.state.json` 完整路径。纯函数,不做 I/O。
pub fn state_path(workspace_root: &Path, udid: &str) -> PathBuf {
    workspace_root
        .join(".smix")
        .join("capsule")
        .join(format!("{udid}.state.json"))
}

/// 哨兵:`pgrep -x Simulator` exit 0 → Simulator.app 在跑。
pub fn simulator_app_running() -> bool {
    std::process::Command::new("pgrep")
        .args(["-x", "Simulator"])
        .output()
        .map(|out| out.status.success())
        .unwrap_or(false)
}

/// 决策模式。`soft=true` 是 user 显式 `--soft` flag 把守门关掉的 escape
/// hatch;`sim_running=true && soft=false` → 拒绝(返回 [`CapsuleGuardRejected`]
/// 含 hint 文字)。
pub fn decide_mode(sim_running: bool, soft: bool) -> Result<CapsuleMode, CapsuleGuardRejected> {
    match (sim_running, soft) {
        (false, _) => Ok(CapsuleMode::Hard),
        (true, true) => Ok(CapsuleMode::Soft),
        (true, false) => Err(CapsuleGuardRejected {
            hint: GUARD_HINT.to_string(),
        }),
    }
}

/// `capsule up <DEVICE>` 端到端 bring up 选项。
pub struct UpOptions<'a> {
    pub root: &'a Path,
    pub udid: &'a str,
    pub runner_port: u16,
    pub capture_endpoint: &'a str,
    pub bundle: Option<&'a str>,
    pub soft: bool,
    /// v5.6 c2 — true 跳过 `/api/capture/start` 调用 (skip /live HLS
    /// capture pipeline). 用于 selftest gate / scenario 内嵌 simctl io
    /// recordVideo (v2_recording_basic seg) 的 EBUSY 互斥规避。
    pub no_capture: bool,
}

/// `capsule up` 端到端:哨兵 + boot + capture start + /live URL + runner up
/// (record mode) + state.json 落地。每步失败立刻 stderr 报 + 不进入下一步;
/// state.json 仅在全部成功时落地(原子 write+rename),失败时不残留。
///
/// v5.2 c1 — 改为 async fn;原 v5.1 c4 内部 `Builder::new_current_thread()
/// .block_on(...)` 在 `#[tokio::main]` runtime 内嵌新 runtime,tokio 1.x
/// 严禁 nested runtime (gate exit 9 真根因)。修法 = 表面 async + main 直接
/// await,不在 cement 层重设 runtime。
pub async fn up(opts: UpOptions<'_>) -> Result<(), String> {
    let sim_running = simulator_app_running();
    let mode = decide_mode(sim_running, opts.soft).map_err(|e| e.hint)?;

    // 2. boot sim(若已 Booted 跳过)。`boot_and_wait` 内部 boot + bootstatus
    // 二合一,等到 sim 真就绪;NonZeroExit + stderr 含 "current state: Booted"
    // 表示已 Booted,等同成功。
    let simctl = smix_simctl::SimctlClient::new();
    match simctl
        .boot_and_wait(opts.udid, std::time::Duration::from_secs(120))
        .await
    {
        Ok(_) => {}
        Err(smix_simctl::SimctlError::NonZeroExit { stderr, .. })
            if stderr.contains("current state: Booted") => {}
        Err(e) => return Err(format!("simctl boot {}: {e}", opts.udid)),
    }

    // 3. capture start. v5.6 c2 — skip when --no-capture (releases the
    // simctl io recordVideo lock for in-scenario recording segments).
    if !opts.no_capture {
        let capture_start_url = format!("{}/api/capture/start", opts.capture_endpoint);
        let body = format!("{{\"udid\":\"{}\"}}", opts.udid);
        post_json(&capture_start_url, &body).map_err(|e| {
            format!(
                "capsule up: capture start failed: {e} (是否 smix-server 在 {} 跑?)",
                opts.capture_endpoint
            )
        })?;
    }

    // 4. print /live URL (or skip-capture banner).
    if opts.no_capture {
        println!(
            "capsule up: mode={mode:?} device={} no-capture (simctl io recordVideo lock free for in-scenario use)",
            opts.udid
        );
    } else {
        println!(
            "capsule up: mode={mode:?} device={} /live={}/live/{}",
            opts.udid, opts.capture_endpoint, opts.udid
        );
    }

    // 5. runner up with record=true.
    crate::runner::up(
        opts.root,
        opts.udid,
        opts.runner_port,
        opts.bundle.or(Some("dev.smix.SelftestFixture")),
        true,
    )?;

    // 6. write state.json(原子 write+rename)。
    let path = state_path(opts.root, opts.udid);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
    }
    let state = CapsuleState {
        mode,
        udid: opts.udid.to_string(),
        started_at: chrono::Utc::now().to_rfc3339(),
        runner_port: opts.runner_port,
        capture_endpoint: opts.capture_endpoint.to_string(),
        simulator_app_was_running: sim_running,
        no_capture: opts.no_capture,
    };
    let json = serde_json::to_string_pretty(&state).map_err(|e| format!("serialize state: {e}"))?;
    let tmp = path.with_extension("tmp");
    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path).map_err(|e| format!("rename {}: {e}", path.display()))?;
    Ok(())
}

/// `capsule down <DEVICE>` 反向收。读 state.json → runner down → capture
/// stop → simctl shutdown → 删 state.json。某步失败 stderr 报继续后续(尽
/// 最大努力收摊),只要 state.json 在最后一步删除前不丢。
///
/// v5.2 c1 — 改 async fn,理由同 [`up`]。
pub async fn down(root: &Path, udid: &str) -> Result<(), String> {
    let path = state_path(root, udid);
    if !path.exists() {
        // 幂等:state.json 不在 → noop。
        eprintln!("capsule down: {} not active (no state.json)", udid);
        return Ok(());
    }
    let json =
        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
    let state: CapsuleState =
        serde_json::from_str(&json).map_err(|e| format!("deserialize {}: {e}", path.display()))?;

    let mut errors: Vec<String> = Vec::new();
    if let Err(e) = crate::runner::down(root, state.runner_port) {
        eprintln!("capsule down: runner down failed: {e}");
        errors.push(format!("runner: {e}"));
    }

    if !state.no_capture {
        let capture_stop_url = format!("{}/api/capture/stop", state.capture_endpoint);
        let body = format!("{{\"udid\":\"{}\"}}", state.udid);
        if let Err(e) = post_json(&capture_stop_url, &body) {
            // 404 / 410 视为正常(capture 已不在)。
            if !e.contains("status=404") && !e.contains("status=410") {
                eprintln!("capsule down: capture stop failed: {e}");
                errors.push(format!("capture: {e}"));
            }
        }
    }

    let simctl = smix_simctl::SimctlClient::new();
    if let Err(e) = simctl.shutdown(&state.udid).await {
        eprintln!("capsule down: simctl shutdown failed: {e}");
        errors.push(format!("shutdown: {e}"));
    }

    if let Err(e) = std::fs::remove_file(&path) {
        eprintln!("capsule down: remove {}: {e}", path.display());
        errors.push(format!("rm state: {e}"));
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(format!(
            "capsule down completed with errors: {}",
            errors.join("; ")
        ))
    }
}

/// Bare HTTP POST helper(沿用 [`crate::runner::health_ok`] 同款手写 TCP
/// 风格,不引 `reqwest` / `ureq` 大 dep)。返回 `Err` 含 `status=<code>`
/// 字面让调用方按状态码分流。
fn post_json(url: &str, body: &str) -> Result<(), String> {
    use std::io::{Read, Write};
    use std::net::TcpStream;
    use std::time::Duration;

    let (host, port, path) = parse_url(url)?;
    let mut stream = TcpStream::connect_timeout(
        &(host.as_str(), port)
            .to_socket_addrs()
            .map_err(|e| format!("resolve {host}:{port}: {e}"))?
            .next()
            .ok_or_else(|| format!("no socket addr for {host}:{port}"))?,
        Duration::from_secs(5),
    )
    .map_err(|e| format!("connect {host}:{port}: {e}"))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("set read_timeout: {e}"))?;
    let req = format!(
        "POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\
         Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    stream
        .write_all(req.as_bytes())
        .map_err(|e| format!("write: {e}"))?;
    let mut resp = String::new();
    stream
        .read_to_string(&mut resp)
        .map_err(|e| format!("read: {e}"))?;
    let status_line = resp.lines().next().unwrap_or("");
    // HTTP/1.1 200 OK
    let status_code: u16 = status_line
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| format!("malformed status line: {status_line:?}"))?;
    if (200..300).contains(&status_code) {
        Ok(())
    } else {
        Err(format!(
            "status={status_code} body={}",
            resp.lines().last().unwrap_or("")
        ))
    }
}

fn parse_url(url: &str) -> Result<(String, u16, String), String> {
    let rest = url
        .strip_prefix("http://")
        .ok_or_else(|| format!("only http:// supported, got {url}"))?;
    let (host_port, path) = rest.split_once('/').unwrap_or((rest, ""));
    let (host, port) = if let Some((h, p)) = host_port.split_once(':') {
        (
            h.to_string(),
            p.parse().map_err(|e| format!("port parse: {e}"))?,
        )
    } else {
        (host_port.to_string(), 80)
    };
    Ok((host, port, format!("/{path}")))
}

use std::net::ToSocketAddrs;

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

    #[test]
    fn state_path_under_workspace() {
        let p = state_path(Path::new("/tmp/ws"), "ABCD-1234");
        assert_eq!(
            p,
            PathBuf::from("/tmp/ws/.smix/capsule/ABCD-1234.state.json")
        );
    }

    #[test]
    fn mode_default_hard_when_simulator_absent() {
        assert_eq!(decide_mode(false, false).unwrap(), CapsuleMode::Hard);
        assert_eq!(decide_mode(false, true).unwrap(), CapsuleMode::Hard);
    }

    #[test]
    fn mode_requires_soft_flag_when_simulator_present() {
        let err = decide_mode(true, false).unwrap_err();
        assert!(
            err.hint.contains("Simulator.app 已运行"),
            "guard hint should name the precondition, got {:?}",
            err.hint
        );
        assert!(
            err.hint.contains("--soft"),
            "guard hint should suggest --soft, got {:?}",
            err.hint
        );
    }

    #[test]
    fn mode_soft_when_explicit() {
        assert_eq!(decide_mode(true, true).unwrap(), CapsuleMode::Soft);
    }

    #[test]
    fn state_round_trips_json() {
        let s = CapsuleState {
            mode: CapsuleMode::Hard,
            udid: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string(),
            started_at: "2026-06-14T12:34:56+09:00".to_string(),
            runner_port: 22087,
            capture_endpoint: "http://127.0.0.1:8787".to_string(),
            simulator_app_was_running: false,
            no_capture: false,
        };
        let j = serde_json::to_string(&s).unwrap();
        let r: CapsuleState = serde_json::from_str(&j).unwrap();
        assert_eq!(r.mode, CapsuleMode::Hard);
        assert_eq!(r.udid, s.udid);
        assert_eq!(r.started_at, s.started_at);
        assert_eq!(r.runner_port, 22087);
        assert_eq!(r.capture_endpoint, s.capture_endpoint);
        assert!(!r.simulator_app_was_running);
        assert!(!r.no_capture);
    }

    // v5.6 c2 — state round-trip honors --no-capture flag for selftest
    // gate scenario recording (avoids simctl io recordVideo EBUSY 16
    // mutex with the live HLS capture pipeline).
    #[test]
    fn state_round_trips_with_no_capture_flag() {
        let s = CapsuleState {
            mode: CapsuleMode::Hard,
            udid: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string(),
            started_at: "2026-06-16T12:34:56+09:00".to_string(),
            runner_port: 22087,
            capture_endpoint: "http://127.0.0.1:8787".to_string(),
            simulator_app_was_running: false,
            no_capture: true,
        };
        let j = serde_json::to_string(&s).unwrap();
        let r: CapsuleState = serde_json::from_str(&j).unwrap();
        assert!(r.no_capture);
    }

    // v5.6 c2 — state.json written by pre-c2 binaries (no `no_capture`
    // field) deserializes with no_capture=false default. Forward compat.
    #[test]
    fn state_back_compat_missing_no_capture_field_defaults_false() {
        let legacy = r#"{
            "mode": "hard",
            "udid": "X",
            "started_at": "2026-06-14T12:34:56+09:00",
            "runner_port": 22087,
            "capture_endpoint": "http://127.0.0.1:8787",
            "simulator_app_was_running": false
        }"#;
        let r: CapsuleState = serde_json::from_str(legacy).unwrap();
        assert!(!r.no_capture);
    }
}