use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::service::persistence::data_dir;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrackedRoot {
pub path: PathBuf,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct RootsFile {
#[serde(default, rename = "root")]
roots: Vec<TrackedRoot>,
}
pub fn roots_toml_path() -> Result<PathBuf> {
Ok(data_dir()?.join("roots.toml"))
}
pub fn load_roots() -> Result<Vec<TrackedRoot>> {
load_roots_at(&roots_toml_path()?)
}
pub(crate) fn load_roots_at(path: &Path) -> Result<Vec<TrackedRoot>> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).context("read roots.toml"),
};
match toml::from_str::<RootsFile>(&content) {
Ok(f) => Ok(f.roots),
Err(e) => {
tracing::warn!(
"roots.toml at {} is corrupt ({e}); starting with empty roots list",
path.display()
);
Ok(Vec::new())
}
}
}
pub fn save_roots(roots: &[TrackedRoot]) -> Result<()> {
save_roots_at(&roots_toml_path()?, roots)
}
pub(crate) fn save_roots_at(path: &Path, roots: &[TrackedRoot]) -> Result<()> {
let file = RootsFile {
roots: roots.to_vec(),
};
let serialised = toml::to_string_pretty(&file).context("serialise roots.toml")?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &serialised).context("write roots.toml.tmp")?;
std::fs::rename(&tmp, path).context("rename roots.toml.tmp → roots.toml")?;
Ok(())
}
pub fn upsert_root(root: PathBuf) -> Result<()> {
upsert_root_at(&roots_toml_path()?, root)
}
pub(crate) fn upsert_root_at(path: &Path, root: PathBuf) -> Result<()> {
let mut roots = load_roots_at(path)?;
if roots.iter().any(|r| r.path == root) {
return Ok(());
}
roots.push(TrackedRoot { path: root });
save_roots_at(path, &roots)
}
pub fn remove_root(root: &Path) -> Result<()> {
remove_root_at(&roots_toml_path()?, root)
}
pub(crate) fn remove_root_at(path: &Path, root: &Path) -> Result<()> {
let mut roots = load_roots_at(path)?;
let before = roots.len();
roots.retain(|r| r.path != root);
if roots.len() == before {
return Ok(());
}
save_roots_at(path, &roots)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn roots_roundtrip() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let roots = vec![
TrackedRoot {
path: PathBuf::from("/projects/alpha"),
},
TrackedRoot {
path: PathBuf::from("/projects/beta"),
},
];
save_roots_at(&path, &roots).unwrap();
let loaded = load_roots_at(&path).unwrap();
assert_eq!(loaded, roots);
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("[[root]]"),
"roots.toml must use [[root]] syntax; got: {content}"
);
}
#[test]
fn roots_upsert_dedupes() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
upsert_root_at(&path, PathBuf::from("/projects/alpha")).unwrap();
upsert_root_at(&path, PathBuf::from("/projects/alpha")).unwrap();
let loaded = load_roots_at(&path).unwrap();
assert_eq!(loaded.len(), 1, "duplicate insert must be a no-op");
assert_eq!(loaded[0].path, PathBuf::from("/projects/alpha"));
}
#[test]
fn roots_remove_idempotent() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
upsert_root_at(&path, PathBuf::from("/projects/alpha")).unwrap();
upsert_root_at(&path, PathBuf::from("/projects/beta")).unwrap();
assert_eq!(load_roots_at(&path).unwrap().len(), 2);
remove_root_at(&path, Path::new("/projects/alpha")).unwrap();
let after = load_roots_at(&path).unwrap();
assert_eq!(after.len(), 1);
assert_eq!(after[0].path, PathBuf::from("/projects/beta"));
remove_root_at(&path, Path::new("/projects/alpha")).unwrap();
assert_eq!(load_roots_at(&path).unwrap().len(), 1);
remove_root_at(&path, Path::new("/projects/gamma")).unwrap();
assert_eq!(load_roots_at(&path).unwrap().len(), 1);
}
#[test]
fn missing_roots_file_returns_empty() {
let tmp_dir = tempfile::tempdir().unwrap();
let nonexistent = tmp_dir.path().join("roots.toml");
let roots = load_roots_at(&nonexistent).unwrap();
assert!(roots.is_empty());
}
}