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
10pub 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
23fn 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 #[cfg(windows)]
32 let abs = abs.to_lowercase();
33 abs.replace('\\', "/")
35}
36
37pub 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
54pub fn project_dir(project_id: &str) -> PathBuf {
56 store_root().join("projects").join(project_id)
57}
58
59pub 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
69pub 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
82pub struct LockGuard {
84 _file: fs::File,
85}
86
87pub 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#[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 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}