use agentstategraph::CommitOptions;
use agentstategraph_core::IntentCategory;
use agentstatedeveloper_core::{
AsgLedgerStore, Author, AuthorKind, Engine, LedgerEntry, LedgerKind, LedgerStore,
ledger_counts, missing_ledger_entries, paths, restore_missing_ledger_entries, sync_to_dir,
};
const KINDS: [LedgerKind; 4] = [
LedgerKind::Decision,
LedgerKind::Invariant,
LedgerKind::Proof,
LedgerKind::ValidationScenario,
];
fn author() -> Author {
Author {
kind: AuthorKind::Human,
id: "tester".into(),
}
}
fn seeded(n: usize) -> (tempfile::TempDir, Engine, Vec<LedgerEntry>) {
let dir = tempfile::tempdir().unwrap();
let engine = Engine::open_sqlite(&dir.path().join(".asd-state.db")).unwrap();
let ledger = AsgLedgerStore::from_engine(&engine);
let entries: Vec<_> = (0..n)
.map(|i| {
let e = LedgerEntry::new(
format!("sym_{}", i % 3),
KINDS[i % KINDS.len()],
format!("entry {i}"),
author(),
);
ledger.append_entry(&engine.ref_name, &e, "tester").unwrap();
e
})
.collect();
sync_to_dir(&engine.repo, &engine.ref_name, dir.path()).unwrap();
(dir, engine, entries)
}
fn lose_from_store(engine: &Engine, e: &LedgerEntry) {
for path in [
paths::ledger_entry_path(&e.symbol_id, &e.entry_id),
paths::ledger_entry_index_path(&e.entry_id),
] {
engine
.repo
.delete(
&engine.ref_name,
&path,
CommitOptions::new("test", IntentCategory::Refine, "simulate loss"),
)
.unwrap();
}
}
fn counts(dir: &tempfile::TempDir, engine: &Engine) -> agentstatedeveloper_core::LedgerCounts {
ledger_counts(
&engine.repo,
&engine.ref_name,
engine.fts.as_ref(),
Some(dir.path()),
)
.unwrap()
}
#[test]
fn consistent_store_reports_equal_counts_and_no_warning() {
let (dir, engine, _) = seeded(8);
let c = counts(&dir, &engine);
assert_eq!((c.cache, c.asg, c.sidecar), (Some(8), 8, Some(8)));
assert!(c.is_consistent());
assert_eq!(c.warning(), None);
}
#[test]
fn entry_lost_from_the_store_is_counted_and_warned() {
let (dir, engine, entries) = seeded(8);
lose_from_store(&engine, &entries[2]);
lose_from_store(&engine, &entries[5]);
let c = counts(&dir, &engine);
assert_eq!(c.cache, Some(8));
assert_eq!(c.asg, 6);
assert_eq!(c.missing_from_asg, 2);
let w = c.warning().expect("a lost entry must produce a warning");
assert!(
w.contains("2 ledger entries") && w.contains("asd repair --fix"),
"{w}"
);
sync_to_dir(&engine.repo, &engine.ref_name, dir.path()).unwrap();
assert_eq!(counts(&dir, &engine).missing_from_asg, 2);
}
#[test]
fn restore_puts_lost_entries_back_and_sync_exports_them() {
let (dir, engine, entries) = seeded(8);
lose_from_store(&engine, &entries[1]);
lose_from_store(&engine, &entries[6]);
let (missing, unparseable) = missing_ledger_entries(&engine).unwrap();
let mut ids: Vec<_> = missing.iter().map(|e| e.entry_id.clone()).collect();
ids.sort();
let mut want = vec![entries[1].entry_id.clone(), entries[6].entry_id.clone()];
want.sort();
assert_eq!(ids, want);
assert!(unparseable.is_empty());
let report = restore_missing_ledger_entries(&engine, "tester").unwrap();
assert_eq!((report.missing, report.restored), (2, 2));
assert!(report.failed.is_empty());
for e in [&entries[1], &entries[6]] {
let back: LedgerEntry = serde_json::from_value(
engine
.repo
.get_json(
&engine.ref_name,
&paths::ledger_entry_path(&e.symbol_id, &e.entry_id),
)
.unwrap(),
)
.unwrap();
assert_eq!(
serde_json::to_value(&back).unwrap(),
serde_json::to_value(e).unwrap()
);
assert_eq!(
engine
.repo
.get_json(
&engine.ref_name,
&paths::ledger_entry_index_path(&e.entry_id)
)
.unwrap(),
serde_json::json!(e.symbol_id)
);
}
sync_to_dir(&engine.repo, &engine.ref_name, dir.path()).unwrap();
let c = counts(&dir, &engine);
assert_eq!((c.cache, c.asg, c.sidecar), (Some(8), 8, Some(8)));
assert!(c.is_consistent());
let again = restore_missing_ledger_entries(&engine, "tester").unwrap();
assert_eq!((again.missing, again.restored), (0, 0));
}
#[test]
fn a_restore_lands_as_one_commit() {
let (_dir, engine, entries) = seeded(10);
for e in &entries[..6] {
lose_from_store(&engine, e);
}
let before = engine.repo.log(&engine.ref_name, 10_000).unwrap().len();
let report = restore_missing_ledger_entries(&engine, "tester").unwrap();
assert_eq!((report.missing, report.restored), (6, 6));
let log = engine.repo.log(&engine.ref_name, 10_000).unwrap();
assert_eq!(log.len() - before, 1, "one commit for the whole restore");
assert!(
log[0]
.intent
.description
.starts_with("restore 6 ledger entries"),
"{}",
log[0].intent.description
);
let (missing, _) = missing_ledger_entries(&engine).unwrap();
assert!(missing.is_empty(), "{} still missing", missing.len());
}
#[test]
fn rebound_entry_is_not_reported_missing() {
let (dir, engine, entries) = seeded(4);
let e = &entries[0];
let mut moved = e.clone();
moved.symbol_id = "sym_renamed".into();
let ledger = AsgLedgerStore::from_engine(&engine);
ledger
.append_entry(&engine.ref_name, &moved, "tester")
.unwrap();
engine
.repo
.delete(
&engine.ref_name,
&paths::ledger_entry_path(&e.symbol_id, &e.entry_id),
CommitOptions::new("test", IntentCategory::Refine, "rebind"),
)
.unwrap();
let c = counts(&dir, &engine);
assert_eq!(c.missing_from_asg, 0);
assert!(missing_ledger_entries(&engine).unwrap().0.is_empty());
}
#[test]
fn entries_not_yet_synced_are_reported_as_sidecar_lag() {
let (dir, engine, _) = seeded(3);
let ledger = AsgLedgerStore::from_engine(&engine);
let e = LedgerEntry::new("sym_new", LedgerKind::Decision, "after sync", author());
ledger.append_entry(&engine.ref_name, &e, "tester").unwrap();
let c = counts(&dir, &engine);
assert_eq!(c.missing_from_asg, 0);
assert_eq!(c.missing_from_sidecar, 1);
assert!(c.warning().unwrap().contains("asd sync"));
sync_to_dir(&engine.repo, &engine.ref_name, dir.path()).unwrap();
assert!(counts(&dir, &engine).is_consistent());
}