asobi 0.6.1

A persistent, project-local knowledge graph CLI for AI agents.
Documentation
use asobi::api::{
    BackupRequest, BackupStore, GraphStore, MaintenanceStore, OpenNodes, PurgeRequest, SearchQuery,
    SearchStore, SkillRecord, SkillStore, SnapshotStore, TaskStore,
};
use asobi::model::{EntityInput, RelationInput};
use asobi::storage::SqliteStore;
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;

fn store() -> (tempfile::TempDir, SqliteStore) {
    let dir = tempdir().unwrap();
    let db = dir.path().join("contract.db");
    let store = SqliteStore::open_at(&db).unwrap();
    (dir, store)
}

#[test]
fn sqlite_implements_the_v2_contract() {
    let (_dir, store) = store();
    let capabilities = store.capabilities().unwrap();
    assert_eq!(capabilities.backend, "sqlite");
    assert_eq!(capabilities.keyword_search_kind, "fts5");
    assert!(capabilities.multi_process);
    assert!(capabilities.physical_backup);
}

#[test]
fn graph_truth_search_and_task_claim_are_atomic_surfaces() {
    let (_dir, store) = store();
    store
        .create_entities(vec![EntityInput {
            name: "project:asobi".into(),
            entity_type: "project".into(),
            observations: vec!["SQLite FTS5 supports concurrent agent recall".into()],
        }])
        .unwrap();
    store
        .create_entities(vec![EntityInput {
            name: "asobi:task-1".into(),
            entity_type: "task".into(),
            observations: vec![],
        }])
        .unwrap();
    store
        .create_relations(vec![RelationInput {
            from: "asobi:task-1".into(),
            to: "project:asobi".into(),
            relation_type: "part_of".into(),
        }])
        .unwrap();
    store
        .truth_upsert("asobi:task-1", "status", "READY_TO_DISPATCH")
        .unwrap();
    store
        .truth_upsert("project:asobi", "status", "ACTIVE")
        .unwrap();

    let graph = store
        .search_nodes(SearchQuery {
            query: "concurrent recall".into(),
            limit: 10,
            filters: vec![],
        })
        .unwrap();
    assert_eq!(graph.entities[0].name, "project:asobi");

    let filtered = store
        .search_nodes(SearchQuery {
            query: "concurrent".into(),
            limit: 10,
            filters: vec![("status".into(), "ACTIVE".into())],
        })
        .unwrap();
    assert_eq!(filtered.entities.len(), 1);
    assert_eq!(filtered.entities[0].name, "project:asobi");

    assert_eq!(
        store.claim_next("agent-a").unwrap().as_deref(),
        Some("asobi:task-1")
    );
    assert_eq!(store.claim_next("agent-b").unwrap(), None);
}

#[test]
fn graph_and_search_keep_observations_and_skill_bodies_lazy() {
    let (_dir, store) = store();
    store
        .upsert_skill(SkillRecord {
            entity_name: "skill:lean-read".into(),
            body: "heavy skill instructions".into(),
            source: "local".into(),
            version: "test".into(),
            description: "lean read regression".into(),
        })
        .unwrap();
    store
        .add_observations(
            vec![asobi::model::ObservationInput {
                entity_name: "skill:lean-read".into(),
                contents: vec!["heavy observation".into()],
            }],
            200,
        )
        .unwrap();

    let lean = store.read_graph().unwrap();
    let entity = &lean.entities[0];
    assert_eq!(entity.observation_count, 1);
    assert!(entity.observations.is_empty());
    assert!(entity.body.is_none());
    assert!(entity.observations_detailed.is_none());
    let lean_json = serde_json::to_value(&lean).unwrap();
    assert!(
        !lean_json["entities"][0]
            .as_object()
            .unwrap()
            .contains_key("body")
    );
    assert!(
        !lean_json["entities"][0]
            .as_object()
            .unwrap()
            .contains_key("observations")
    );
    assert!(
        !lean_json["entities"][0]
            .as_object()
            .unwrap()
            .contains_key("observationsDetailed")
    );

    let search = store
        .search_nodes(SearchQuery {
            query: "heavy observation".into(),
            limit: 10,
            filters: vec![],
        })
        .unwrap();
    let entity = &search.entities[0];
    assert_eq!(entity.observation_count, 1);
    assert!(entity.observations.is_empty());
    assert!(entity.body.is_none());
    assert!(entity.observations_detailed.is_none());

    let full = store
        .open_nodes(OpenNodes {
            names: vec!["skill:lean-read".into()],
            with_ids: true,
            expand: vec![],
        })
        .unwrap();
    let entity = &full.entities[0];
    assert_eq!(entity.observations, vec!["heavy observation"]);
    assert_eq!(entity.body.as_deref(), Some("heavy skill instructions"));
    assert_eq!(entity.observations_detailed.as_ref().unwrap().len(), 1);

    let exported = store.read_graph_full().unwrap();
    assert_eq!(exported.entities[0].observations, vec!["heavy observation"]);
    assert_eq!(
        exported.entities[0].body.as_deref(),
        Some("heavy skill instructions")
    );
}

