Skip to main content

edda_store/
lib.rs

1pub mod registry;
2pub mod skill_registry;
3pub mod user_config;
4
5use fs2::FileExt;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10/// Compute a deterministic project ID from a repo root or cwd path.
11/// project_id = blake3(normalize_path(input)) → hex string (first 32 chars).
12///
13/// If `repo_root_or_cwd` is inside a git worktree, resolves to the main
14/// repository root so that all worktrees share the same project ID.
15pub fn project_id(repo_root_or_cwd: &Path) -> String {
16    let resolved = edda_core::git::resolve_git_root(repo_root_or_cwd)
17        .unwrap_or_else(|| repo_root_or_cwd.to_path_buf());
18    let normalized = normalize_path(&resolved);
19    let hash = blake3::hash(normalized.as_bytes());
20    hash.to_hex()[..32].to_string()
21}
22
23/// Normalize a path: canonicalize, lowercase on Windows, forward slashes.
24fn normalize_path(p: &Path) -> String {
25    let abs = p
26        .canonicalize()
27        .unwrap_or_else(|_| p.to_path_buf())
28        .to_string_lossy()
29        .to_string();
30    // Lowercase on Windows for consistency
31    #[cfg(windows)]
32    let abs = abs.to_lowercase();
33    // Normalize path separators to forward slashes
34    abs.replace('\\', "/")
35}
36
37/// Return the per-user store root: `~/.edda/`
38/// Windows: `%APPDATA%\edda\` (falls back to `%USERPROFILE%\.edda\`)
39///
40/// Override with `EDDA_STORE_ROOT` env var (useful for testing).
41pub fn store_root() -> PathBuf {
42    if let Ok(custom) = std::env::var("EDDA_STORE_ROOT") {
43        return PathBuf::from(custom);
44    }
45    if let Some(data_dir) = dirs::data_dir() {
46        data_dir.join("edda")
47    } else if let Some(home) = dirs::home_dir() {
48        home.join(".edda")
49    } else {
50        PathBuf::from(".edda-store")
51    }
52}
53
54/// Return the project directory: `store_root/projects/<project_id>/`
55pub fn project_dir(project_id: &str) -> PathBuf {
56    store_root().join("projects").join(project_id)
57}
58
59/// Ensure all subdirectories exist for a project.
60pub fn ensure_dirs(project_id: &str) -> anyhow::Result<()> {
61    let base = project_dir(project_id);
62    let subdirs = ["ledger", "transcripts", "index", "packs", "state", "search"];
63    for sub in &subdirs {
64        fs::create_dir_all(base.join(sub))?;
65    }
66    Ok(())
67}
68
69/// Atomic write: write to temp file in same dir, then rename.
70pub fn write_atomic(path: &Path, data: &[u8]) -> anyhow::Result<()> {
71    let parent = path
72        .parent()
73        .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", path.display()))?;
74    fs::create_dir_all(parent)?;
75    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
76    tmp.write_all(data)?;
77    tmp.flush()?;
78    tmp.persist(path)?;
79    Ok(())
80}
81
82/// File-based exclusive lock guard.
83pub struct LockGuard {
84    _file: fs::File,
85}
86
87/// Acquire an exclusive file lock. Creates the lock file if needed.
88pub fn lock_file(path: &Path) -> anyhow::Result<LockGuard> {
89    if let Some(parent) = path.parent() {
90        fs::create_dir_all(parent)?;
91    }
92    let file = fs::OpenOptions::new()
93        .create(true)
94        .truncate(false)
95        .write(true)
96        .open(path)?;
97    file.lock_exclusive()?;
98    Ok(LockGuard { _file: file })
99}
100
101/// Serialize tests that mutate `EDDA_STORE_ROOT` env var to avoid races.
102#[cfg(test)]
103pub(crate) static ENV_STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn project_id_is_deterministic() {
111        let id1 = project_id(Path::new("/tmp/test-repo"));
112        let id2 = project_id(Path::new("/tmp/test-repo"));
113        assert_eq!(id1, id2);
114        assert_eq!(id1.len(), 32);
115        assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
116    }
117
118    #[test]
119    fn store_root_is_not_empty() {
120        let root = store_root();
121        assert!(!root.as_os_str().is_empty());
122    }
123
124    #[test]
125    fn ensure_dirs_creates_subdirs() {
126        let tmp = tempfile::tempdir().unwrap();
127        // Override store root by using project_dir directly
128        let base = tmp.path().join("projects").join("test_proj");
129        let subdirs = ["ledger", "transcripts", "index", "packs", "state", "search"];
130        for sub in &subdirs {
131            fs::create_dir_all(base.join(sub)).unwrap();
132        }
133        for sub in &subdirs {
134            assert!(base.join(sub).is_dir());
135        }
136    }
137
138    #[test]
139    fn write_atomic_creates_file() {
140        let tmp = tempfile::tempdir().unwrap();
141        let path = tmp.path().join("test.txt");
142        write_atomic(&path, b"hello world").unwrap();
143        assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
144    }
145
146    #[test]
147    fn lock_file_acquires_and_drops() {
148        let tmp = tempfile::tempdir().unwrap();
149        let lock_path = tmp.path().join("test.lock");
150        let guard = lock_file(&lock_path).unwrap();
151        assert!(lock_path.exists());
152        drop(guard);
153    }
154
155    #[test]
156    fn worktree_and_main_produce_same_project_id() {
157        let tmp = tempfile::tempdir().unwrap();
158        let repo = tmp.path().join("repo");
159        fs::create_dir_all(repo.join(".git").join("worktrees").join("feat-x")).unwrap();
160
161        let wt = repo.join(".claude").join("worktrees").join("feat-x");
162        fs::create_dir_all(&wt).unwrap();
163        let gitdir = repo.join(".git").join("worktrees").join("feat-x");
164        let gitdir_str = gitdir.to_string_lossy().replace('\\', "/");
165        fs::write(wt.join(".git"), format!("gitdir: {gitdir_str}")).unwrap();
166
167        let id_main = project_id(&repo);
168        let id_wt = project_id(&wt);
169        assert_eq!(
170            id_main, id_wt,
171            "worktree and main tree must have same project_id"
172        );
173    }
174}