use crate::datatypes::Value;
use crate::graph::dir_graph::DirGraph;
use crate::graph::mutation::extend::extend_graph;
use crate::graph::storage::{GraphRead, GraphWrite};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
#[allow(clippy::too_many_arguments)]
pub fn archive_and_build(
src_dir: &Path,
rev: &str,
repo_root: Option<&Path>,
verbose: bool,
include_tests: bool,
save_to: Option<&Path>,
max_loc_per_file: Option<usize>,
include_docs: bool,
) -> Result<Arc<DirGraph>, String> {
let repo_root = match repo_root {
Some(r) => r.to_path_buf(),
None => resolve_repo_root(src_dir)?,
};
let sha = verify_rev(&repo_root, rev)?;
let tmp = tempfile::Builder::new()
.prefix("kglite-rev-")
.tempdir()
.map_err(|e| format!("could not create tempdir: {}", e))?;
let graph = archive_and_build_into(
&repo_root,
rev,
src_dir,
tmp.path(),
verbose,
include_tests,
save_to,
max_loc_per_file,
include_docs,
)?;
stamp_rev_provenance(graph, rev, &sha, &repo_root)
}
#[allow(clippy::too_many_arguments)]
fn archive_and_build_into(
repo_root: &Path,
rev: &str,
src_dir: &Path,
snapshot_dir: &Path,
verbose: bool,
include_tests: bool,
save_to: Option<&Path>,
max_loc_per_file: Option<usize>,
include_docs: bool,
) -> Result<Arc<DirGraph>, String> {
archive_into(repo_root, rev, snapshot_dir)?;
let build_input = rebase_input(src_dir, repo_root, snapshot_dir);
crate::code_tree::builder::run_with_options(
&build_input,
verbose,
include_tests,
save_to,
max_loc_per_file,
include_docs,
)
}
fn resolve_repo_root(src_dir: &Path) -> Result<PathBuf, String> {
let out = Command::new("git")
.arg("-C")
.arg(src_dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| format!("git command failed: {}", e))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"not a git repository: {} (pass repo_root= if the git root is elsewhere){}",
src_dir.display(),
if stderr.is_empty() {
String::new()
} else {
format!(" — git said: {}", stderr)
}
));
}
let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(PathBuf::from(root))
}
fn verify_rev(repo_root: &Path, rev: &str) -> Result<String, String> {
let out = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["rev-parse", "--verify"])
.arg(format!("{}^{{commit}}", rev))
.output()
.map_err(|e| format!("git command failed: {}", e))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"could not resolve git revision {:?} in {}: {}",
rev,
repo_root.display(),
if stderr.is_empty() {
"unknown revision".to_string()
} else {
stderr
}
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn archive_into(repo_root: &Path, rev: &str, dest: &Path) -> Result<(), String> {
let mut archive = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["archive", "--format=tar"])
.arg(rev)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("git archive failed to start: {}", e))?;
let archive_stdout = archive
.stdout
.take()
.ok_or_else(|| "git archive produced no stdout".to_string())?;
let tar = Command::new("tar")
.arg("-x")
.arg("-C")
.arg(dest)
.stdin(Stdio::from(archive_stdout))
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("tar failed to start: {}", e))?;
let tar_out = tar
.wait_with_output()
.map_err(|e| format!("tar wait failed: {}", e))?;
let archive_out = archive
.wait_with_output()
.map_err(|e| format!("git archive wait failed: {}", e))?;
if !archive_out.status.success() {
return Err(format!(
"git archive failed: {}",
String::from_utf8_lossy(&archive_out.stderr).trim()
));
}
if !tar_out.status.success() {
return Err(format!(
"tar extract failed: {}",
String::from_utf8_lossy(&tar_out.stderr).trim()
));
}
Ok(())
}
fn rebase_input(src_dir: &Path, repo_root: &Path, snapshot: &Path) -> PathBuf {
let src = src_dir
.canonicalize()
.unwrap_or_else(|_| src_dir.to_path_buf());
let root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
match src.strip_prefix(&root) {
Ok(rel) if !rel.as_os_str().is_empty() => snapshot.join(rel),
_ => snapshot.to_path_buf(),
}
}
fn stamp_rev_provenance(
mut graph: Arc<DirGraph>,
rev: &str,
sha: &str,
repo_root: &Path,
) -> Result<Arc<DirGraph>, String> {
let short = &sha[..sha.len().min(12)];
let text = format!(
"Built from git revision '{rev}' ({short}) of {}. Reflects committed \
content at that revision, not the current working tree.",
repo_root.display()
);
let g = Arc::get_mut(&mut graph)
.ok_or_else(|| "graph not uniquely owned when stamping rev provenance".to_string())?;
g.set_instructions(&text, None);
Ok(graph)
}
pub fn dedup_revs(revs: &[String]) -> Vec<String> {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
revs.iter()
.filter(|r| seen.insert(r.as_str()))
.cloned()
.collect()
}
type NodeRevManifest = HashMap<(String, String), (Vec<Value>, Vec<Value>)>;
type EdgeRevManifest = HashMap<(String, String, String), Vec<Value>>;
fn fingerprint_fields(node_type: &str) -> &'static [&'static str] {
match node_type {
"Function" => &["signature", "visibility", "loc_span"],
"Class" | "Mixin" | "Trait" | "Protocol" | "Interface" => &["visibility", "loc_span"],
"Struct" => &["visibility", "fields", "loc_span"],
"Enum" => &["visibility", "variants", "loc_span"],
"Constant" => &["visibility", "value_preview"],
_ => &[],
}
}
fn node_fingerprint(node_type: &str, props: &HashMap<String, Value>) -> i64 {
let fields = fingerprint_fields(node_type);
if fields.is_empty() {
return 0;
}
let mut hasher = DefaultHasher::new();
for field in fields {
field.hash(&mut hasher);
if *field == "loc_span" {
let span = match (props.get("end_line"), props.get("line_number")) {
(Some(Value::Int64(end)), Some(Value::Int64(line))) => Some(end - line),
_ => None,
};
span.hash(&mut hasher);
} else {
match props.get(*field) {
Some(v) => v.to_string().hash(&mut hasher),
None => 0u8.hash(&mut hasher),
}
}
}
hasher.finish() as i64
}
#[allow(clippy::too_many_arguments)]
pub fn build_code_tree_revs(
src_dir: &Path,
revs: &[String],
repo_root: Option<&Path>,
verbose: bool,
include_tests: bool,
save_to: Option<&Path>,
max_loc_per_file: Option<usize>,
include_docs: bool,
) -> Result<Arc<DirGraph>, String> {
if revs.is_empty() {
return Err("build_code_tree_revs requires at least one revision".to_string());
}
let revs = dedup_revs(revs);
let revs = revs.as_slice();
let repo_root = match repo_root {
Some(r) => r.to_path_buf(),
None => resolve_repo_root(src_dir)?,
};
let tmp = tempfile::Builder::new()
.prefix("kglite-revs-")
.tempdir()
.map_err(|e| format!("could not create tempdir: {}", e))?;
let snapshot = tmp.path().join("snapshot");
let mut node_revs: NodeRevManifest = HashMap::new();
let mut edge_revs: EdgeRevManifest = HashMap::new();
let mut sha_of_rev: Vec<(String, String)> = Vec::with_capacity(revs.len());
let mut accumulator: Option<Arc<DirGraph>> = None;
for rev in revs {
let sha = verify_rev(&repo_root, rev)?;
sha_of_rev.push((rev.clone(), sha));
if snapshot.exists() {
std::fs::remove_dir_all(&snapshot)
.map_err(|e| format!("could not clear snapshot dir: {}", e))?;
}
std::fs::create_dir_all(&snapshot)
.map_err(|e| format!("could not create snapshot dir: {}", e))?;
let rev_graph = archive_and_build_into(
&repo_root,
rev,
src_dir,
&snapshot,
verbose,
include_tests,
None, max_loc_per_file,
include_docs,
)?;
record_rev_manifest(&rev_graph, rev, &mut node_revs, &mut edge_revs);
match accumulator.as_mut() {
None => accumulator = Some(rev_graph), Some(acc) => {
let target = Arc::get_mut(acc)
.ok_or_else(|| "accumulator not uniquely owned during merge".to_string())?;
extend_graph(target, &rev_graph, Some("update".to_string()))?;
}
}
}
let mut graph = accumulator.expect("revs is non-empty");
{
let g = Arc::get_mut(&mut graph)
.ok_or_else(|| "merged graph not uniquely owned when stamping revs".to_string())?;
stamp_node_revs(g, &node_revs)?;
stamp_edge_revs(g, &edge_revs);
}
if let Some(dest) = save_to {
crate::graph::io::file::prepare_save(&mut graph);
Arc::make_mut(&mut graph).enable_columnar();
let dest_str = dest.to_string_lossy();
crate::graph::io::file::write_kgl(&graph, &dest_str).map_err(|e| e.to_string())?;
}
stamp_rev_provenance_multi(graph, &sha_of_rev, &repo_root)
}
fn record_rev_manifest(
rev_graph: &DirGraph,
rev: &str,
node_revs: &mut NodeRevManifest,
edge_revs: &mut EdgeRevManifest,
) {
let rev_val = Value::String(rev.to_string());
for idx in rev_graph.graph.node_indices() {
let Some(node) = rev_graph.graph.node_weight(idx) else {
continue;
};
let node_type = node.node_type_str(&rev_graph.interner).to_string();
let id = node.id().to_string();
let props = node.properties_cloned(&rev_graph.interner);
let fp = node_fingerprint(&node_type, &props);
let entry = node_revs.entry((node_type, id)).or_default();
entry.0.push(rev_val.clone());
entry.1.push(Value::Int64(fp));
}
for eidx in rev_graph.graph.edge_indices() {
let Some(edge) = rev_graph.graph.edge_weight(eidx) else {
continue;
};
let Some((s, t)) = rev_graph.graph.edge_endpoints(eidx) else {
continue;
};
let (Some(sn), Some(tn)) = (
rev_graph.graph.node_weight(s),
rev_graph.graph.node_weight(t),
) else {
continue;
};
let conn = edge.connection_type_str(&rev_graph.interner).to_string();
let key = (conn, sn.id().to_string(), tn.id().to_string());
let revs = edge_revs.entry(key).or_default();
if revs.last() != Some(&rev_val) {
revs.push(rev_val.clone());
}
}
}
fn stamp_node_revs(graph: &mut DirGraph, node_revs: &NodeRevManifest) -> Result<(), String> {
let revs_key = graph.interner.get_or_intern("revs");
let fp_key = graph.interner.get_or_intern("rev_fp");
let mut updates: Vec<(_, Value, Value)> = Vec::new();
let mut types_touched: std::collections::HashSet<String> = std::collections::HashSet::new();
for idx in graph.graph.node_indices() {
let Some(node) = graph.graph.node_weight(idx) else {
continue;
};
let node_type = node.node_type_str(&graph.interner).to_string();
let id = node.id().to_string();
if let Some((revs, fps)) = node_revs.get(&(node_type.clone(), id)) {
updates.push((idx, Value::List(revs.clone()), Value::List(fps.clone())));
types_touched.insert(node_type);
}
}
for node_type in &types_touched {
if let Some(existing) = graph.type_schemas.get(node_type).cloned() {
let mut merged = (*existing).clone();
merged.add_key(revs_key);
merged.add_key(fp_key);
graph
.type_schemas
.insert(node_type.clone(), Arc::new(merged));
}
let mut meta = HashMap::new();
meta.insert("revs".to_string(), "List".to_string());
meta.insert("rev_fp".to_string(), "List".to_string());
graph.upsert_node_type_metadata(node_type, meta);
}
for (idx, revs_val, fp_val) in updates {
if let Some(node) = graph.graph.node_weight_mut(idx) {
node.properties.insert(revs_key, revs_val);
node.properties.insert(fp_key, fp_val);
}
}
Ok(())
}
fn stamp_edge_revs(graph: &mut DirGraph, edge_revs: &EdgeRevManifest) {
let revs_key = graph.interner.get_or_intern("revs");
let mut updates = Vec::new();
for eidx in graph.graph.edge_indices() {
let Some(edge) = graph.graph.edge_weight(eidx) else {
continue;
};
let Some((s, t)) = graph.graph.edge_endpoints(eidx) else {
continue;
};
let (Some(sn), Some(tn)) = (graph.graph.node_weight(s), graph.graph.node_weight(t)) else {
continue;
};
let conn = edge.connection_type_str(&graph.interner).to_string();
let key = (conn, sn.id().to_string(), tn.id().to_string());
if let Some(revs) = edge_revs.get(&key) {
updates.push((eidx, Value::List(revs.clone())));
}
}
for (eidx, revs_val) in updates {
if let Some(edge) = graph.graph.edge_weight_mut(eidx) {
match edge.properties.iter_mut().find(|(k, _)| *k == revs_key) {
Some(slot) => slot.1 = revs_val,
None => edge.properties.push((revs_key, revs_val)),
}
}
}
}
fn stamp_rev_provenance_multi(
mut graph: Arc<DirGraph>,
revs: &[(String, String)],
repo_root: &Path,
) -> Result<Arc<DirGraph>, String> {
let labels: Vec<String> = revs
.iter()
.map(|(rev, sha)| format!("{} ({})", rev, &sha[..sha.len().min(12)]))
.collect();
let text = if revs.len() == 1 {
let (rev, sha) = &revs[0];
format!(
"Code graph of {} at revision '{}' ({}). Reflects committed content at \
that revision, not the current working tree. Every entity carries \
`revs: [str]` (this single rev) + `rev_fp: [int]` (per-rev shape hash).",
repo_root.display(),
rev,
&sha[..sha.len().min(12)],
)
} else {
let newest = revs.last().map(|(rev, _)| rev.as_str()).unwrap_or("");
format!(
"Multi-rev code graph of {}, spanning {} revisions (oldest → newest): \
{}. One node per entity across revs; every node carries `revs: [str]` \
(revs it appears in) + `rev_fp: [int]` (per-rev shape hash), every edge \
carries `revs: [str]`. Ordinary properties (signature, value_preview, …) \
report the NEWEST rev ('{newest}') an entity appears in. UNSCOPED queries \
span ALL revs (e.g. `MATCH (n:Function) RETURN count(n)` over-counts) — \
scope with `WHERE '<rev>' IN n.revs` (head only: `WHERE '{newest}' IN \
n.revs`). For deltas between two revs use \
`CALL rev_diff({{from: '<rev>', to: '<rev>'}}) YIELD bucket, type, \
qualified_name, name, file, line` (added / removed / changed).",
repo_root.display(),
revs.len(),
labels.join(", "),
)
};
let g = Arc::get_mut(&mut graph)
.ok_or_else(|| "graph not uniquely owned when stamping multi-rev provenance".to_string())?;
g.set_instructions(&text, None);
Ok(graph)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::storage::interner::InternedKey;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(dir: &Path, body: &str) -> String {
std::fs::write(dir.join("m.py"), body).unwrap();
git(dir, &["add", "-A"]);
git(
dir,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"c",
],
);
git(dir, &["rev-parse", "HEAD"])
}
fn fixture() -> (tempfile::TempDir, [String; 3]) {
let tmp = tempfile::Builder::new()
.prefix("kglite-revtest-")
.tempdir()
.unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let s1 = commit(
dir,
"def foo(a):\n return a + 1\n\n\ndef gone():\n return 0\n",
);
let s2 = commit(
dir,
"def foo(a):\n return bar(a)\n\n\ndef bar(x):\n return x + 1\n",
);
let s3 = commit(
dir,
"def foo(a, b):\n return bar(a) + b\n\n\ndef bar(x):\n return x + 1\n",
);
(tmp, [s1, s2, s3])
}
fn build(dir: &Path, revs: &[String]) -> Arc<DirGraph> {
build_code_tree_revs(dir, revs, Some(dir), false, false, None, None, false)
.expect("build_code_tree_revs")
}
fn fn_revs(graph: &DirGraph, name: &str) -> Vec<String> {
list_prop(graph, "Function", name, "revs")
}
fn title_is(node: &crate::graph::schema::NodeData, name: &str) -> bool {
node.title().as_ref() == &Value::String(name.to_string())
}
fn list_prop(graph: &DirGraph, node_type: &str, name: &str, prop: &str) -> Vec<String> {
let mut found: Vec<Vec<String>> = Vec::new();
for idx in graph.graph.node_indices() {
let Some(node) = graph.graph.node_weight(idx) else {
continue;
};
if node.node_type_str(&graph.interner) != node_type {
continue;
}
if !title_is(node, name) {
continue;
}
let list = match node.get_property_value(prop) {
Some(Value::List(items)) => items
.iter()
.map(|v| v.as_string().unwrap_or_else(|| v.to_string()))
.collect(),
_ => Vec::new(),
};
found.push(list);
}
assert_eq!(found.len(), 1, "expected exactly one {node_type} {name}");
found.into_iter().next().unwrap()
}
fn calls_edge_revs(graph: &DirGraph, caller: &str, callee: &str) -> Vec<String> {
let revs_key = InternedKey::from_str("revs");
for eidx in graph.graph.edge_indices() {
let Some(edge) = graph.graph.edge_weight(eidx) else {
continue;
};
if edge.connection_type_str(&graph.interner) != "CALLS" {
continue;
}
let Some((s, t)) = graph.graph.edge_endpoints(eidx) else {
continue;
};
let (Some(sn), Some(tn)) = (graph.graph.node_weight(s), graph.graph.node_weight(t))
else {
continue;
};
if title_is(sn, caller) && title_is(tn, callee) {
return match edge.properties.iter().find(|(k, _)| *k == revs_key) {
Some((_, Value::List(items))) => items
.iter()
.map(|v| v.as_string().unwrap_or_else(|| v.to_string()))
.collect(),
_ => Vec::new(),
};
}
}
panic!("no CALLS edge {caller} -> {callee}");
}
#[test]
fn multi_rev_merge_tracks_presence_change_and_edges() {
let (tmp, [s1, s2, s3]) = fixture();
let revs = vec![s1.clone(), s2.clone(), s3.clone()];
let graph = build(tmp.path(), &revs);
assert_eq!(
fn_revs(&graph, "foo"),
vec![s1.clone(), s2.clone(), s3.clone()]
);
assert_eq!(fn_revs(&graph, "bar"), vec![s2.clone(), s3.clone()]); assert_eq!(fn_revs(&graph, "gone"), vec![s1.clone()]);
let fp = match graph
.graph
.node_indices()
.filter_map(|i| graph.graph.node_weight(i))
.find(|n| n.node_type_str(&graph.interner) == "Function" && title_is(n, "foo"))
.and_then(|n| n.get_property_value("rev_fp"))
{
Some(Value::List(items)) => items,
other => panic!("foo rev_fp missing/not a list: {other:?}"),
};
assert_eq!(fp.len(), 3, "one fingerprint per rev");
assert_eq!(fp[0], fp[1], "signature unchanged rev1→rev2");
assert_ne!(fp[1], fp[2], "signature widened in rev3");
assert_eq!(calls_edge_revs(&graph, "foo", "bar"), vec![s2, s3]);
let sig = graph
.graph
.node_indices()
.filter_map(|i| graph.graph.node_weight(i))
.find(|n| n.node_type_str(&graph.interner) == "Function" && title_is(n, "foo"))
.and_then(|n| n.get_property_value("signature"))
.and_then(|v| v.as_string())
.unwrap();
assert!(
sig.contains('b'),
"newest-wins signature (a, b), got {sig:?}"
);
}
#[test]
fn single_rev_matches_a1_shape_plus_rev_tags() {
let (tmp, [_s1, _s2, s3]) = fixture();
let revs = vec![s3.clone()];
let merged = build(tmp.path(), &revs);
assert_eq!(fn_revs(&merged, "foo"), vec![s3.clone()]);
assert_eq!(fn_revs(&merged, "bar"), vec![s3.clone()]);
let names: Vec<String> = merged
.graph
.node_indices()
.filter_map(|i| merged.graph.node_weight(i))
.filter(|n| n.node_type_str(&merged.interner) == "Function")
.map(|n| n.title().into_owned())
.filter_map(|v| v.as_string())
.collect();
assert!(names.contains(&"foo".to_string()) && names.contains(&"bar".to_string()));
assert!(!names.contains(&"gone".to_string()), "gone not in rev3");
assert_eq!(calls_edge_revs(&merged, "foo", "bar"), vec![s3]);
}
fn commit_files(files: &[(&str, &str)]) -> (tempfile::TempDir, String) {
let tmp = tempfile::Builder::new()
.prefix("kglite-revhtml-")
.tempdir()
.unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
for (rel, body) in files {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, body).unwrap();
}
git(dir, &["add", "-A"]);
git(
dir,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"c",
],
);
let sha = git(dir, &["rev-parse", "HEAD"]);
(tmp, sha)
}
fn html_script_fixture() -> (tempfile::TempDir, String) {
let mut files: Vec<(String, String)> = Vec::new();
for i in 0..24 {
let rel = format!("page_{i}.html");
let body = format!(
"<!doctype html>\n<html><head>\n<script>\n\
function gtag(){{ window.dataLayer.push(arguments); }}\n\
function helper_{i}(x){{ return x + {i}; }}\n\
</script>\n</head><body><h1>Page {i}</h1></body></html>\n"
);
files.push((rel, body));
}
let refs: Vec<(&str, &str)> = files
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
commit_files(&refs)
}
type EntitySets = (Vec<(String, String)>, Vec<(String, String, String)>);
fn entity_sets(graph: &DirGraph) -> EntitySets {
let mut nodes: Vec<(String, String)> = graph
.graph
.node_indices()
.filter_map(|i| graph.graph.node_weight(i))
.map(|n| {
(
n.node_type_str(&graph.interner).to_string(),
n.id().to_string(),
)
})
.collect();
nodes.sort();
let mut edges: Vec<(String, String, String)> = graph
.graph
.edge_indices()
.filter_map(|e| {
let edge = graph.graph.edge_weight(e)?;
let (s, t) = graph.graph.edge_endpoints(e)?;
let sn = graph.graph.node_weight(s)?;
let tn = graph.graph.node_weight(t)?;
Some((
edge.connection_type_str(&graph.interner).to_string(),
sn.id().to_string(),
tn.id().to_string(),
))
})
.collect();
edges.sort();
(nodes, edges)
}
fn assert_all_nodes_have_revs(graph: &DirGraph, expect: &[String]) {
for idx in graph.graph.node_indices() {
let Some(node) = graph.graph.node_weight(idx) else {
continue;
};
let revs: Vec<String> = match node.get_property_value("revs") {
Some(Value::List(items)) => items
.iter()
.map(|v| v.as_string().unwrap_or_else(|| v.to_string()))
.collect(),
_ => Vec::new(),
};
assert_eq!(
revs,
expect,
"node {:?} carries revs {:?}, expected {:?}",
node.id(),
revs,
expect
);
}
}
#[test]
fn multi_rev_identical_tree_is_idempotent() {
let (tmp, sha) = html_script_fixture();
let single = build(tmp.path(), std::slice::from_ref(&sha));
let (single_nodes, single_edges) = entity_sets(&single);
assert!(
single_nodes
.iter()
.any(|(t, id)| t == "Function" && id.contains("gtag")),
"fixture should extract gtag functions from inline scripts"
);
let dual = build(tmp.path(), &[sha.clone(), "HEAD".to_string()]);
let (dual_nodes, dual_edges) = entity_sets(&dual);
assert_eq!(
single_nodes, dual_nodes,
"folding the identical tree twice must not change the node set"
);
assert_eq!(
single_edges, dual_edges,
"folding the identical tree twice must not change the edge set"
);
assert_all_nodes_have_revs(&dual, &[sha, "HEAD".to_string()]);
}
#[test]
fn single_rev_build_is_deterministic() {
let (tmp, sha) = html_script_fixture();
let a = build(tmp.path(), std::slice::from_ref(&sha));
let b = build(tmp.path(), std::slice::from_ref(&sha));
assert_eq!(
entity_sets(&a),
entity_sets(&b),
"two builds of the same rev diverged — extraction is nondeterministic"
);
}
#[test]
fn duplicate_rev_labels_are_deduped() {
let (tmp, [_s1, _s2, s3]) = fixture();
let graph = build(tmp.path(), &[s3.clone(), s3.clone(), "HEAD".to_string()]);
assert_eq!(fn_revs(&graph, "foo"), vec![s3, "HEAD".to_string()]);
}
#[test]
fn merged_graph_revs_survive_save_reload() {
let (tmp, [s1, s2, s3]) = fixture();
let revs = vec![s1.clone(), s2.clone(), s3.clone()];
let out = tmp.path().join("merged.kgl");
let built = build_code_tree_revs(
tmp.path(),
&revs,
Some(tmp.path()),
false,
false,
Some(&out),
None,
false,
)
.expect("build+save");
assert_eq!(fn_revs(&built, "bar"), vec![s2.clone(), s3.clone()]);
let reloaded = crate::graph::io::file::load_file(out.to_str().unwrap()).expect("reload");
assert_eq!(
fn_revs(&reloaded, "foo"),
vec![s1.clone(), s2.clone(), s3.clone()]
);
assert_eq!(fn_revs(&reloaded, "bar"), vec![s2.clone(), s3.clone()]);
assert_eq!(list_prop(&reloaded, "Function", "gone", "revs"), vec![s1]);
assert_eq!(calls_edge_revs(&reloaded, "foo", "bar"), vec![s2, s3]);
}
}