rho-coding-agent 2.26.0

A fast Rust agent harness with a small footprint and opinionated defaults
use std::{fs, path::PathBuf};

use rusqlite::{params, Connection};
use tempfile::TempDir;

use super::{
    matching_sessions_any_workspace, migrate_index, migrate_index_with_hook, open_index,
    validate_index_columns, INDEX_SCHEMA_VERSION,
};

/// Commits one minimal session row, to force a WAL write or seed a migration.
pub(super) fn insert_placeholder_session(connection: &Connection, id: &str) {
    connection
        .execute(
            "insert into sessions (
                workspace_key, cwd, id, path, created_at, updated_at, message_count
             ) values ('ws', '/tmp/ws', ?1, '/tmp/ws/' || ?1 || '.jsonl', 1, 1, 0)",
            [id],
        )
        .unwrap();
}

const INDEX_V0: &str = include_str!("fixtures/index-v0.sql");
const INDEX_V1: &str = include_str!("fixtures/index-v1.sql");

#[test]
fn every_supported_index_fixture_migrates_transactionally() {
    for (source_version, fixture) in [(0, INDEX_V0), (1, INDEX_V1)] {
        let mut connection = Connection::open_in_memory().unwrap();
        connection.execute_batch(fixture).unwrap();
        let before: u32 = connection
            .query_row("pragma user_version", [], |row| row.get(0))
            .unwrap();
        assert_eq!(before, source_version);

        migrate_index(&mut connection).unwrap();

        let after: u32 = connection
            .query_row("pragma user_version", [], |row| row.get(0))
            .unwrap();
        let title: Option<String> = connection
            .query_row(
                "select title from sessions where id = 'fixture-session'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(after, INDEX_SCHEMA_VERSION);
        if source_version == 1 {
            assert_eq!(title.as_deref(), Some("fixture title"));
            let file_size: Option<i64> = connection
                .query_row(
                    "select file_size from sessions where id = 'fixture-session'",
                    [],
                    |row| row.get(0),
                )
                .unwrap();
            assert_eq!(file_size, None);
        }
        validate_index_columns(&connection).unwrap();
    }
}

#[test]
fn rejects_newer_and_malformed_index_schemas() {
    let mut newer = Connection::open_in_memory().unwrap();
    newer
        .pragma_update(None, "user_version", INDEX_SCHEMA_VERSION + 1)
        .unwrap();
    let error = migrate_index(&mut newer).unwrap_err();
    assert!(error
        .to_string()
        .contains("unsupported session index schema"));

    let mut malformed = Connection::open_in_memory().unwrap();
    malformed
        .execute_batch(
            "pragma user_version = 1;
             create table sessions (workspace_key text not null);",
        )
        .unwrap();
    let error = migrate_index(&mut malformed).unwrap_err();
    assert!(error.to_string().contains("malformed session index schema"));
}

#[test]
fn failed_index_migration_rolls_back_every_schema_change() {
    let mut connection = Connection::open_in_memory().unwrap();
    connection.execute_batch(INDEX_V0).unwrap();

    let error = migrate_index_with_hook(&mut connection, |_| {
        anyhow::bail!("injected migration failure")
    })
    .unwrap_err();

    assert!(error.to_string().contains("injected migration failure"));
    let version: u32 = connection
        .query_row("pragma user_version", [], |row| row.get(0))
        .unwrap();
    let mut statement = connection.prepare("pragma table_info(sessions)").unwrap();
    let columns = statement
        .query_map([], |row| row.get::<_, String>(1))
        .unwrap()
        .collect::<rusqlite::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(version, 0);
    assert!(!columns.iter().any(|column| column == "title"));
    assert!(!columns.iter().any(|column| column == "first_user_message"));
}

#[test]
fn open_index_creates_schema() {
    let root = TempDir::new().unwrap();
    let connection = open_index(root.path()).unwrap();
    let connection = connection.lock().unwrap();

    let table_count: i64 = connection
        .query_row(
            "select count(*) from sqlite_master where type = 'table' and name = 'sessions'",
            [],
            |row| row.get(0),
        )
        .unwrap();

    assert_eq!(table_count, 1);
    let version: u32 = connection
        .query_row("pragma user_version", [], |row| row.get(0))
        .unwrap();
    assert_eq!(version, INDEX_SCHEMA_VERSION);
    assert!(root.path().join("index.sqlite3").exists());
}

#[test]
fn matching_sessions_any_workspace_dedupes_same_path_across_cwds() {
    let root = TempDir::new().unwrap();
    let session_path = root.path().join("session.jsonl");
    fs::write(&session_path, "").unwrap();
    let path = session_path.to_string_lossy().to_string();
    let id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";

    {
        let connection = open_index(root.path()).unwrap();
        let connection = connection.lock().unwrap();
        // Same session file indexed under two workspace keys with different cwds.
        for (workspace_key, cwd, updated_at) in [
            ("ws-stale", "/tmp/old-workspace", 10_i64),
            ("ws-fresh", "/tmp/new-workspace", 20_i64),
        ] {
            connection
                .execute(
                    "insert into sessions (
                        workspace_key, cwd, id, path, created_at, updated_at, message_count
                     ) values (?1, ?2, ?3, ?4, 1, ?5, 0)",
                    params![workspace_key, cwd, id, path.as_str(), updated_at],
                )
                .unwrap();
        }
    }

    let matches = matching_sessions_any_workspace(root.path(), "aaaaaaaa").unwrap();
    assert_eq!(
        matches,
        vec![(session_path, PathBuf::from("/tmp/new-workspace"))],
        "one path with differing indexed cwds must resolve as a single match"
    );
}

