graphyn-store 0.2.0

Persistence layer for Graphyn — RocksDB graph storage and LRU cache
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use graphyn_core::graph::GraphynGraph;
use graphyn_core::ir::{
    Language, ReExportEntry, Relationship, RelationshipKind, Symbol, SymbolKind,
};
use graphyn_core::resolver::AliasResolver;
use graphyn_store::{GraphSnapshot, RocksGraphStore, StoreError};

fn temp_db_path(name: &str) -> PathBuf {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock must be after epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("graphyn-store-{name}-{now}"))
}

fn make_symbol(id: &str, name: &str, file: &str, kind: SymbolKind) -> Symbol {
    Symbol {
        id: id.to_string(),
        name: name.to_string(),
        kind,
        language: Language::TypeScript,
        file: file.to_string(),
        line_start: 1,
        line_end: 1,
        signature: None,
    }
}

fn make_graph() -> GraphynGraph {
    let mut graph = GraphynGraph::new();

    let model = make_symbol(
        "models/user_payload.ts::UserPayload::class",
        "UserPayload",
        "models/user_payload.ts",
        SymbolKind::Class,
    );
    let mapper = make_symbol(
        "mappers/view_model_mapper.ts::ViewModelMapper::class",
        "ViewModelMapper",
        "mappers/view_model_mapper.ts",
        SymbolKind::Class,
    );

    graph.add_symbol(model.clone());
    graph.add_symbol(mapper.clone());

    let relationships = vec![Relationship {
        from: mapper.id.clone(),
        to: model.id.clone(),
        kind: RelationshipKind::Imports,
        alias: Some("ResponseModel".to_string()),
        properties_accessed: vec!["userId".to_string(), "timestamp".to_string()],
        context: "import { UserPayload as ResponseModel } from '../models/user_payload'"
            .to_string(),
        file: "mappers/view_model_mapper.ts".to_string(),
        line: 1,
    }];

    for rel in &relationships {
        graph.add_relationship(rel);
    }

    let resolver = AliasResolver::default();
    resolver.ingest_relationships(&graph, &relationships);
    graph.file_reexports.insert(
        "barrel/index.ts".to_string(),
        vec![ReExportEntry {
            exported_name: "UserPayload".to_string(),
            source_module: "./models/user_payload".to_string(),
        }],
    );

    graph
}

#[test]
fn test_graph_snapshot_round_trip_preserves_symbols_and_edges() {
    let graph = make_graph();

    let snapshot = GraphSnapshot::from_graph(&graph).expect("snapshot created");
    let restored = snapshot.into_graph().expect("graph restored");

    assert_eq!(restored.symbols.len(), 2);
    assert_eq!(restored.graph.edge_count(), 1);

    let aliases = restored
        .alias_chains
        .get("models/user_payload.ts::UserPayload::class")
        .expect("alias chain exists");
    assert_eq!(aliases.len(), 1);
    assert_eq!(aliases[0].alias_name, "ResponseModel");
}

#[test]
fn test_rocksdb_store_save_then_load_graph() {
    let path = temp_db_path("save-load");
    {
        let store = RocksGraphStore::open(&path).expect("db open");
        let graph = make_graph();
        store.save_graph(&graph).expect("graph saved");
    }

    {
        let store = RocksGraphStore::open(&path).expect("db reopen");
        let restored = store.load_graph().expect("graph loaded");

        assert_eq!(restored.symbols.len(), 2);
        assert_eq!(restored.graph.edge_count(), 1);
        assert!(restored
            .symbols
            .contains_key("models/user_payload.ts::UserPayload::class"));
    }

    let _ = std::fs::remove_dir_all(&path);
}

#[test]
fn test_open_does_not_panic_on_windows_style_path() {
    let path = temp_db_path("windows-path-test");
    let store = RocksGraphStore::open(&path)
        .expect("store should open without mixed separator path errors on Windows");
    drop(store);
    let _ = std::fs::remove_dir_all(&path);
}

#[test]
fn test_load_graph_without_snapshot_returns_not_found() {
    let path = temp_db_path("missing");
    let store = RocksGraphStore::open(&path).expect("db open");

    let err = store.load_graph().expect_err("expected missing snapshot");
    assert!(matches!(err, StoreError::SnapshotNotFound));

    let _ = std::fs::remove_dir_all(&path);
}

#[test]
fn test_snapshot_round_trip_preserves_literal_backslash_sequences() {
    let graph = make_graph();

    let model_id = "models/user_payload.ts::UserPayload::class";
    {
        let mut model = graph
            .symbols
            .get_mut(model_id)
            .expect("model symbol exists");
        model.signature = Some("class UserPayload { note: \"\\\\t\\\\n\" }".to_string());
    }

    let path = temp_db_path("backslash-roundtrip");
    {
        let store = RocksGraphStore::open(&path).expect("db open");
        store.save_graph(&graph).expect("graph saved");
    }

    let restored = {
        let store = RocksGraphStore::open(&path).expect("db open");
        store.load_graph().expect("graph loaded")
    };
    let restored_sig = restored
        .symbols
        .get(model_id)
        .and_then(|s| s.signature.clone())
        .expect("restored signature exists");
    assert_eq!(restored_sig, "class UserPayload { note: \"\\\\t\\\\n\" }");

    let _ = std::fs::remove_dir_all(&path);
}

#[test]
fn test_snapshot_preserves_reexports() {
    let path = temp_db_path("reexports-roundtrip");
    {
        let store = RocksGraphStore::open(&path).expect("db open");
        let graph = make_graph();
        store.save_graph(&graph).expect("graph saved");
    }

    let restored = {
        let store = RocksGraphStore::open(&path).expect("db open");
        store.load_graph().expect("graph loaded")
    };

    let reexports = restored
        .file_reexports
        .get("barrel/index.ts")
        .expect("barrel re-exports should be restored");
    assert_eq!(
        reexports[0].source_module, "./models/user_payload",
        "restored barrel re-export source should match saved data"
    );

    let _ = std::fs::remove_dir_all(&path);
}