1use fs2::FileExt;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6pub 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
19fn 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 let common_git = &gitdir[..pos];
45 return Path::new(common_git).parent().map(|p| p.to_path_buf());
46 }
47 }
48 }
49 return Some(cur.to_path_buf());
51 }
52 cur = cur.parent()?;
53 }
54}
55
56fn 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 #[cfg(windows)]
65 let abs = abs.to_lowercase();
66 abs.replace('\\', "/")
68}
69
70pub 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
82pub fn project_dir(project_id: &str) -> PathBuf {
84 store_root().join("projects").join(project_id)
85}
86
87pub 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
97pub 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
110pub struct LockGuard {
112 _file: fs::File,
113}
114
115pub 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 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 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 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 fs::write(sub.join(".git"), "gitdir: ../../.git/modules/submodule").unwrap();
239
240 let resolved = resolve_git_root(&sub).unwrap();
241 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}