// Covers: an index written by an older Rho (rollback journal) keeps its rows
// and is switched to WAL + synchronous=NORMAL for the per-save upsert.
// Owner: session index
#[test]
fn existing_rollback_journal_index_reopens_in_wal_with_rows_intact() {
    let root = TempDir::new().unwrap();
    let path = root.path().join("index.sqlite3");
    {
        let mut legacy = Connection::open(&path).unwrap();
        migrate_index(&mut legacy).unwrap();
        insert_placeholder_session(&legacy, "legacy-id");
        let mode: String = legacy
            .query_row("pragma journal_mode", [], |row| row.get(0))
            .unwrap();
        assert_eq!(mode, "delete");
    }

    let connection = open_index(root.path()).unwrap();
    let connection = connection.lock().unwrap();
    let settings: (String, i64, Vec<String>) = (
        connection
            .query_row("pragma journal_mode", [], |row| row.get(0))
            .unwrap(),
        connection
            .query_row("pragma synchronous", [], |row| row.get(0))
            .unwrap(),
        connection
            .prepare("select id from sessions")
            .unwrap()
            .query_map([], |row| row.get(0))
            .unwrap()
            .collect::<rusqlite::Result<_>>()
            .unwrap(),
    );
    assert_eq!(settings, ("wal".into(), 1, vec!["legacy-id".into()]));
}

// Covers: WAL sidecars hold uncheckpointed index rows, so they must be as
// private as the index itself.
// Owner: session index
#[cfg(unix)]
#[test]
fn index_and_wal_sidecars_are_owner_only() {
    use std::os::unix::fs::PermissionsExt;

    let root = TempDir::new().unwrap();
    let connection = open_index(root.path()).unwrap();
    insert_placeholder_session(&connection.lock().unwrap(), "id");

    let modes = ["index.sqlite3", "index.sqlite3-wal", "index.sqlite3-shm"].map(|name| {
        let mode = fs::metadata(root.path().join(name))
            .unwrap()
            .permissions()
            .mode();
        (name, mode & 0o777)
    });
    assert_eq!(
        modes,
        [
            ("index.sqlite3", 0o600),
            ("index.sqlite3-wal", 0o600),
            ("index.sqlite3-shm", 0o600),
        ]
    );
}