use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectEntry {
#[serde(skip)]
pub alias: String,
pub path: PathBuf,
pub added_at: u64,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct RegistryFile {
#[serde(default)]
projects: BTreeMap<String, ProjectEntry>,
}
pub struct ProjectRegistry {
toml_path: PathBuf,
}
fn registry_toml_path() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect("HOME or USERPROFILE environment variable must be set");
PathBuf::from(home)
.join(".code-graph")
.join("projects.toml")
}
fn validate_alias(alias: &str) -> Result<()> {
if alias.is_empty() {
bail!("alias must not be empty");
}
if alias.len() > 64 {
bail!(
"alias '{}...' exceeds maximum length of 64 characters (got {})",
&alias[..32],
alias.len()
);
}
if let Some(c) = alias
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-')
{
bail!(
"alias '{}' contains invalid character '{}' — only alphanumeric characters and hyphens are allowed",
alias,
c
);
}
Ok(())
}
impl ProjectRegistry {
pub fn new() -> Self {
Self {
toml_path: registry_toml_path(),
}
}
#[cfg(test)]
fn with_path(toml_path: PathBuf) -> Self {
Self { toml_path }
}
pub fn add(&self, alias: &str, path: &Path) -> Result<ProjectEntry> {
validate_alias(alias)?;
let canonical = path.canonicalize().with_context(|| {
format!(
"path '{}' does not exist or is not accessible",
path.display()
)
})?;
let mut registry = self.load()?;
if registry.projects.contains_key(alias) {
bail!(
"alias '{}' is already registered — use a different alias or remove the existing one first",
alias
);
}
for (existing_alias, entry) in ®istry.projects {
if entry.path == canonical {
bail!(
"path '{}' is already registered under alias '{}' — each project path can only be registered once",
canonical.display(),
existing_alias
);
}
}
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time is before unix epoch")
.as_secs();
let entry = ProjectEntry {
alias: alias.to_string(),
path: canonical,
added_at: now,
};
registry.projects.insert(entry.alias.clone(), entry.clone());
self.save(®istry)?;
Ok(entry)
}
pub fn remove(&self, alias: &str) -> Result<()> {
let mut registry = self.load()?;
if registry.projects.remove(alias).is_none() {
bail!("alias '{}' is not registered — nothing to remove", alias);
}
self.save(®istry)?;
Ok(())
}
pub fn list(&self) -> Vec<ProjectEntry> {
let registry = self.load().unwrap_or_default();
registry
.projects
.into_iter()
.map(|(alias, mut entry)| {
entry.alias = alias;
entry
})
.collect()
}
pub fn get(&self, alias: &str) -> Option<ProjectEntry> {
let registry = self.load().unwrap_or_default();
registry.projects.get(alias).map(|entry| {
let mut e = entry.clone();
e.alias = alias.to_string();
e
})
}
fn load(&self) -> Result<RegistryFile> {
if !self.toml_path.exists() {
return Ok(RegistryFile::default());
}
let content = std::fs::read_to_string(&self.toml_path)
.with_context(|| format!("failed to read {}", self.toml_path.display()))?;
let registry: RegistryFile = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", self.toml_path.display()))?;
Ok(registry)
}
fn save(&self, registry: &RegistryFile) -> Result<()> {
if let Some(parent) = self.toml_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let content =
toml::to_string_pretty(registry).context("failed to serialize registry to TOML")?;
let tmp_path = self.toml_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, content.as_bytes())
.with_context(|| format!("failed to write temporary file {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &self.toml_path).with_context(|| {
format!(
"failed to rename {} to {}",
tmp_path.display(),
self.toml_path.display()
)
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_registry(tmp: &TempDir) -> ProjectRegistry {
let toml_path = tmp.path().join(".code-graph").join("projects.toml");
ProjectRegistry::with_path(toml_path)
}
fn create_project_dir(tmp: &TempDir, name: &str) -> PathBuf {
let dir = tmp.path().join(name);
std::fs::create_dir_all(&dir).expect("create project dir");
dir.canonicalize().expect("canonicalize project dir")
}
#[test]
fn add_and_list_round_trip() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let project_dir = create_project_dir(&tmp, "my-project");
let entry = reg.add("my-project", &project_dir).unwrap();
assert_eq!(entry.alias, "my-project");
assert_eq!(entry.path, project_dir);
assert!(entry.added_at > 0);
let entries = reg.list();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "my-project");
assert_eq!(entries[0].path, project_dir);
}
#[test]
fn add_duplicate_alias_is_rejected() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir1 = create_project_dir(&tmp, "proj-a");
let dir2 = create_project_dir(&tmp, "proj-b");
reg.add("my-alias", &dir1).unwrap();
let err = reg.add("my-alias", &dir2).unwrap_err();
assert!(
err.to_string().contains("already registered"),
"expected 'already registered' in error: {}",
err
);
}
#[test]
fn add_duplicate_path_is_rejected() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
reg.add("alias-one", &dir).unwrap();
let err = reg.add("alias-two", &dir).unwrap_err();
assert!(
err.to_string().contains("already registered under alias"),
"expected path-duplicate error, got: {}",
err
);
}
#[test]
fn alias_validation_accepts_valid_aliases() {
assert!(validate_alias("a").is_ok());
assert!(validate_alias("my-project").is_ok());
assert!(validate_alias("MyProject123").is_ok());
assert!(validate_alias("A").is_ok());
assert!(validate_alias("a-b-c-d").is_ok());
let max_alias = "a".repeat(64);
assert!(validate_alias(&max_alias).is_ok());
}
#[test]
fn alias_validation_rejects_empty() {
let err = validate_alias("").unwrap_err();
assert!(
err.to_string().contains("must not be empty"),
"unexpected error: {}",
err
);
}
#[test]
fn alias_validation_rejects_too_long() {
let long_alias = "a".repeat(65);
let err = validate_alias(&long_alias).unwrap_err();
assert!(
err.to_string().contains("exceeds maximum length"),
"unexpected error: {}",
err
);
}
#[test]
fn alias_validation_rejects_invalid_chars() {
for bad in &[
"my project",
"my_project",
"my.project",
"my/project",
"hello@world",
] {
let err = validate_alias(bad).unwrap_err();
assert!(
err.to_string().contains("invalid character"),
"expected 'invalid character' for '{}', got: {}",
bad,
err
);
}
}
#[test]
fn remove_and_list() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
reg.add("proj", &dir).unwrap();
assert_eq!(reg.list().len(), 1);
reg.remove("proj").unwrap();
assert_eq!(reg.list().len(), 0);
}
#[test]
fn remove_nonexistent_alias_returns_error() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let err = reg.remove("ghost").unwrap_err();
assert!(
err.to_string().contains("not registered"),
"unexpected error: {}",
err
);
}
#[test]
fn get_existing_returns_entry() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
reg.add("proj", &dir).unwrap();
let entry = reg.get("proj").expect("should find entry");
assert_eq!(entry.alias, "proj");
assert_eq!(entry.path, dir);
}
#[test]
fn get_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn add_nonexistent_path_returns_error() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let bad_path = tmp.path().join("does-not-exist");
let err = reg.add("bad", &bad_path).unwrap_err();
assert!(
err.to_string().contains("does not exist"),
"unexpected error: {}",
err
);
}
#[test]
fn toml_file_exists_after_add() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
let toml_path = tmp.path().join(".code-graph").join("projects.toml");
assert!(!toml_path.exists(), "file should not exist before add");
reg.add("proj", &dir).unwrap();
assert!(toml_path.exists(), "file should exist after add");
let content = std::fs::read_to_string(&toml_path).unwrap();
assert!(
content.contains("[projects.proj]"),
"TOML should contain [projects.proj], got:\n{}",
content
);
assert!(
content.contains("added_at"),
"TOML should contain added_at field"
);
}
#[test]
fn tmp_file_does_not_linger() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
reg.add("proj", &dir).unwrap();
let tmp_path = tmp.path().join(".code-graph").join("projects.toml.tmp");
assert!(
!tmp_path.exists(),
"temporary file should not exist after successful write"
);
}
#[test]
fn creates_code_graph_directory_automatically() {
let tmp = TempDir::new().unwrap();
let cg_dir = tmp.path().join(".code-graph");
assert!(!cg_dir.exists(), "dir should not pre-exist");
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "proj");
reg.add("proj", &dir).unwrap();
assert!(cg_dir.exists(), "dir should be created automatically");
}
#[test]
fn multiple_projects_are_persisted() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir_a = create_project_dir(&tmp, "proj-a");
let dir_b = create_project_dir(&tmp, "proj-b");
reg.add("alpha", &dir_a).unwrap();
reg.add("beta", &dir_b).unwrap();
let entries = reg.list();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].alias, "alpha");
assert_eq!(entries[1].alias, "beta");
}
#[test]
fn integration_add_list_show_remove() {
let tmp = TempDir::new().unwrap();
let reg = test_registry(&tmp);
let dir = create_project_dir(&tmp, "my-app");
let entry = reg.add("my-app", &dir).unwrap();
assert_eq!(entry.alias, "my-app");
let entries = reg.list();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "my-app");
let found = reg.get("my-app").unwrap();
assert_eq!(found.path, dir);
reg.remove("my-app").unwrap();
assert!(reg.list().is_empty());
assert!(reg.get("my-app").is_none());
}
}