Skip to main content

edda_ledger/
ledger.rs

1use crate::paths::EddaPaths;
2use crate::sqlite_store::{DecisionRow, SqliteStore};
3use edda_core::Event;
4use std::path::Path;
5
6/// The append-only event ledger (SQLite backend).
7pub struct Ledger {
8    pub paths: EddaPaths,
9    sqlite: SqliteStore,
10}
11
12impl Ledger {
13    /// Open an existing workspace. Fails if `.edda/` does not exist.
14    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    /// Open a workspace, auto-initializing `.edda/` if missing.
27    ///
28    /// Use this for read-path consumers (e.g. `edda watch`) that should
29    /// work without requiring the user to run `edda init` first.
30    ///
31    /// This is a **lightweight init** — it only creates the ledger directory
32    /// layout and SQLite DB. Config files (`policy.yaml`, `actors.yaml`) and
33    /// bridge hooks are NOT created; those require `edda init`.
34    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    /// Ensure `.edda/` and ledger exist, without returning a Ledger handle.
46    ///
47    /// Use this when you only need the side effect (workspace creation)
48    /// and will open the ledger separately later.
49    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    /// Convenience: open from a Path ref (avoids Into<PathBuf> ambiguity).
61    pub fn open_path(repo_root: &Path) -> anyhow::Result<Self> {
62        Self::open(repo_root.to_path_buf())
63    }
64
65    // ── HEAD branch ─────────────────────────────────────────────────
66
67    /// Read the current HEAD branch name.
68    pub fn head_branch(&self) -> anyhow::Result<String> {
69        self.sqlite.head_branch()
70    }
71
72    /// Write the HEAD branch name.
73    pub fn set_head_branch(&self, name: &str) -> anyhow::Result<()> {
74        self.sqlite.set_head_branch(name)
75    }
76
77    // ── Events ──────────────────────────────────────────────────────
78
79    /// Append an event to the ledger. Append-only (CONTRACT LEDGER-02).
80    pub fn append_event(&self, event: &Event) -> anyhow::Result<()> {
81        self.sqlite.append_event(event)
82    }
83
84    /// Get the hash of the last event, or `None` if the ledger is empty.
85    pub fn last_event_hash(&self) -> anyhow::Result<Option<String>> {
86        self.sqlite.last_event_hash()
87    }
88
89    /// Read all events in the ledger.
90    pub fn iter_events(&self) -> anyhow::Result<Vec<Event>> {
91        self.sqlite.iter_events()
92    }
93
94    // ── Branches JSON ───────────────────────────────────────────────
95
96    /// Read branches.json content.
97    pub fn branches_json(&self) -> anyhow::Result<serde_json::Value> {
98        self.sqlite.branches_json()
99    }
100
101    /// Write branches.json content.
102    pub fn set_branches_json(&self, value: &serde_json::Value) -> anyhow::Result<()> {
103        self.sqlite.set_branches_json(value)
104    }
105
106    // ── Decisions ───────────────────────────────────────────────────
107
108    /// Query active decisions, optionally filtered by domain or key pattern.
109    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    /// All decisions for a key (active + superseded), ordered by time.
118    pub fn decision_timeline(&self, key: &str) -> anyhow::Result<Vec<DecisionRow>> {
119        self.sqlite.decision_timeline(key)
120    }
121
122    /// All decisions for a domain (active + superseded), ordered by time.
123    pub fn domain_timeline(&self, domain: &str) -> anyhow::Result<Vec<DecisionRow>> {
124        self.sqlite.domain_timeline(domain)
125    }
126
127    /// Distinct domain values from active decisions.
128    pub fn list_domains(&self) -> anyhow::Result<Vec<String>> {
129        self.sqlite.list_domains()
130    }
131
132    /// Find the active decision for a specific key on a branch.
133    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
142// ── Init functions ──────────────────────────────────────────────────
143
144/// Initialize a new workspace from `EddaPaths`. Used by `cmd_init`.
145///
146/// Creates the directory layout AND a fresh `ledger.db` with schema.
147pub 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
154/// Write the initial HEAD into SQLite.
155pub 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
163/// Write initial branches.json into SQLite.
164pub 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        // No .edda/ exists yet
269        assert!(!tmp.join(".edda").exists());
270
271        // open_or_init should create it
272        let ledger = Ledger::open_or_init(&tmp).unwrap();
273        assert!(tmp.join(".edda").exists());
274        assert_eq!(ledger.head_branch().unwrap(), "main");
275
276        // Second call is idempotent
277        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}