#[test]
fn purge_is_preview_first_and_rejects_durable_types() {
    let (dir, store) = store();
    store
        .create_entities(vec![
            EntityInput {
                name: "project:session".into(),
                entity_type: "session".into(),
                observations: vec!["old session note".into()],
            },
            EntityInput {
                name: "project:task".into(),
                entity_type: "task".into(),
                observations: vec!["old task note".into()],
            },
            EntityInput {
                name: "project:concept".into(),
                entity_type: "concept".into(),
                observations: vec!["durable note".into()],
            },
        ])
        .unwrap();
    store
        .truth_upsert("project:session", "status", "DONE")
        .unwrap();
    store
        .truth_upsert("project:task", "status", "DONE")
        .unwrap();

    let db = dir.path().join("contract.db");
    let conn = Connection::open(db).unwrap();
    conn.execute_batch(
        "UPDATE asobi_entities SET created_at = datetime('now', '-90 days');
         UPDATE asobi_observations SET created_at = datetime('now', '-90 days');
         UPDATE asobi_truths SET updated_at = datetime('now', '-90 days');",
    )
    .unwrap();
    drop(conn);

    let request = PurgeRequest {
        entity_types: vec!["session".into(), "task".into()],
        statuses: vec!["DONE".into()],
        older_than_days: 30,
        apply: false,
    };
    let preview = store.purge(request.clone()).unwrap();
    assert!(preview.dry_run);
    assert_eq!(preview.deleted, 0);
    assert_eq!(preview.candidates.len(), 2);
    assert!(
        store
            .open_nodes(OpenNodes {
                names: vec!["project:task".into()],
                ..Default::default()
            })
            .unwrap()
            .entities
            .len()
            == 1
    );

    let applied = store
        .purge(PurgeRequest {
            apply: true,
            ..request
        })
        .unwrap();
    assert!(!applied.dry_run);
    assert_eq!(applied.deleted, 2);
    assert!(
        store
            .open_nodes(OpenNodes {
                names: vec!["project:task".into()],
                ..Default::default()
            })
            .unwrap()
            .entities
            .is_empty()
    );
    assert!(
        store
            .search_nodes(SearchQuery {
                query: "old task note".into(),
                limit: 10,
                filters: vec![],
            })
            .unwrap()
            .entities
            .is_empty()
    );
    assert_eq!(store.read_graph().unwrap().entities.len(), 1);

    let error = store
        .purge(PurgeRequest {
            entity_types: vec!["concept".into()],
            statuses: vec!["DONE".into()],
            older_than_days: 30,
            apply: true,
        })
        .unwrap_err();
    assert!(
        error
            .to_string()
            .contains("restricted to operational entity types")
    );

    let error = store
        .purge(PurgeRequest {
            entity_types: vec!["task".into()],
            statuses: vec!["IN_PROGRESS".into()],
            older_than_days: 30,
            apply: false,
        })
        .unwrap_err();
    assert!(error.to_string().contains("only accepts terminal statuses"));
}

#[test]
fn snapshot_and_physical_backup_are_supported() {
    let (dir, live_store) = store();
    live_store
        .create_entities(vec![EntityInput {
            name: "snapshot:test".into(),
            entity_type: "concept".into(),
            observations: vec!["portable graph state".into()],
        }])
        .unwrap();
    let snapshot = live_store.export_snapshot(&[], false).unwrap();
    assert_eq!(snapshot.source_backend, "sqlite");
    assert_eq!(snapshot.graph.entities.len(), 1);

    let backup = dir.path().join("backup.db");
    let receipt = live_store
        .backup(BackupRequest {
            destination: backup.clone(),
            keep: 1,
        })
        .unwrap();
    assert_eq!(receipt.path, backup);
    assert!(backup.exists());
}

#[test]
fn managed_backup_retention_prunes_old_snapshots() {
    let (dir, store) = store();
    store
        .create_entities(vec![EntityInput {
            name: "retention:test".into(),
            entity_type: "concept".into(),
            observations: vec!["retention data".into()],
        }])
        .unwrap();

    for _ in 0..3 {
        store
            .backup(BackupRequest {
                destination: PathBuf::new(),
                keep: 2,
            })
            .unwrap();
    }

    let backups = fs::read_dir(dir.path().join("backups"))
        .unwrap()
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.file_name().to_string_lossy().starts_with("asobi-"))
        .count();
    assert_eq!(backups, 2);
}

#[test]
fn restore_rejects_non_asobi_sqlite_and_removes_sidecars() {
    let (dir, live_store) = store();
    live_store
        .create_entities(vec![EntityInput {
            name: "restore:test".into(),
            entity_type: "concept".into(),
            observations: vec!["restore data".into()],
        }])
        .unwrap();

    let source = dir.path().join("source.db");
    live_store
        .backup(BackupRequest {
            destination: source.clone(),
            keep: 1,
        })
        .unwrap();
    let live = dir.path().join("contract.db");
    fs::write(format!("{}-wal", live.display()), b"stale wal").unwrap();
    fs::write(format!("{}-shm", live.display()), b"stale shm").unwrap();
    live_store.restore(source, true).unwrap();
    assert!(!PathBuf::from(format!("{}-wal", live.display())).exists());
    assert!(!PathBuf::from(format!("{}-shm", live.display())).exists());

    let invalid_source = dir.path().join("invalid.db");
    let invalid = Connection::open(&invalid_source).unwrap();
    invalid
        .execute("CREATE TABLE unrelated (value TEXT)", [])
        .unwrap();
    drop(invalid);
    let (_invalid_dir, invalid_store) = store();
    let error = invalid_store.restore(invalid_source, true).unwrap_err();
    assert!(error.to_string().contains("not an Asobi SQLite database"));
}