use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use agentstatedeveloper_core::{
AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, IndexStore, LedgerEntry, LedgerKind,
LedgerStore, Position, Symbol, SymbolKind, ledger_integrity::asg_ledger_entry_ids, sync_to_dir,
};
use tempfile::TempDir;
fn asd_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_asd"))
}
struct Sandbox {
root: TempDir,
decoy_db: PathBuf,
registry: PathBuf,
}
impl Sandbox {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let decoy = root.path().join("decoy");
std::fs::create_dir_all(&decoy).unwrap();
let decoy_db = decoy.join(".asd-state.db");
seed(&decoy_db, "sym_decoy", 1);
let registry = root.path().join("repos.toml");
std::fs::write(
®istry,
format!(
"[active]\nrepo = \"decoy\"\n\n[repos.decoy]\npath = \"{}\"\n",
decoy_db.display()
),
)
.unwrap();
Self {
root,
decoy_db,
registry,
}
}
fn dir(&self, name: &str) -> PathBuf {
let d = self.root.path().join(name);
std::fs::create_dir_all(&d).unwrap();
d
}
fn decoy_state(&self) -> (Vec<(String, String, Vec<u8>)>, i64) {
let c = rusqlite::Connection::open_with_flags(
&self.decoy_db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.unwrap();
let mut stmt = c
.prepare("SELECT namespace, name, target FROM refs ORDER BY namespace, name")
.unwrap();
let refs = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
let commits = c
.query_row("SELECT COUNT(*) FROM commits", [], |r| r.get(0))
.unwrap();
(refs, commits)
}
fn asd(&self, cwd: &Path, args: &[&str]) -> Output {
let out = Command::new(asd_bin())
.current_dir(cwd)
.args(args)
.env("HOME", self.root.path())
.env("ASD_REGISTRY", &self.registry)
.env_remove("ASD_DB")
.output()
.expect("spawn asd");
assert!(
out.status.success(),
"asd {args:?} failed\nstdout={}\nstderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
out
}
}
fn seed(db: &Path, symbol: &str, n: usize) -> Vec<String> {
let engine = Engine::open_sqlite(db).unwrap();
AsgIndexStore::from_engine(&engine)
.put_symbol(
&engine.ref_name,
&Symbol {
symbol_id: symbol.into(),
symbol_fp: format!("fp-{symbol}"),
qname: format!("pkg.{symbol}"),
language: "python".into(),
kind: SymbolKind::Function,
file: "pkg/mod.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
},
"tester",
)
.unwrap();
let ledger = AsgLedgerStore::from_engine(&engine);
(0..n)
.map(|i| {
let e = LedgerEntry::new(
symbol,
LedgerKind::Decision,
format!("{symbol} decision {i}"),
Author {
kind: AuthorKind::Human,
id: "tester".into(),
},
);
ledger.append_entry(&engine.ref_name, &e, "tester").unwrap();
e.entry_id
})
.collect()
}
fn sidecar_only_dir(sb: &Sandbox, name: &str) -> (PathBuf, Vec<String>) {
let src = sb.dir(&format!("{name}-src"));
let src_db = src.join(".asd-state.db");
let ids = seed(&src_db, "sym_proj", 3);
let dir = sb.dir(name);
let engine = Engine::open_sqlite(&src_db).unwrap();
sync_to_dir(&engine.repo, &engine.ref_name, &dir).unwrap();
assert!(!dir.join(".asd-state.db").exists());
(dir, ids)
}
fn ledger_ids(db: &Path) -> Vec<String> {
let e = Engine::open_sqlite(db).unwrap();
asg_ledger_entry_ids(&e.repo, &e.ref_name)
.into_iter()
.collect()
}
fn sorted(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
}
#[test]
fn hydrate_in_a_sidecar_only_dir_fills_a_store_there_not_the_active_repo() {
let sb = Sandbox::new();
let before = sb.decoy_state();
let (dir, ids) = sidecar_only_dir(&sb, "clone");
let out = sb.asd(&dir, &["hydrate"]);
assert_eq!(
sb.decoy_state(),
before,
"hydrate wrote into the registry's active repo"
);
let local = dir.join(".asd-state.db");
assert!(
local.exists(),
"hydrate did not create the store beside the sidecar"
);
assert_eq!(sorted(ledger_ids(&local)), sorted(ids));
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(
json["db"].as_str().unwrap().ends_with(".asd-state.db"),
"hydrate output must name the store it filled: {json}"
);
}
#[test]
fn hydrate_dir_without_db_fills_the_store_beside_that_sidecar() {
let sb = Sandbox::new();
let before = sb.decoy_state();
let (dir, ids) = sidecar_only_dir(&sb, "clone");
let elsewhere = sb.dir("elsewhere");
sb.asd(&elsewhere, &["hydrate", "--dir", dir.to_str().unwrap()]);
assert_eq!(
sb.decoy_state(),
before,
"hydrate wrote into the registry's active repo"
);
assert!(
!elsewhere.join(".asd-state.db").exists(),
"hydrate --dir filled a store in the directory it ran from"
);
assert_eq!(sorted(ledger_ids(&dir.join(".asd-state.db"))), sorted(ids));
}
#[test]
fn sync_from_a_subdirectory_writes_the_sidecar_beside_the_store() {
let sb = Sandbox::new();
let project = sb.dir("project");
seed(&project.join(".asd-state.db"), "sym_proj", 2);
let sub = sb.dir("project/src/deep");
sb.asd(&sub, &["sync"]);
assert!(
project.join(".asd/v1/ledger").is_dir(),
"sync did not write the sidecar at the project root"
);
assert!(
!sub.join(".asd").exists(),
"sync exported the project's store into a subdirectory"
);
}
#[test]
fn sync_outside_any_project_never_exports_the_active_repo_into_the_cwd() {
let sb = Sandbox::new();
let before = sb.decoy_state();
let stray = sb.dir("stray");
sb.asd(&stray, &["sync"]);
assert!(
!stray.join(".asd").exists(),
"sync exported the registry's active repo into an unrelated directory"
);
assert_eq!(sb.decoy_state(), before);
}
#[test]
fn index_from_a_parent_directory_uses_the_projects_store() {
let sb = Sandbox::new();
let parent = sb.dir("apps");
let project = parent.join("proj");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
project.join("rates.py"),
"def fetch_rates():\n return 1\n",
)
.unwrap();
let project_db = project.join(".asd-state.db");
seed(&project_db, "sym_existing", 1);
let out = sb.asd(&parent, &["index", "proj"]);
assert!(
!parent.join(".asd-state.db").exists(),
"index built a stray store in the parent"
);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(
Path::new(summary["db"].as_str().unwrap()),
project_db.canonicalize().unwrap(),
"{summary}"
);
let engine = Engine::open_sqlite(&project_db).unwrap();
let qnames = engine
.repo
.get_json(&engine.ref_name, "/asd/v1/index/by-qname")
.unwrap();
assert!(
qnames
.as_object()
.unwrap()
.keys()
.any(|q| q.ends_with("fetch_rates")),
"the project's store was not indexed: {qnames}"
);
}