1use crate::paths::EddaPaths;
2use crate::sqlite_store::{DecisionRow, SqliteStore};
3use edda_core::Event;
4use std::path::Path;
5
6pub struct Ledger {
8 pub paths: EddaPaths,
9 sqlite: SqliteStore,
10}
11
12impl Ledger {
13 pub fn open(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
15 let paths = EddaPaths::discover(repo_root);
16 if !paths.is_initialized() {
17 anyhow::bail!(
18 "not a edda workspace ({}/.edda not found). Run `edda init` first.",
19 paths.root.display()
20 );
21 }
22 let sqlite = SqliteStore::open_or_create(&paths.ledger_db)?;
23 Ok(Self { paths, sqlite })
24 }
25
26 pub fn open_or_init(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
35 let root = repo_root.into();
36 let paths = EddaPaths::discover(&root);
37 if !paths.is_initialized() {
38 init_workspace(&paths)?;
39 init_head(&paths, "main")?;
40 init_branches_json(&paths, "main")?;
41 }
42 Self::open(root)
43 }
44
45 pub fn ensure_initialized(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<()> {
50 let root = repo_root.into();
51 let paths = EddaPaths::discover(&root);
52 if !paths.is_initialized() {
53 init_workspace(&paths)?;
54 init_head(&paths, "main")?;
55 init_branches_json(&paths, "main")?;
56 }
57 Ok(())
58 }
59
60 pub fn open_path(repo_root: &Path) -> anyhow::Result<Self> {
62 Self::open(repo_root.to_path_buf())
63 }
64
65 pub fn head_branch(&self) -> anyhow::Result<String> {
69 self.sqlite.head_branch()
70 }
71
72 pub fn set_head_branch(&self, name: &str) -> anyhow::Result<()> {
74 self.sqlite.set_head_branch(name)
75 }
76
77 pub fn append_event(&self, event: &Event) -> anyhow::Result<()> {
81 self.sqlite.append_event(event)
82 }
83
84 pub fn last_event_hash(&self) -> anyhow::Result<Option<String>> {
86 self.sqlite.last_event_hash()
87 }
88
89 pub fn iter_events(&self) -> anyhow::Result<Vec<Event>> {
91 self.sqlite.iter_events()
92 }
93
94 pub fn branches_json(&self) -> anyhow::Result<serde_json::Value> {
98 self.sqlite.branches_json()
99 }
100
101 pub fn set_branches_json(&self, value: &serde_json::Value) -> anyhow::Result<()> {
103 self.sqlite.set_branches_json(value)
104 }
105
106 pub fn active_decisions(
110 &self,
111 domain: Option<&str>,
112 key_pattern: Option<&str>,
113 ) -> anyhow::Result<Vec<DecisionRow>> {
114 self.sqlite.active_decisions(domain, key_pattern)
115 }
116
117 pub fn decision_timeline(&self, key: &str) -> anyhow::Result<Vec<DecisionRow>> {
119 self.sqlite.decision_timeline(key)
120 }
121
122 pub fn domain_timeline(&self, domain: &str) -> anyhow::Result<Vec<DecisionRow>> {
124 self.sqlite.domain_timeline(domain)
125 }
126
127 pub fn list_domains(&self) -> anyhow::Result<Vec<String>> {
129 self.sqlite.list_domains()
130 }
131
132 pub fn find_active_decision(
134 &self,
135 branch: &str,
136 key: &str,
137 ) -> anyhow::Result<Option<DecisionRow>> {
138 self.sqlite.find_active_decision(branch, key)
139 }
140}
141
142pub fn init_workspace(paths: &EddaPaths) -> anyhow::Result<()> {
148 paths.ensure_layout()?;
149 std::fs::create_dir_all(paths.branch_dir("main"))?;
150 SqliteStore::open_or_create(&paths.ledger_db)?;
151 Ok(())
152}
153
154pub fn init_head(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
156 let store = SqliteStore::open(&paths.ledger_db)?;
157 if store.head_branch().is_err() {
158 store.set_head_branch(branch)?;
159 }
160 Ok(())
161}
162
163pub fn init_branches_json(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
165 let now = time_now_rfc3339();
166 let json = serde_json::json!({
167 "branches": {
168 branch: {
169 "created_at": now
170 }
171 }
172 });
173 let store = SqliteStore::open(&paths.ledger_db)?;
174 if store.branches_json().is_err() {
175 store.set_branches_json(&json)?;
176 }
177 Ok(())
178}
179
180fn time_now_rfc3339() -> String {
181 let now = time::OffsetDateTime::now_utc();
182 now.format(&time::format_description::well_known::Rfc3339)
183 .expect("RFC3339 formatting should not fail")
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use edda_core::event::new_note_event;
190 use std::sync::atomic::{AtomicU64, Ordering};
191
192 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
193
194 fn setup_workspace() -> (std::path::PathBuf, Ledger) {
195 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
196 let tmp = std::env::temp_dir().join(format!("edda_ledger_test_{}_{n}", std::process::id()));
197 let _ = std::fs::remove_dir_all(&tmp);
198 let paths = EddaPaths::discover(&tmp);
199 init_workspace(&paths).unwrap();
200 init_head(&paths, "main").unwrap();
201 init_branches_json(&paths, "main").unwrap();
202 let ledger = Ledger::open(&tmp).unwrap();
203 (tmp, ledger)
204 }
205
206 #[test]
207 fn empty_ledger_has_no_hash() {
208 let (tmp, ledger) = setup_workspace();
209 assert_eq!(ledger.last_event_hash().unwrap(), None);
210 let _ = std::fs::remove_dir_all(&tmp);
211 }
212
213 #[test]
214 fn append_and_read_back() {
215 let (tmp, ledger) = setup_workspace();
216 let e1 = new_note_event("main", None, "system", "init", &[]).unwrap();
217 ledger.append_event(&e1).unwrap();
218 assert_eq!(ledger.last_event_hash().unwrap(), Some(e1.hash.clone()));
219
220 let e2 = new_note_event("main", Some(&e1.hash), "user", "hello", &[]).unwrap();
221 ledger.append_event(&e2).unwrap();
222 assert_eq!(ledger.last_event_hash().unwrap(), Some(e2.hash.clone()));
223
224 let events = ledger.iter_events().unwrap();
225 assert_eq!(events.len(), 2);
226 assert_eq!(events[0].event_id, e1.event_id);
227 assert_eq!(events[1].event_id, e2.event_id);
228 assert_eq!(events[1].parent_hash.as_deref(), Some(e1.hash.as_str()));
229
230 let _ = std::fs::remove_dir_all(&tmp);
231 }
232
233 #[test]
234 fn head_branch_read_write() {
235 let (tmp, ledger) = setup_workspace();
236 assert_eq!(ledger.head_branch().unwrap(), "main");
237 ledger.set_head_branch("feat/x").unwrap();
238 assert_eq!(ledger.head_branch().unwrap(), "feat/x");
239 let _ = std::fs::remove_dir_all(&tmp);
240 }
241
242 #[test]
243 fn branches_json_read_write() {
244 let (tmp, ledger) = setup_workspace();
245 let bj = ledger.branches_json().unwrap();
246 assert!(bj["branches"]["main"].is_object());
247
248 let new_json = serde_json::json!({
249 "branches": {
250 "main": { "created_at": "2026-01-01T00:00:00Z" },
251 "dev": { "created_at": "2026-02-01T00:00:00Z" }
252 }
253 });
254 ledger.set_branches_json(&new_json).unwrap();
255 let loaded = ledger.branches_json().unwrap();
256 assert_eq!(loaded, new_json);
257
258 let _ = std::fs::remove_dir_all(&tmp);
259 }
260
261 #[test]
262 fn open_or_init_creates_workspace() {
263 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
264 let tmp = std::env::temp_dir().join(format!("edda_auto_init_{}_{n}", std::process::id()));
265 let _ = std::fs::remove_dir_all(&tmp);
266 std::fs::create_dir_all(&tmp).unwrap();
267
268 assert!(!tmp.join(".edda").exists());
270
271 let ledger = Ledger::open_or_init(&tmp).unwrap();
273 assert!(tmp.join(".edda").exists());
274 assert_eq!(ledger.head_branch().unwrap(), "main");
275
276 let ledger2 = Ledger::open_or_init(&tmp).unwrap();
278 assert_eq!(ledger2.head_branch().unwrap(), "main");
279
280 let _ = std::fs::remove_dir_all(&tmp);
281 }
282
283 #[test]
284 fn open_without_init_fails() {
285 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
286 let tmp = std::env::temp_dir().join(format!("edda_no_init_{}_{n}", std::process::id()));
287 let _ = std::fs::remove_dir_all(&tmp);
288 std::fs::create_dir_all(&tmp).unwrap();
289 assert!(Ledger::open(&tmp).is_err());
290 let _ = std::fs::remove_dir_all(&tmp);
291 }
292}