use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use super::types::ProjectEntry;
fn default_registry_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
.join(".config")
.join("magellan")
.join("config.toml")
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistryFile {
#[serde(default)]
pub version: String,
#[serde(default)]
pub project: Vec<ProjectEntry>,
}
#[derive(Debug, Clone)]
pub struct Registry {
pub projects: Vec<ProjectEntry>,
path: PathBuf,
}
impl Registry {
pub fn load() -> Result<Self> {
Self::load_from(default_registry_path())
}
pub fn load_from(path: PathBuf) -> Result<Self> {
let projects = if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let file: RegistryFile =
toml::from_str(&content).with_context(|| "Failed to parse registry TOML")?;
file.project
} else {
Vec::new()
};
Ok(Self { projects, path })
}
pub fn save(&self) -> Result<()> {
let dir = self.path.parent().unwrap_or(Path::new("."));
if !dir.exists() {
fs::create_dir_all(dir)?;
}
let mut doc: toml::value::Table = if self.path.exists() {
let content = fs::read_to_string(&self.path)
.with_context(|| format!("Failed to read {}", self.path.display()))?;
toml::from_str::<toml::value::Table>(&content).unwrap_or_default()
} else {
toml::value::Table::new()
};
if self.projects.is_empty() {
doc.remove("project");
} else {
let projects_val =
toml::Value::try_from(&self.projects).context("Failed to serialize projects")?;
doc.insert("project".to_string(), projects_val);
}
let content = toml::to_string_pretty(&toml::Value::Table(doc))
.context("Failed to serialize config.toml")?;
fs::write(&self.path, &content)
.with_context(|| format!("Failed to write {}", self.path.display()))?;
Ok(())
}
pub fn list(&self) -> &[ProjectEntry] {
&self.projects
}
pub fn find(&self, name: &str) -> Option<&ProjectEntry> {
self.projects.iter().find(|p| p.name == name)
}
pub fn find_by_root(&self, root: &Path) -> Option<&ProjectEntry> {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
self.projects
.iter()
.find(|p| p.root.canonicalize().unwrap_or_else(|_| p.root.clone()) == root)
}
pub fn is_registered(&self, root: &Path) -> bool {
self.find_by_root(root).is_some()
}
#[allow(dead_code, reason = "Phase 7: used in tests")]
pub fn names(&self) -> Vec<String> {
self.projects.iter().map(|p| p.name.clone()).collect()
}
pub fn enabled_names(&self) -> Vec<String> {
self.projects
.iter()
.filter_map(|p| {
if p.enabled {
Some(p.name.clone())
} else {
None
}
})
.collect()
}
pub fn register(&mut self, entry: ProjectEntry) -> Result<()> {
if self.is_registered(&entry.root) {
anyhow::bail!(
"Project at root '{}' is already registered",
entry.root.display()
);
}
let name = Self::disambiguate_name(&self.projects, &entry.name);
let entry = ProjectEntry { name, ..entry };
self.projects.push(entry);
self.save()
}
pub fn unregister(&mut self, name: &str) -> Result<bool> {
let before = self.projects.len();
self.projects.retain(|p| p.name != name);
let removed = self.projects.len() != before;
if removed {
self.save()?;
}
Ok(removed)
}
pub fn pause(&mut self, name: &str) -> Result<bool> {
if let Some(p) = self.projects.iter_mut().find(|p| p.name == name) {
p.enabled = false;
self.save()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn resume(&mut self, name: &str) -> Result<bool> {
if let Some(p) = self.projects.iter_mut().find(|p| p.name == name) {
p.enabled = true;
self.save()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn disambiguate_name(projects: &[ProjectEntry], base: &str) -> String {
let names: HashSet<String> = projects.iter().map(|p| p.name.clone()).collect();
if !names.contains(base) {
return base.to_string();
}
let mut n = 1;
loop {
let candidate = format!("{}-{}", base, n);
if !names.contains(&candidate) {
return candidate;
}
n += 1;
if n > 10_000 {
return format!("{}-{}", base, uuid::Uuid::new_v4());
}
}
}
pub fn canonical_db_path(name: &str) -> PathBuf {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
home.join(".magellan")
.join(name)
.join(format!("{}.db", name))
}
pub fn ensure_db_dir(name: &str) -> Result<PathBuf> {
let path = Self::canonical_db_path(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_registry() -> (Registry, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("registry.toml");
let reg = Registry::load_from(path).unwrap();
(reg, dir)
}
fn make_entry(name: &str) -> ProjectEntry {
ProjectEntry::new(
name.to_string(),
PathBuf::from(format!("/tmp/roots/{}", name)),
PathBuf::from(format!("/tmp/dbs/{}.db", name)),
"cargo".to_string(),
)
}
#[test]
fn registry_register_and_find() {
let (mut reg, _dir) = tmp_registry();
let e = make_entry("test");
reg.register(e.clone()).unwrap();
assert_eq!(reg.list().len(), 1);
assert!(reg.find("test").is_some());
assert_eq!(reg.find("test").unwrap().name, "test");
}
#[test]
fn registry_unregister() {
let (mut reg, _dir) = tmp_registry();
reg.register(make_entry("a")).unwrap();
reg.register(make_entry("b")).unwrap();
assert!(reg.unregister("a").unwrap());
assert!(reg.find("a").is_none());
assert!(reg.find("b").is_some());
}
#[test]
fn registry_roundtrip() {
let (mut reg, _dir) = tmp_registry();
reg.register(make_entry("x")).unwrap();
let reg2 = Registry::load_from(reg.path.clone()).unwrap();
assert_eq!(reg2.list().len(), 1);
assert_eq!(reg2.find("x").unwrap().name, "x");
}
fn make_entry_with_root(name: &str, root_name: &str) -> ProjectEntry {
ProjectEntry::new(
name.to_string(),
PathBuf::from(format!("/tmp/roots/{}", root_name)),
PathBuf::from(format!("/tmp/dbs/{}.db", name)),
"cargo".to_string(),
)
}
#[test]
fn registry_duplicate_name_disambiguates() {
let (mut reg, _dir) = tmp_registry();
reg.register(make_entry_with_root("foo", "foo-0")).unwrap();
reg.register(make_entry_with_root("foo", "foo-1")).unwrap();
let names = reg.names();
assert!(names.contains(&"foo".to_string()));
assert!(
names.contains(&"foo-1".to_string()),
"Expected foo-1 but got {:?}",
names
);
}
#[test]
fn registry_include_exclude_roundtrip() {
let (mut reg, _dir) = tmp_registry();
let mut entry = make_entry("multi_path");
entry.include = vec![
"src/".to_string(),
"kernels/".to_string(),
"tests/".to_string(),
];
entry.exclude = vec!["target/".to_string()];
reg.register(entry).unwrap();
let reg2 = Registry::load_from(reg.path.clone()).unwrap();
let loaded = reg2.find("multi_path").unwrap();
assert_eq!(
loaded.include,
vec!["src/", "kernels/", "tests/"],
"include should round-trip through TOML"
);
assert_eq!(loaded.exclude, vec!["target/"]);
}
#[test]
fn registry_backward_compat_without_include_exclude() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("registry.toml");
let toml_content = r#"
version = "1"
[[project]]
name = "legacy"
root = "/tmp/legacy"
db = "/tmp/legacy.db"
source = "manual"
enabled = true
"#;
std::fs::write(&path, toml_content).unwrap();
let reg = Registry::load_from(path).unwrap();
let entry = reg.find("legacy").unwrap();
assert_eq!(entry.name, "legacy");
assert!(
entry.include.is_empty(),
"legacy entries should have empty include"
);
assert!(
entry.exclude.is_empty(),
"legacy entries should have empty exclude"
);
}
#[test]
fn test_default_registry_path_respects_home() {
let original_home = std::env::var("HOME").ok();
let dir = tempfile::tempdir().unwrap();
let fake_home = dir.path().to_path_buf();
std::env::set_var("HOME", &fake_home);
let resolved = default_registry_path();
match &original_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
let expected = fake_home
.join(".config")
.join("magellan")
.join("config.toml");
assert_eq!(
resolved, expected,
"default_registry_path must resolve under $HOME, not a hardcoded path"
);
let resolved_str = resolved.to_string_lossy();
assert!(
!resolved_str.contains("/home/feanor"),
"default_registry_path must not be hardcoded to /home/feanor, got: {}",
resolved_str
);
}
}