use std::path::Path;
use sha2::{Digest, Sha256};
pub struct PackOptions {
pub path: Option<String>,
pub max_nodes: usize,
pub source_revision: Option<String>,
}
impl Default for PackOptions {
fn default() -> Self {
Self {
path: None,
max_nodes: 5000,
source_revision: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PackManifest {
pub schema_version: u32,
pub kind: &'static str,
pub elements: usize,
pub relationships: usize,
pub content_hash: String,
pub source_revision: Option<String>,
pub path_scope: Option<String>,
}
pub fn write_pack(
project_root: &Path,
db_path: &Path,
out_dir: &Path,
opts: &PackOptions,
) -> Result<PackManifest, Box<dyn std::error::Error>> {
std::fs::create_dir_all(out_dir)?;
let db = crate::db::backend::init_db_readonly(db_path)?;
let graph = crate::graph::GraphEngine::new(db);
let (elements, relationships) = select_slice(&graph, opts)?;
if elements.len() > opts.max_nodes {
return Err(format!(
"pack slice {} elements exceeds max_nodes={} (refusing to truncate)",
elements.len(),
opts.max_nodes
)
.into());
}
let snapshot_path = out_dir.join("snapshot.json");
let snapshot_bytes = render_snapshot(project_root, &elements, &relationships)?;
std::fs::write(&snapshot_path, &snapshot_bytes)?;
let content_hash = hex::encode(Sha256::digest(&snapshot_bytes));
let manifest = PackManifest {
schema_version: 1,
kind: "leankg.context.pack",
elements: elements.len(),
relationships: relationships.len(),
content_hash,
source_revision: opts.source_revision.clone(),
path_scope: opts.path.clone(),
};
let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
std::fs::write(out_dir.join("manifest.json"), &manifest_bytes)?;
Ok(manifest)
}
type Slice = (
Vec<crate::db::models::CodeElement>,
Vec<crate::db::models::Relationship>,
);
fn select_slice(
graph: &crate::graph::GraphEngine,
opts: &PackOptions,
) -> Result<Slice, Box<dyn std::error::Error>> {
let all = graph.all_elements()?;
let all_rel = graph.all_relationships()?;
let mut elements: Vec<_> = match &opts.path {
Some(p) => {
let p = p.trim_start_matches("./");
all.into_iter()
.filter(|e| {
e.file_path.trim_start_matches("./").starts_with(p)
|| e.qualified_name.starts_with(p)
})
.collect()
}
None => all,
};
elements.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
let ids: std::collections::HashSet<String> =
elements.iter().map(|e| e.qualified_name.clone()).collect();
let relationships: Vec<_> = all_rel
.into_iter()
.filter(|r| ids.contains(&r.source_qualified) && ids.contains(&r.target_qualified))
.collect();
Ok((elements, relationships))
}
fn render_snapshot(
_project_root: &Path,
elements: &[crate::db::models::CodeElement],
relationships: &[crate::db::models::Relationship],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut elems: Vec<serde_json::Value> = elements
.iter()
.map(|e| {
serde_json::json!({
"qualified_name": e.qualified_name,
"element_type": e.element_type,
"name": e.name,
"file_path": relativize(&e.file_path),
"line_start": e.line_start,
"line_end": e.line_end,
"language": e.language,
"cluster_id": e.cluster_id,
"cluster_label": e.cluster_label,
"parent_qualified": e.parent_qualified,
})
})
.collect();
elems.sort_by(|a, b| {
a["qualified_name"]
.as_str()
.cmp(&b["qualified_name"].as_str())
});
let mut rels: Vec<serde_json::Value> = relationships
.iter()
.map(|r| {
serde_json::json!({
"source": r.source_qualified,
"target": r.target_qualified,
"rel_type": r.rel_type,
"confidence": r.confidence,
})
})
.collect();
rels.sort_by(|a, b| {
(
a["source"].as_str(),
a["rel_type"].as_str(),
a["target"].as_str(),
)
.cmp(&(
b["source"].as_str(),
b["rel_type"].as_str(),
b["target"].as_str(),
))
});
let doc = serde_json::json!({
"version": 1,
"kind": "leankg.context.pack.snapshot",
"elements": elems,
"relationships": rels,
});
serde_json::to_vec_pretty(&doc).map_err(Into::into)
}
fn relativize(file_path: &str) -> String {
let norm = file_path.replace('\\', "/");
norm.trim_start_matches('/')
.trim_start_matches("./")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(base: &Path, rel: &str, content: &str) {
let p = base.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
#[test]
fn manifest_and_snapshot_written() {
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
write(&proj, "src/a.rs", "fn a() {}\n");
let db_path = proj.join(".leankg");
crate::db::backend::init_db(&db_path).expect("seed db");
let out = tmp.path().join("pack");
let m = write_pack(&proj, &db_path, &out, &PackOptions::default()).expect("pack");
assert!(out.join("manifest.json").exists());
assert!(out.join("snapshot.json").exists());
assert_eq!(m.schema_version, 1);
assert_eq!(m.kind, "leankg.context.pack");
assert!(!m.content_hash.is_empty());
}
#[test]
fn same_graph_same_hash() {
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
write(&proj, "src/a.rs", "fn a() {}\n");
let db_path = proj.join(".leankg");
crate::db::backend::init_db(&db_path).expect("seed db");
let out1 = tmp.path().join("p1");
let out2 = tmp.path().join("p2");
let m1 = write_pack(&proj, &db_path, &out1, &PackOptions::default()).expect("p1");
let m2 = write_pack(&proj, &db_path, &out2, &PackOptions::default()).expect("p2");
assert_eq!(
m1.content_hash, m2.content_hash,
"deterministic content hash"
);
let s1 = std::fs::read(out1.join("snapshot.json")).unwrap();
let s2 = std::fs::read(out2.join("snapshot.json")).unwrap();
assert_eq!(s1, s2, "byte-identical snapshots");
}
#[test]
fn path_scope_filters_elements() {
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
write(&proj, "src/a.rs", "fn a() {}\n");
write(&proj, "tests/t.rs", "mod t;\n");
let db_path = proj.join(".leankg");
crate::db::backend::init_db(&db_path).expect("seed db");
let out = tmp.path().join("pack");
let opts = PackOptions {
path: Some("src".to_string()),
max_nodes: 1000,
source_revision: None,
};
let m = write_pack(&proj, &db_path, &out, &opts).expect("pack");
assert!(m.elements <= 1000);
assert_eq!(m.path_scope.as_deref(), Some("src"));
}
#[test]
fn manifest_is_byte_identical_across_runs() {
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
write(&proj, "src/a.rs", "fn a() {}\n");
let db_path = proj.join(".leankg");
crate::db::backend::init_db(&db_path).expect("seed db");
let out1 = tmp.path().join("p1");
let out2 = tmp.path().join("p2");
write_pack(&proj, &db_path, &out1, &PackOptions::default()).expect("p1");
write_pack(&proj, &db_path, &out2, &PackOptions::default()).expect("p2");
let m1 = std::fs::read(out1.join("manifest.json")).unwrap();
let m2 = std::fs::read(out2.join("manifest.json")).unwrap();
assert_eq!(m1, m2, "manifest must be byte-identical across runs");
let s = std::fs::read_to_string(out1.join("snapshot.json")).unwrap();
assert!(
!s.contains(&proj.to_string_lossy().to_string()),
"snapshot must not contain absolute project_root"
);
}
#[test]
fn path_scope_refuses_to_truncate_when_oversize() {
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("proj");
write(&proj, "src/a.rs", "fn a() {}\n");
let db_path = proj.join(".leankg");
let db = crate::db::backend::init_db(&db_path).expect("seed db");
let graph = crate::graph::GraphEngine::new(db);
graph
.insert_element(&crate::db::models::CodeElement {
qualified_name: "src::a".into(),
element_type: "function".into(),
name: "a".into(),
file_path: "src/a.rs".into(),
line_start: 1,
line_end: 1,
language: "rust".into(),
parent_qualified: None,
cluster_id: None,
cluster_label: None,
metadata: serde_json::json!({}),
env: "local".into(),
})
.expect("insert");
let out = tmp.path().join("pack");
let opts = PackOptions {
path: None,
max_nodes: 0, source_revision: None,
};
let res = write_pack(&proj, &db_path, &out, &opts);
assert!(
res.is_err(),
"oversize pack must be refused, not silently truncated"
);
}
}