Skip to main content

edda_store/
lib.rs

1use fs2::FileExt;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6/// Compute a deterministic project ID from a repo root or cwd path.
7/// project_id = blake3(normalize_path(input)) → hex string (first 32 chars).
8///
9/// If `repo_root_or_cwd` is inside a git worktree, resolves to the main
10/// repository root so that all worktrees share the same project ID.
11pub fn project_id(repo_root_or_cwd: &Path) -> String {
12    let resolved =
13        resolve_git_root(repo_root_or_cwd).unwrap_or_else(|| repo_root_or_cwd.to_path_buf());
14    let normalized = normalize_path(&resolved);
15    let hash = blake3::hash(normalized.as_bytes());
16    hash.to_hex()[..32].to_string()
17}
18
19/// Resolve the git repository root, handling worktrees.
20///
21/// Walks up from `start` looking for `.git`:
22/// - **Directory** → parent is the repo root (normal repo).
23/// - **File** with `gitdir: .../worktrees/{name}` → strip to find the common
24///   `.git` directory, then return its parent (the main working tree root).
25/// - **File** without `/worktrees/` (e.g. submodule) → return that directory.
26/// - **Not found** → returns `None` (non-git directory; caller uses original path).
27fn resolve_git_root(start: &Path) -> Option<PathBuf> {
28    let abs = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
29    let mut cur = abs.as_path();
30    loop {
31        let dot_git = cur.join(".git");
32        if dot_git.is_dir() {
33            return Some(cur.to_path_buf());
34        }
35        if dot_git.is_file() {
36            if let Ok(content) = fs::read_to_string(&dot_git) {
37                let content = content.trim();
38                if let Some(gitdir) = content.strip_prefix("gitdir:") {
39                    let gitdir = gitdir.trim().replace('\\', "/");
40                    if let Some(pos) = gitdir.find("/worktrees/") {
41                        // Worktree: gitdir points to .git/worktrees/{name}
42                        // Strip /worktrees/{name} to get the common .git dir,
43                        // then take its parent as the repo root.
44                        let common_git = &gitdir[..pos];
45                        return Path::new(common_git).parent().map(|p| p.to_path_buf());
46                    }
47                }
48            }
49            // .git file but not a worktree (e.g. submodule) → use this dir
50            return Some(cur.to_path_buf());
51        }
52        cur = cur.parent()?;
53    }
54}
55
56/// Normalize a path: canonicalize, lowercase on Windows, forward slashes.
57fn normalize_path(p: &Path) -> String {
58    let abs = p
59        .canonicalize()
60        .unwrap_or_else(|_| p.to_path_buf())
61        .to_string_lossy()
62        .to_string();
63    // Lowercase on Windows for consistency
64    #[cfg(windows)]
65    let abs = abs.to_lowercase();
66    // Normalize path separators to forward slashes
67    abs.replace('\\', "/")
68}
69
70/// Return the per-user store root: `~/.edda/`
71/// Windows: `%APPDATA%\edda\` (falls back to `%USERPROFILE%\.edda\`)
72pub fn store_root() -> PathBuf {
73    if let Some(data_dir) = dirs::data_dir() {
74        data_dir.join("edda")
75    } else if let Some(home) = dirs::home_dir() {
76        home.join(".edda")
77    } else {
78        PathBuf::from(".edda-store")
79    }
80}
81
82/// Return the project directory: `store_root/projects/<project_id>/`
83pub fn project_dir(project_id: &str) -> PathBuf {
84    store_root().join("projects").join(project_id)
85}
86
87/// Ensure all subdirectories exist for a project.
88pub fn ensure_dirs(project_id: &str) -> anyhow::Result<()> {
89    let base = project_dir(project_id);
90    let subdirs = ["ledger", "transcripts", "index", "packs", "state", "search"];
91    for sub in &subdirs {
92        fs::create_dir_all(base.join(sub))?;
93    }
94    Ok(())
95}
96
97/// Atomic write: write to temp file in same dir, then rename.
98pub fn write_atomic(path: &Path, data: &[u8]) -> anyhow::Result<()> {
99    let parent = path
100        .parent()
101        .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", path.display()))?;
102    fs::create_dir_all(parent)?;
103    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
104    tmp.write_all(data)?;
105    tmp.flush()?;
106    tmp.persist(path)?;
107    Ok(())
108}
109
110/// File-based exclusive lock guard.
111pub struct LockGuard {
112    _file: fs::File,
113}
114
115/// Acquire an exclusive file lock. Creates the lock file if needed.
116pub fn lock_file(path: &Path) -> anyhow::Result<LockGuard> {
117    if let Some(parent) = path.parent() {
118        fs::create_dir_all(parent)?;
119    }
120    let file = fs::OpenOptions::new()
121        .create(true)
122        .truncate(false)
123        .write(true)
124        .open(path)?;
125    file.lock_exclusive()?;
126    Ok(LockGuard { _file: file })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn project_id_is_deterministic() {
135        let id1 = project_id(Path::new("/tmp/test-repo"));
136        let id2 = project_id(Path::new("/tmp/test-repo"));
137        assert_eq!(id1, id2);
138        assert_eq!(id1.len(), 32);
139        assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
140    }
141
142    #[test]
143    fn store_root_is_not_empty() {
144        let root = store_root();
145        assert!(!root.as_os_str().is_empty());
146    }
147
148    #[test]
149    fn ensure_dirs_creates_subdirs() {
150        let tmp = tempfile::tempdir().unwrap();
151        // Override store root by using project_dir directly
152        let base = tmp.path().join("projects").join("test_proj");
153        let subdirs = ["ledger", "transcripts", "index", "packs", "state", "search"];
154        for sub in &subdirs {
155            fs::create_dir_all(base.join(sub)).unwrap();
156        }
157        for sub in &subdirs {
158            assert!(base.join(sub).is_dir());
159        }
160    }
161
162    #[test]
163    fn write_atomic_creates_file() {
164        let tmp = tempfile::tempdir().unwrap();
165        let path = tmp.path().join("test.txt");
166        write_atomic(&path, b"hello world").unwrap();
167        assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
168    }
169
170    #[test]
171    fn lock_file_acquires_and_drops() {
172        let tmp = tempfile::tempdir().unwrap();
173        let lock_path = tmp.path().join("test.lock");
174        let guard = lock_file(&lock_path).unwrap();
175        assert!(lock_path.exists());
176        drop(guard);
177    }
178
179    #[test]
180    fn resolve_git_root_normal_repo() {
181        let tmp = tempfile::tempdir().unwrap();
182        let repo = tmp.path().join("my-repo");
183        fs::create_dir_all(repo.join(".git")).unwrap();
184
185        let result = resolve_git_root(&repo);
186        assert_eq!(result.unwrap(), repo.canonicalize().unwrap());
187    }
188
189    #[test]
190    fn resolve_git_root_worktree() {
191        let tmp = tempfile::tempdir().unwrap();
192        // Simulate: repo/.git/ (directory) + repo/.claude/worktrees/feat-x/.git (file)
193        let repo = tmp.path().join("repo");
194        fs::create_dir_all(repo.join(".git").join("worktrees").join("feat-x")).unwrap();
195
196        let wt = repo.join(".claude").join("worktrees").join("feat-x");
197        fs::create_dir_all(&wt).unwrap();
198
199        // Write .git file pointing to the worktree gitdir
200        let gitdir = repo.join(".git").join("worktrees").join("feat-x");
201        let gitdir_str = gitdir.to_string_lossy().replace('\\', "/");
202        fs::write(wt.join(".git"), format!("gitdir: {gitdir_str}")).unwrap();
203
204        let resolved = resolve_git_root(&wt).unwrap();
205        assert_eq!(
206            normalize_path(&resolved),
207            normalize_path(&repo),
208            "worktree should resolve to repo root"
209        );
210    }
211
212    #[test]
213    fn worktree_and_main_produce_same_project_id() {
214        let tmp = tempfile::tempdir().unwrap();
215        let repo = tmp.path().join("repo");
216        fs::create_dir_all(repo.join(".git").join("worktrees").join("feat-x")).unwrap();
217
218        let wt = repo.join(".claude").join("worktrees").join("feat-x");
219        fs::create_dir_all(&wt).unwrap();
220        let gitdir = repo.join(".git").join("worktrees").join("feat-x");
221        let gitdir_str = gitdir.to_string_lossy().replace('\\', "/");
222        fs::write(wt.join(".git"), format!("gitdir: {gitdir_str}")).unwrap();
223
224        let id_main = project_id(&repo);
225        let id_wt = project_id(&wt);
226        assert_eq!(
227            id_main, id_wt,
228            "worktree and main tree must have same project_id"
229        );
230    }
231
232    #[test]
233    fn resolve_git_root_submodule_no_worktree_resolution() {
234        let tmp = tempfile::tempdir().unwrap();
235        let sub = tmp.path().join("parent").join("submodule");
236        fs::create_dir_all(&sub).unwrap();
237        // Submodule .git file has /modules/ not /worktrees/
238        fs::write(sub.join(".git"), "gitdir: ../../.git/modules/submodule").unwrap();
239
240        let resolved = resolve_git_root(&sub).unwrap();
241        // Should resolve to the submodule dir itself (not the parent repo)
242        assert_eq!(resolved, sub.canonicalize().unwrap());
243    }
244
245    #[test]
246    fn resolve_git_root_non_git_returns_none() {
247        let tmp = tempfile::tempdir().unwrap();
248        let dir = tmp.path().join("not-a-repo");
249        fs::create_dir_all(&dir).unwrap();
250
251        assert!(resolve_git_root(&dir).is_none());
252    }
253}