Skip to main content

edda_ledger/
paths.rs

1use std::path::{Path, PathBuf};
2
3/// All well-known paths under `.edda/`.
4#[derive(Debug, Clone)]
5pub struct EddaPaths {
6    pub root: PathBuf,
7    pub edda_dir: PathBuf,
8    pub ledger_dir: PathBuf,
9    pub ledger_db: PathBuf,
10    pub blobs_dir: PathBuf,
11    pub branches_dir: PathBuf,
12    pub drafts_dir: PathBuf,
13    pub lock_file: PathBuf,
14    pub config_json: PathBuf,
15    pub patterns_dir: PathBuf,
16    pub blob_meta_json: PathBuf,
17    pub tombstones_jsonl: PathBuf,
18    pub archive_dir: PathBuf,
19    pub archive_blobs_dir: PathBuf,
20}
21
22impl EddaPaths {
23    /// Derive all paths from a repo root. Pure computation, no I/O.
24    pub fn discover(repo_root: impl Into<PathBuf>) -> Self {
25        let root = repo_root.into();
26        let edda_dir = root.join(".edda");
27        let ledger_dir = edda_dir.join("ledger");
28        let archive_dir = edda_dir.join("archive");
29        Self {
30            ledger_db: edda_dir.join("ledger.db"),
31            blobs_dir: ledger_dir.join("blobs"),
32            blob_meta_json: ledger_dir.join("blob_meta.json"),
33            tombstones_jsonl: ledger_dir.join("tombstones.jsonl"),
34            branches_dir: edda_dir.join("branches"),
35            drafts_dir: edda_dir.join("drafts"),
36            lock_file: edda_dir.join("LOCK"),
37            config_json: edda_dir.join("config.json"),
38            patterns_dir: edda_dir.join("patterns"),
39            archive_blobs_dir: archive_dir.join("blobs"),
40            archive_dir,
41            ledger_dir,
42            edda_dir,
43            root,
44        }
45    }
46
47    /// Create all required directories. Idempotent.
48    pub fn ensure_layout(&self) -> anyhow::Result<()> {
49        for dir in [
50            &self.ledger_dir,
51            &self.blobs_dir,
52            &self.branches_dir,
53            &self.drafts_dir,
54            &self.patterns_dir,
55        ] {
56            std::fs::create_dir_all(dir)?;
57        }
58        Ok(())
59    }
60
61    /// Check whether `.edda/` exists.
62    pub fn is_initialized(&self) -> bool {
63        self.edda_dir.is_dir()
64    }
65
66    /// Resolve a branch directory under `.edda/branches/<name>/`.
67    pub fn branch_dir(&self, name: &str) -> PathBuf {
68        self.branches_dir.join(name)
69    }
70}
71
72impl EddaPaths {
73    /// Walk up from `start` looking for a directory containing `.edda/`.
74    ///
75    /// If the walk-up fails, falls back to git worktree resolution:
76    /// reads the `.git` file to find the main repo root, then checks
77    /// whether `.edda/` exists there.
78    ///
79    /// Returns `None` if not found by either method.
80    pub fn find_root(start: &Path) -> Option<PathBuf> {
81        // Phase 1: Walk up looking for .edda/ (fast path)
82        let mut cur = start.to_path_buf();
83        loop {
84            if cur.join(".edda").is_dir() {
85                return Some(cur);
86            }
87            if !cur.pop() {
88                break;
89            }
90        }
91
92        // Phase 2: Git worktree fallback — resolve to main repo, check .edda/ there
93        edda_core::git::resolve_git_root(start).filter(|root| root.join(".edda").is_dir())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn discover_builds_correct_paths() {
103        let p = EddaPaths::discover("/tmp/repo");
104        assert_eq!(p.edda_dir, PathBuf::from("/tmp/repo/.edda"));
105        assert_eq!(p.blobs_dir, PathBuf::from("/tmp/repo/.edda/ledger/blobs"));
106        assert_eq!(p.lock_file, PathBuf::from("/tmp/repo/.edda/LOCK"));
107        assert_eq!(p.patterns_dir, PathBuf::from("/tmp/repo/.edda/patterns"));
108        assert_eq!(
109            p.blob_meta_json,
110            PathBuf::from("/tmp/repo/.edda/ledger/blob_meta.json")
111        );
112        assert_eq!(
113            p.tombstones_jsonl,
114            PathBuf::from("/tmp/repo/.edda/ledger/tombstones.jsonl")
115        );
116        assert_eq!(p.archive_dir, PathBuf::from("/tmp/repo/.edda/archive"));
117        assert_eq!(
118            p.archive_blobs_dir,
119            PathBuf::from("/tmp/repo/.edda/archive/blobs")
120        );
121    }
122
123    #[test]
124    fn ensure_layout_creates_dirs() {
125        let tmp = std::env::temp_dir().join(format!("edda_test_{}", std::process::id()));
126        let _ = std::fs::remove_dir_all(&tmp);
127        let p = EddaPaths::discover(&tmp);
128        p.ensure_layout().unwrap();
129        assert!(p.ledger_dir.is_dir());
130        assert!(p.blobs_dir.is_dir());
131        assert!(p.branches_dir.is_dir());
132        assert!(p.drafts_dir.is_dir());
133        assert!(p.patterns_dir.is_dir());
134        let _ = std::fs::remove_dir_all(&tmp);
135    }
136
137    use std::sync::atomic::{AtomicU64, Ordering};
138    static PATH_TEST_CTR: AtomicU64 = AtomicU64::new(0);
139
140    fn unique_tmp(label: &str) -> PathBuf {
141        let n = PATH_TEST_CTR.fetch_add(1, Ordering::SeqCst);
142        std::env::temp_dir().join(format!("edda_path_{label}_{}_{n}", std::process::id()))
143    }
144
145    #[test]
146    fn find_root_walks_up_to_edda_dir() {
147        let tmp = unique_tmp("walkup");
148        let _ = std::fs::remove_dir_all(&tmp);
149        // repo/.edda/ exists, start from repo/sub/deep/
150        std::fs::create_dir_all(tmp.join(".edda")).unwrap();
151        let deep = tmp.join("sub").join("deep");
152        std::fs::create_dir_all(&deep).unwrap();
153
154        let found = EddaPaths::find_root(&deep);
155        assert_eq!(found.unwrap(), tmp);
156        let _ = std::fs::remove_dir_all(&tmp);
157    }
158
159    #[test]
160    fn find_root_worktree_outside_repo() {
161        // Simulate: main repo at repo/ with .edda/ and .git/
162        // Worktree at wt/ with .git file pointing back
163        let tmp = unique_tmp("wt_outside");
164        let _ = std::fs::remove_dir_all(&tmp);
165        let repo = tmp.join("repo");
166        let wt = tmp.join("wt");
167
168        // Main repo: .git/ directory + .edda/ workspace
169        std::fs::create_dir_all(repo.join(".git").join("worktrees").join("feat-x")).unwrap();
170        std::fs::create_dir_all(repo.join(".edda")).unwrap();
171
172        // Worktree: .git file pointing to main repo's worktree gitdir
173        std::fs::create_dir_all(&wt).unwrap();
174        let gitdir = repo.join(".git").join("worktrees").join("feat-x");
175        let gitdir_str = gitdir
176            .canonicalize()
177            .unwrap()
178            .to_string_lossy()
179            .replace('\\', "/");
180        std::fs::write(wt.join(".git"), format!("gitdir: {gitdir_str}")).unwrap();
181
182        let found = EddaPaths::find_root(&wt);
183        assert!(found.is_some(), "should resolve worktree to main repo");
184        // Resolved root should contain .edda/
185        assert!(found.unwrap().join(".edda").is_dir());
186        let _ = std::fs::remove_dir_all(&tmp);
187    }
188
189    #[test]
190    fn find_root_non_git_no_edda_returns_none() {
191        let tmp = unique_tmp("no_git");
192        let _ = std::fs::remove_dir_all(&tmp);
193        std::fs::create_dir_all(&tmp).unwrap();
194
195        assert!(EddaPaths::find_root(&tmp).is_none());
196        let _ = std::fs::remove_dir_all(&tmp);
197    }
198}