codex-mobile-bridge 0.3.10

Remote bridge and service manager for codex-mobile.
Documentation
use std::env;
use std::fs;
use std::thread;
use std::time::Duration;

use rusqlite::Connection;
use tempfile::tempdir;

use super::Storage;
use crate::bridge_protocol::{
    BridgeManagementOperation, BridgeManagementPhase, BridgeManagementStatus, BridgeManagementTask,
};

#[test]
fn thread_index_migration_removes_legacy_note_column() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let db_path = temp_dir.path().join("bridge.db");

    {
        let conn = Connection::open(&db_path).expect("创建旧版数据库失败");
        conn.execute_batch(
            "CREATE TABLE thread_index (
                thread_id TEXT PRIMARY KEY,
                runtime_id TEXT NOT NULL DEFAULT 'primary',
                name TEXT NULL,
                note TEXT NULL,
                preview TEXT NOT NULL,
                cwd TEXT NOT NULL,
                status TEXT NOT NULL,
                model_provider TEXT NOT NULL,
                source TEXT NOT NULL,
                created_at_ms INTEGER NOT NULL,
                updated_at_ms INTEGER NOT NULL,
                is_loaded INTEGER NOT NULL,
                is_active INTEGER NOT NULL,
                archived INTEGER NOT NULL DEFAULT 0,
                raw_json TEXT NOT NULL
            );

            INSERT INTO thread_index (
                thread_id, runtime_id, name, note, preview, cwd, status,
                model_provider, source, created_at_ms, updated_at_ms, is_loaded,
                is_active, archived, raw_json
            )
            VALUES (
                'thread-legacy',
                'runtime-primary',
                'Legacy Name',
                'legacy note',
                'legacy preview',
                '/srv/workspace',
                'idle',
                'openai',
                'mobile',
                1,
                2,
                1,
                0,
                0,
                '{\"id\":\"thread-legacy\",\"runtimeId\":\"runtime-primary\",\"name\":\"Legacy Name\",\"note\":\"legacy note\",\"preview\":\"legacy preview\",\"cwd\":\"/srv/workspace\",\"status\":\"idle\",\"statusInfo\":{\"kind\":\"idle\",\"raw\":null},\"tokenUsage\":null,\"modelProvider\":\"openai\",\"source\":\"mobile\",\"createdAt\":1,\"updatedAt\":2,\"isLoaded\":true,\"isActive\":false,\"archived\":false}'
            );",
        )
        .expect("写入旧版 thread_index 数据失败");
    }

    let storage = Storage::open(db_path.clone()).expect("执行 thread_index 迁移失败");
    let migrated = storage
        .get_thread_index("thread-legacy")
        .expect("读取迁移后线程失败")
        .expect("迁移后线程不存在");

    assert_eq!(migrated.name.as_deref(), Some("Legacy Name"));
    assert_eq!(migrated.preview, "legacy preview");

    let conn = Connection::open(&db_path).expect("打开迁移后的数据库失败");
    let mut stmt = conn
        .prepare("PRAGMA table_info(thread_index)")
        .expect("准备 thread_index 结构查询失败");
    let columns = stmt
        .query_map([], |row| row.get::<_, String>(1))
        .expect("查询 thread_index 结构失败")
        .collect::<rusqlite::Result<Vec<_>>>()
        .expect("收集 thread_index 列失败");

    assert!(!columns.iter().any(|column| column == "note"));
}

#[test]
fn ensure_primary_runtime_refreshes_existing_binary() {
    let base_dir =
        env::temp_dir().join(format!("codex-mobile-storage-test-{}", std::process::id()));
    fs::create_dir_all(&base_dir).expect("创建测试目录失败");
    let db_path = base_dir.join("bridge.db");
    let storage = Storage::open(db_path).expect("打开存储失败");

    let initial = storage
        .ensure_primary_runtime(None, "codex".to_string())
        .expect("创建 primary runtime 失败");
    assert_eq!(initial.codex_binary, "codex");

    let refreshed = storage
        .ensure_primary_runtime(None, "/home/test/.npm-global/bin/codex".to_string())
        .expect("刷新 primary runtime 失败");
    assert_eq!(refreshed.codex_binary, "/home/test/.npm-global/bin/codex");
}

