ctx-graph 0.2.5

Graph and memory storage layer for CTX
Documentation
use ctx_graph::GraphStore;
use tempfile::tempdir;

#[test]
fn upsert_symbol_and_query_by_term() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    let symbol_id = store
        .upsert_symbol(
            "src/auth.rs",
            "validate_refresh_token",
            "function",
            "fn validate_refresh_token()",
        )
        .expect("symbol");

    assert!(symbol_id > 0);
    let hits = store.search_symbols("refresh").expect("search");
    assert!(hits.iter().any(|h| h.name == "validate_refresh_token"));
}

#[test]
fn link_symbols_and_list_neighbors() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    let src = store
        .upsert_symbol(
            "src/auth.rs",
            "decode_token",
            "function",
            "fn decode_token()",
        )
        .expect("src");
    let dst = store
        .upsert_symbol(
            "src/auth.rs",
            "validate_refresh_token",
            "function",
            "fn validate_refresh_token()",
        )
        .expect("dst");

    store.link_symbols(src, dst, "calls", None).expect("link");

    let neighbors = store
        .related_symbols("decode_token", 10)
        .expect("neighbors");
    assert!(neighbors.iter().any(|n| n.name == "validate_refresh_token"));
}

#[test]
fn snippet_fts_search_returns_relevant_snippet() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    store
        .upsert_symbol(
            "src/auth.rs",
            "decode_token",
            "function",
            "fn decode_token()",
        )
        .expect("symbol");
    store
        .add_snippet(
            "src/auth.rs",
            Some("decode_token"),
            "decode token and validate audience",
        )
        .expect("snippet");

    let hits = store.search_snippets("decode", 10).expect("fts");
    assert!(!hits.is_empty());
    assert!(hits[0].content.contains("decode token"));
}

#[test]
fn record_failure_and_recent_decisions_are_queryable() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    let run_id = store.record_run("pytest -q", "failed").expect("run");
    store
        .record_failure(run_id, "traceback in auth", Some("decode token"))
        .expect("failure");
    store
        .record_decision("auth-fix", "keep public signature stable")
        .expect("decision");

    let failures = store.recent_failures(10).expect("failures");
    let decisions = store.recent_decisions(10).expect("decisions");

    assert!(failures.iter().any(|f| f.message.contains("auth")));
    assert!(decisions.iter().any(|d| d.contains("auth-fix")));
}

#[test]
fn invocation_runs_persist_full_metadata() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    let run_id = store
        .record_invocation_run(&ctx_graph::RunInsert {
            command: "/ctx-pack \"fix auth\"".to_string(),
            status: "succeeded".to_string(),
            agent: Some("opencode".to_string()),
            exit_code: Some(0),
            duration_ms: Some(42),
            original_tokens: Some(1200),
            packed_tokens: Some(240),
            reduction_pct: Some(80.0),
            fallback_used: false,
            pack_path: Some(".ctx/packs/123.json".to_string()),
        })
        .expect("record run");

    assert!(run_id > 0);
    let runs = store.recent_runs(5).expect("recent runs");
    assert_eq!(runs[0].agent.as_deref(), Some("opencode"));
    assert_eq!(runs[0].status, "succeeded");
    assert_eq!(runs[0].packed_tokens, Some(240));
    assert!(!runs[0].fallback_used);
}

#[test]
fn init_schema_migrates_existing_runs_table() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    {
        let conn = rusqlite::Connection::open(&db).expect("open raw");
        conn.execute_batch(
            "CREATE TABLE runs (
              id INTEGER PRIMARY KEY,
              task_id INTEGER,
              command TEXT NOT NULL,
              status TEXT NOT NULL,
              created_at TEXT DEFAULT CURRENT_TIMESTAMP
            );",
        )
        .expect("old schema");
    }

    let store = GraphStore::open(&db).expect("open store");
    store.init_schema().expect("migrate schema");
    store
        .record_invocation_run(&ctx_graph::RunInsert {
            command: "/ctx-ask \"review\"".to_string(),
            status: "fallback".to_string(),
            agent: Some("opencode".to_string()),
            exit_code: None,
            duration_ms: Some(1),
            original_tokens: Some(100),
            packed_tokens: Some(20),
            reduction_pct: Some(80.0),
            fallback_used: true,
            pack_path: None,
        })
        .expect("record after migrate");

    let runs = store.recent_runs(1).expect("recent");
    assert_eq!(runs[0].agent.as_deref(), Some("opencode"));
    assert!(runs[0].fallback_used);
}

#[test]
fn memory_directives_support_crud_and_search() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    let id = store
        .upsert_memory_directive(
            "testing.always_run",
            "Every change must run targeted tests before completion.",
            "project",
            "manual",
        )
        .expect("upsert");
    assert!(id > 0);

    let loaded = store
        .get_memory_directive("testing.always_run")
        .expect("get")
        .expect("existing");
    assert_eq!(loaded.scope, "project");
    assert_eq!(loaded.source, "manual");

    store
        .upsert_memory_directive(
            "testing.always_run",
            "Every change must run targeted and smoke tests before completion.",
            "project",
            "model",
        )
        .expect("update");

    let hits = store
        .search_memory_directives("smoke tests completion", 10)
        .expect("search");
    assert!(!hits.is_empty());
    assert_eq!(hits[0].key, "testing.always_run");

    let all = store
        .list_memory_directives(Some("project"), 10)
        .expect("list");
    assert!(all.iter().any(|d| d.key == "testing.always_run"));

    let deleted = store
        .delete_memory_directive("testing.always_run")
        .expect("delete");
    assert!(deleted);
    assert!(
        store
            .get_memory_directive("testing.always_run")
            .expect("reload")
            .is_none()
    );
}

#[test]
fn exact_symbol_lookup_returns_only_exact_matches() {
    let dir = tempdir().expect("tempdir");
    let db = dir.path().join("graph.db");
    let store = GraphStore::open(&db).expect("open");
    store.init_schema().expect("schema");

    store
        .upsert_symbol(
            "src/auth.rs",
            "decode_token",
            "function",
            "fn decode_token()",
        )
        .expect("decode");
    store
        .upsert_symbol(
            "src/auth.rs",
            "decode_token_strict",
            "function",
            "fn decode_token_strict()",
        )
        .expect("decode strict");

    let hits = store
        .find_symbols_by_exact_name("decode_token", 10)
        .expect("exact lookup");

    assert_eq!(hits.len(), 1);
    assert_eq!(hits[0].name, "decode_token");
}