use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn registry_path() -> PathBuf {
crate::config::SemantexConfig::semantex_home().join("projects.json")
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct BranchEntry {
pub branch: String,
pub branch_key: String,
#[serde(default)]
pub last_indexed_ts: i64,
#[serde(default)]
pub head_commit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectEntry {
pub path: PathBuf,
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub branches: Vec<BranchEntry>,
#[serde(default)]
pub embedder_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RegistryV2 {
pub version: u32,
pub projects: Vec<ProjectEntry>,
}
impl Default for RegistryV2 {
fn default() -> Self {
Self {
version: 2,
projects: Vec::new(),
}
}
}
pub fn project_id_for_path(canonical: &Path) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
let digest = hasher.finalize();
digest[..8].iter().fold(String::new(), |mut out, b| {
let _ = write!(out, "{b:02x}");
out
})
}
fn display_name_for_path(path: &Path) -> String {
path.file_name().map_or_else(
|| path.to_string_lossy().to_string(),
|n| n.to_string_lossy().to_string(),
)
}
fn load_from(path: &Path) -> RegistryV2 {
let Ok(content) = std::fs::read_to_string(path) else {
return RegistryV2::default();
};
if let Ok(v2) = serde_json::from_str::<RegistryV2>(&content) {
return v2;
}
if let Ok(v1_paths) = serde_json::from_str::<Vec<String>>(&content) {
let projects = v1_paths
.into_iter()
.map(|p| {
let path = PathBuf::from(&p);
ProjectEntry {
project_id: project_id_for_path(&path),
display_name: display_name_for_path(&path),
path,
branches: Vec::new(),
embedder_fingerprint: String::new(),
}
})
.collect();
return RegistryV2 {
version: 2,
projects,
};
}
RegistryV2::default()
}
pub fn load() -> RegistryV2 {
load_from(®istry_path())
}
fn save_to(path: &Path, registry: &RegistryV2) -> bool {
let Some(parent) = path.parent() else {
return false;
};
if std::fs::create_dir_all(parent).is_err() {
return false;
}
let Ok(json) = serde_json::to_string_pretty(registry) else {
return false;
};
let tmp = parent.join(format!(
".{}.tmp.{}",
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default(),
std::process::id()
));
if std::fs::write(&tmp, json).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
let ok = std::fs::rename(&tmp, path).is_ok();
if !ok {
let _ = std::fs::remove_file(&tmp);
}
ok
}
pub fn read_all() -> Vec<PathBuf> {
load().projects.into_iter().map(|p| p.path).collect()
}
pub fn read_all_v2() -> RegistryV2 {
load()
}
fn register_at(path: &Path, canonical: &Path) {
let mut registry = load_from(path);
if registry.projects.iter().any(|p| p.path == canonical) {
return; }
registry.projects.push(ProjectEntry {
path: canonical.to_path_buf(),
project_id: project_id_for_path(canonical),
display_name: display_name_for_path(canonical),
branches: Vec::new(),
embedder_fingerprint: String::new(),
});
save_to(path, ®istry);
}
pub fn register(canonical: &Path) {
register_at(®istry_path(), canonical);
}
#[allow(clippy::too_many_arguments)]
fn upsert_branch_at(
path: &Path,
canonical: &Path,
branch: &str,
branch_key: &str,
last_indexed_ts: i64,
head_commit: Option<String>,
embedder_fingerprint: &str,
) {
let mut registry = load_from(path);
let entry = if let Some(existing) = registry.projects.iter_mut().find(|p| p.path == canonical) {
existing
} else {
registry.projects.push(ProjectEntry {
path: canonical.to_path_buf(),
project_id: project_id_for_path(canonical),
display_name: display_name_for_path(canonical),
branches: Vec::new(),
embedder_fingerprint: String::new(),
});
registry.projects.last_mut().expect("just pushed")
};
entry.embedder_fingerprint = embedder_fingerprint.to_string();
if let Some(existing_branch) = entry
.branches
.iter_mut()
.find(|b| b.branch_key == branch_key)
{
existing_branch.branch = branch.to_string();
existing_branch.last_indexed_ts = last_indexed_ts;
existing_branch.head_commit = head_commit;
} else {
entry.branches.push(BranchEntry {
branch: branch.to_string(),
branch_key: branch_key.to_string(),
last_indexed_ts,
head_commit,
});
}
save_to(path, ®istry);
}
pub fn upsert_branch(
canonical: &Path,
branch: &str,
branch_key: &str,
last_indexed_ts: i64,
head_commit: Option<String>,
embedder_fingerprint: &str,
) {
upsert_branch_at(
®istry_path(),
canonical,
branch,
branch_key,
last_indexed_ts,
head_commit,
embedder_fingerprint,
);
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp_registry() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("projects.json");
(tmp, path)
}
#[test]
fn register_and_read_all_round_trip() {
let (_tmp, reg) = tmp_registry();
let p1 = PathBuf::from("/tmp/project-one");
let p2 = PathBuf::from("/tmp/project-two");
register_at(®, &p1);
register_at(®, &p2);
register_at(®, &p1);
let all: Vec<PathBuf> = load_from(®)
.projects
.into_iter()
.map(|p| p.path)
.collect();
assert_eq!(all.len(), 2);
assert!(all.contains(&p1));
assert!(all.contains(&p2));
let v2 = load_from(®);
assert_eq!(v2.version, 2);
let entry = v2.projects.iter().find(|p| p.path == p1).unwrap();
assert_eq!(entry.project_id, project_id_for_path(&p1));
assert_eq!(entry.display_name, "project-one");
}
#[test]
fn v1_array_is_upgraded_to_v2_on_load() {
let (_tmp, reg) = tmp_registry();
let v1_json =
serde_json::to_string(&vec!["/repo/a".to_string(), "/repo/b".to_string()]).unwrap();
std::fs::write(®, v1_json).unwrap();
let v2 = load_from(®);
assert_eq!(v2.version, 2);
assert_eq!(v2.projects.len(), 2);
assert!(v2.projects.iter().any(|p| p.path == Path::new("/repo/a")));
register_at(®, Path::new("/repo/c"));
let raw = std::fs::read_to_string(®).unwrap();
let persisted: RegistryV2 = serde_json::from_str(&raw).unwrap();
assert_eq!(persisted.version, 2);
assert_eq!(persisted.projects.len(), 3);
}
#[test]
fn upsert_branch_creates_project_and_updates_existing_branch() {
let (_tmp, reg) = tmp_registry();
let proj = PathBuf::from("/tmp/branchy-project");
upsert_branch_at(
®,
&proj,
"main",
"main-abc12345",
100,
Some("c1".into()),
"fp1",
);
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 1);
assert_eq!(entry.branches[0].last_indexed_ts, 100);
assert_eq!(entry.embedder_fingerprint, "fp1");
upsert_branch_at(
®,
&proj,
"main",
"main-abc12345",
200,
Some("c2".into()),
"fp2",
);
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 1);
assert_eq!(entry.branches[0].last_indexed_ts, 200);
assert_eq!(entry.branches[0].head_commit, Some("c2".to_string()));
assert_eq!(entry.embedder_fingerprint, "fp2");
upsert_branch_at(®, &proj, "develop", "develop-def67890", 50, None, "fp2");
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 2);
}
#[test]
fn save_is_atomic_no_tmp_file_left_behind() {
let (_tmp, reg) = tmp_registry();
register_at(®, Path::new("/tmp/atomic-project"));
assert!(reg.exists());
let entries: Vec<_> = std::fs::read_dir(reg.parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec!["projects.json".to_string()], "{entries:?}");
}
#[test]
fn corrupt_registry_loads_as_empty_and_is_replaced_on_next_save() {
let (_tmp, reg) = tmp_registry();
std::fs::write(®, "{ this is not json").unwrap();
assert!(load_from(®).projects.is_empty());
register_at(®, Path::new("/tmp/recovered"));
assert_eq!(load_from(®).projects.len(), 1);
}
#[test]
fn registry_path_honors_semantex_home_layout() {
assert_eq!(
registry_path(),
crate::config::SemantexConfig::semantex_home().join("projects.json")
);
}
#[test]
fn project_id_is_stable_for_same_path() {
let p = PathBuf::from("/tmp/stable-id-project");
assert_eq!(project_id_for_path(&p), project_id_for_path(&p));
assert_ne!(
project_id_for_path(&p),
project_id_for_path(&PathBuf::from("/tmp/other-project"))
);
}
}