beam-cli 0.11.1

CLI entrypoint for beam, a Rust runtime that connects Feishu/Lark threads to local AI coding CLIs
use crate::*;
use anyhow::{Context, Result, bail};

#[derive(Debug, Default)]
pub(crate) struct MigrateFlags {
    pub(crate) dry_run: bool,
    pub(crate) force: bool,
}

pub(crate) fn parse_migrate_flags(args: &[String]) -> Result<MigrateFlags> {
    let mut flags = MigrateFlags::default();
    for arg in args {
        match arg.as_str() {
            "--dry-run" => flags.dry_run = true,
            "--force" | "-f" => flags.force = true,
            "--backup" => {}
            other => bail!("未知 migrate 参数: {}", other),
        }
    }
    Ok(flags)
}

pub(crate) async fn cmd_migrate(paths: &BeamPaths, args: Vec<String>) -> Result<()> {
    let flags = parse_migrate_flags(&args)?;
    let home = std::env::var("HOME").context("HOME env var not set")?;
    let ts_root = std::path::PathBuf::from(home).join(".beam");
    let ts_bots = ts_root.join("bots.json");

    if !ts_bots.exists() {
        println!("No TS bots.json found at {}", ts_bots.display());
        println!("Nothing to migrate.");
        return Ok(());
    }

    let raw = std::fs::read_to_string(&ts_bots)
        .with_context(|| format!("failed to read {}", ts_bots.display()))?;
    let bots: serde_json::Value = serde_json::from_str(&raw)
        .with_context(|| format!("invalid JSON in {}", ts_bots.display()))?;

    let rs_bots = paths.bots_json();
    let mut conflict_report = Vec::new();
    let existing_bots = if rs_bots.exists() {
        let existing = std::fs::read_to_string(&rs_bots)?;
        if existing.trim().len() > 2 {
            conflict_report.push(format!("bots: {}", rs_bots.display()));
            Some(existing)
        } else {
            None
        }
    } else {
        None
    };

    let ts_sessions = ts_root.join("sessions.json");
    let sessions: Vec<serde_json::Value> = if ts_sessions.exists() {
        let raw = std::fs::read_to_string(&ts_sessions)
            .with_context(|| format!("failed to read {}", ts_sessions.display()))?;
        serde_json::from_str(&raw)
            .with_context(|| format!("invalid JSON in {}", ts_sessions.display()))?
    } else {
        Vec::new()
    };

    let session_plan: Vec<(String, std::path::PathBuf, serde_json::Value)> = sessions
        .iter()
        .map(|session| {
            let session_id = session
                .get("sessionId")
                .or_else(|| session.get("session_id"))
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();
            let out_path = paths.sessions_dir().join(format!("{}.json", session_id));
            (session_id, out_path, session.clone())
        })
        .collect();

    let session_conflicts: Vec<String> = session_plan
        .iter()
        .filter(|(_, path, _)| path.exists())
        .map(|(id, path, _)| format!("session {} -> {}", id, path.display()))
        .collect();
    conflict_report.extend(session_conflicts.iter().cloned());

    if !conflict_report.is_empty() {
        println!("迁移冲突报告:");
        for item in &conflict_report {
            println!("  - {}", item);
        }
        if !flags.force {
            println!("使用 --force 可覆盖这些冲突目标;使用 --dry-run 仅查看报告。");
            if flags.dry_run {
                return Ok(());
            }
            bail!("迁移目标已有内容,已停止。");
        }
    }

    if flags.dry_run {
        println!("Dry run:");
        println!(
            "  TS bots: {}",
            bots.as_array().map(|a| a.len()).unwrap_or(0)
        );
        println!("  TS sessions: {}", session_plan.len());
        println!("  Rust bots target: {}", rs_bots.display());
        for (_, path, _) in &session_plan {
            println!("  session target: {}", path.display());
        }
        return Ok(());
    }

    std::fs::create_dir_all(paths.root())?;
    if existing_bots.is_some() {
        let backup = rs_bots.with_extension("json.bak");
        std::fs::copy(&rs_bots, &backup)?;
        println!("旧 bots 备份: {}", backup.display());
    }
    std::fs::write(&rs_bots, serde_json::to_string_pretty(&bots)?)?;
    println!("Migrated {} -> {}", ts_bots.display(), rs_bots.display());
    println!(
        "{} bot(s) migrated.",
        bots.as_array().map(|a| a.len()).unwrap_or(0)
    );

    if !session_plan.is_empty() {
        std::fs::create_dir_all(paths.sessions_dir())?;
        let mut migrated = 0usize;
        for (session_id, out_path, session) in session_plan {
            if out_path.exists() {
                let backup = out_path.with_extension("json.bak");
                std::fs::copy(&out_path, &backup)?;
                println!("旧 session 备份: {}", backup.display());
            }
            let migrated_session = serde_json::json!({
                "session_id": session_id,
                "title": session.get("title").unwrap_or(&serde_json::json!("migrated")),
                "chat_id": session.get("chatId").or_else(|| session.get("chat_id")).unwrap_or(&serde_json::json!("")),
                "root_message_id": session.get("rootMessageId").or_else(|| session.get("root_message_id")).unwrap_or(&serde_json::json!("")),
                "scope": session.get("scope").unwrap_or(&serde_json::json!("thread")),
                "status": "closed",
                "created_at": chrono::Utc::now().to_rfc3339(),
                "lark_app_id": session.get("larkAppId").or_else(|| session.get("lark_app_id")).unwrap_or(&serde_json::json!("unknown")),
                "owner_open_id": session.get("ownerOpenId").or_else(|| session.get("owner_open_id")),
                "cli_id": session.get("cliId").or_else(|| session.get("cli_id")),
                "cli_bin": session.get("cliBin").or_else(|| session.get("cli_bin")),
                "working_dir": session.get("workingDir").or_else(|| session.get("working_dir")),
            });
            tokio::fs::write(
                &out_path,
                serde_json::to_string_pretty(&migrated_session)? + "\n",
            )
            .await?;
            migrated += 1;
        }
        println!("Migrated {} session(s).", migrated);
    }

    Ok(())
}