#[test]
fn migrate_legacy_workspaces_preserves_existing_bookmarks() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let db_path = temp_dir.path().join("bridge.db");

    {
        let conn = Connection::open(&db_path).expect("创建旧版数据库失败");
        conn.execute_batch(
            "CREATE TABLE workspaces (
                root_path TEXT PRIMARY KEY,
                display_name TEXT NOT NULL,
                created_at_ms INTEGER NOT NULL,
                updated_at_ms INTEGER NOT NULL
            );

            CREATE TABLE directory_bookmarks (
                path TEXT PRIMARY KEY,
                display_name TEXT NOT NULL,
                created_at_ms INTEGER NOT NULL,
                updated_at_ms INTEGER NOT NULL
            );

            INSERT INTO workspaces (root_path, display_name, created_at_ms, updated_at_ms)
            VALUES
                ('/tmp/new-project', '新项目', 11, 12),
                ('/tmp/existing-project', '旧工作区名称', 21, 22);

            INSERT INTO directory_bookmarks (path, display_name, created_at_ms, updated_at_ms)
            VALUES ('/tmp/existing-project', '已存在书签', 31, 32);",
        )
        .expect("写入旧版测试数据失败");
    }

    Storage::open(db_path.clone()).expect("执行迁移失败");

    let conn = Connection::open(&db_path).expect("打开迁移后的数据库失败");
    let mut stmt = conn
        .prepare(
            "SELECT path, display_name, created_at_ms, updated_at_ms
             FROM directory_bookmarks
             ORDER BY path ASC",
        )
        .expect("准备查询目录书签失败");
    let bookmarks = stmt
        .query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, i64>(2)?,
                row.get::<_, i64>(3)?,
            ))
        })
        .expect("查询目录书签失败")
        .collect::<rusqlite::Result<Vec<_>>>()
        .expect("收集目录书签失败");

    assert_eq!(
        bookmarks,
        vec![
            (
                "/tmp/existing-project".to_string(),
                "已存在书签".to_string(),
                31,
                32,
            ),
            ("/tmp/new-project".to_string(), "新项目".to_string(), 11, 12),
        ]
    );

    let workspace_exists = conn
        .query_row(
            "SELECT EXISTS(
                SELECT 1
                FROM sqlite_master
                WHERE type = 'table' AND name = 'workspaces'
            )",
            [],
            |row| row.get::<_, i64>(0),
        )
        .expect("检查 workspaces 表失败");
    assert_eq!(workspace_exists, 0);
}

#[test]
fn upsert_directory_bookmark_updates_name_without_resetting_created_at() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let db_path = temp_dir.path().join("bridge.db");
    let workspace = temp_dir.path().join("workspace");
    fs::create_dir_all(&workspace).expect("创建工作目录失败");
    let storage = Storage::open(db_path).expect("打开存储失败");

    let first = storage
        .upsert_directory_bookmark(&workspace, Some("旧名称"))
        .expect("首次写入目录书签失败");
    thread::sleep(Duration::from_millis(2));
    let second = storage
        .upsert_directory_bookmark(&workspace, Some("新名称"))
        .expect("更新目录书签失败");

    assert_eq!(first.created_at_ms, second.created_at_ms);
    assert_eq!(second.display_name, "新名称");
    assert!(second.updated_at_ms >= first.updated_at_ms);

    let bookmarks = storage
        .list_directory_bookmarks()
        .expect("读取目录书签失败");
    assert_eq!(bookmarks.len(), 1);
    assert_eq!(bookmarks[0].display_name, "新名称");
}

#[test]
fn record_directory_usage_accumulates_use_count() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let db_path = temp_dir.path().join("bridge.db");
    let workspace = temp_dir.path().join("workspace");
    fs::create_dir_all(&workspace).expect("创建工作目录失败");
    let storage = Storage::open(db_path).expect("打开存储失败");

    let first = storage
        .record_directory_usage(&workspace)
        .expect("首次记录目录使用失败");
    thread::sleep(Duration::from_millis(2));
    let second = storage
        .record_directory_usage(&workspace)
        .expect("再次记录目录使用失败");

    assert_eq!(first.use_count, 1);
    assert_eq!(second.use_count, 2);
    assert!(second.last_used_at_ms >= first.last_used_at_ms);

    let history = storage
        .list_directory_history(20)
        .expect("读取目录历史失败");
    assert_eq!(history.len(), 1);
    assert_eq!(history[0].use_count, 2);
}

#[test]
fn bridge_management_tasks_can_round_trip() {
    let temp_dir = tempdir().expect("创建临时目录失败");
    let db_path = temp_dir.path().join("bridge.db");
    let storage = Storage::open(db_path).expect("打开存储失败");

    let task = BridgeManagementTask {
        task_id: "task-1".to_string(),
        operation: BridgeManagementOperation::Update,
        status: BridgeManagementStatus::Running,
        phase: BridgeManagementPhase::InstallRelease,
        summary: "正在安装 bridge 0.2.8".to_string(),
        detail: Some("通过 cargo install 拉取 release".to_string()),
        failure_code: None,
        target_version: Some("0.2.8".to_string()),
        current_version: Some("0.2.7".to_string()),
        started_at_ms: 1,
        updated_at_ms: 2,
        snapshot: None,
    };

    storage
        .upsert_bridge_management_task(&task)
        .expect("写入 bridge 管理任务失败");

    let loaded = storage
        .get_bridge_management_task("task-1")
        .expect("读取 bridge 管理任务失败")
        .expect("bridge 管理任务不存在");

    assert_eq!(loaded.task_id, "task-1");
    assert_eq!(loaded.target_version.as_deref(), Some("0.2.8"));
    assert_eq!(loaded.current_version.as_deref(), Some("0.2.7"));
    assert!(
        storage
            .active_bridge_management_task()
            .expect("查询活动任务失败")
            .is_some()
    );
}