1use crate::paths::EddaPaths;
2use crate::sqlite_store::{BundleRow, SqliteStore};
3use crate::view::{self, DecisionView};
4use anyhow::Context;
5use edda_core::Event;
6use std::path::Path;
7
8pub struct Ledger {
10 pub paths: EddaPaths,
11 pub(crate) sqlite: SqliteStore,
12}
13
14impl Ledger {
15 pub fn open(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
17 let paths = EddaPaths::discover(repo_root);
18 if !paths.is_initialized() {
19 anyhow::bail!(
20 "not an edda workspace ({}/.edda not found). Run `edda init` first.",
21 paths.root.display()
22 );
23 }
24 let sqlite = SqliteStore::open_or_create(&paths.ledger_db)?;
25 Ok(Self { paths, sqlite })
26 }
27
28 pub fn open_or_init(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<Self> {
37 let root = repo_root.into();
38 let paths = EddaPaths::discover(&root);
39 if !paths.is_initialized() {
40 init_workspace(&paths)?;
41 init_head(&paths, "main")?;
42 init_branches_json(&paths, "main")?;
43 }
44 Self::open(root)
45 }
46
47 pub fn ensure_initialized(repo_root: impl Into<std::path::PathBuf>) -> anyhow::Result<()> {
52 let root = repo_root.into();
53 let paths = EddaPaths::discover(&root);
54 if !paths.is_initialized() {
55 init_workspace(&paths)?;
56 init_head(&paths, "main")?;
57 init_branches_json(&paths, "main")?;
58 }
59 Ok(())
60 }
61
62 pub fn open_path(repo_root: &Path) -> anyhow::Result<Self> {
64 Self::open(repo_root.to_path_buf())
65 }
66
67 pub fn head_branch(&self) -> anyhow::Result<String> {
71 self.sqlite.head_branch().context("Ledger::head_branch")
72 }
73
74 pub fn set_head_branch(&self, name: &str) -> anyhow::Result<()> {
76 self.sqlite
77 .set_head_branch(name)
78 .context("Ledger::set_head_branch")
79 }
80
81 pub fn append_event(&self, event: &Event) -> anyhow::Result<()> {
85 self.sqlite
86 .append_event(event)
87 .with_context(|| format!("Ledger::append_event({})", event.event_id))
88 }
89
90 pub fn append_event_idempotent(&self, event: &Event) -> anyhow::Result<bool> {
92 self.sqlite
93 .append_event_idempotent(event)
94 .with_context(|| format!("Ledger::append_event_idempotent({})", event.event_id))
95 }
96
97 pub fn last_event_hash(&self) -> anyhow::Result<Option<String>> {
99 self.sqlite
100 .last_event_hash()
101 .context("Ledger::last_event_hash")
102 }
103
104 pub fn iter_events(&self) -> anyhow::Result<Vec<Event>> {
106 self.sqlite.iter_events().context("Ledger::iter_events")
107 }
108
109 pub fn get_event(&self, event_id: &str) -> anyhow::Result<Option<Event>> {
111 self.sqlite
112 .get_event(event_id)
113 .with_context(|| format!("Ledger::get_event({event_id})"))
114 }
115
116 pub fn iter_events_by_type(&self, event_type: &str) -> anyhow::Result<Vec<Event>> {
118 self.sqlite
119 .iter_events_by_type(event_type)
120 .with_context(|| format!("Ledger::iter_events_by_type({event_type})"))
121 }
122
123 pub fn iter_branch_events(&self, branch: &str) -> anyhow::Result<Vec<Event>> {
125 self.sqlite
126 .iter_branch_events(branch)
127 .with_context(|| format!("Ledger::iter_branch_events({branch})"))
128 }
129
130 pub fn iter_events_filtered(
133 &self,
134 branch: &str,
135 event_type: Option<&str>,
136 keyword: Option<&str>,
137 after: Option<&str>,
138 before: Option<&str>,
139 limit: usize,
140 ) -> anyhow::Result<Vec<Event>> {
141 self.sqlite
142 .iter_events_filtered(branch, event_type, keyword, after, before, limit)
143 .with_context(|| format!("Ledger::iter_events_filtered(branch={branch})"))
144 }
145
146 pub fn find_related_commits(
148 &self,
149 branch: Option<&str>,
150 keyword: &str,
151 decision_event_ids: &[&str],
152 limit: usize,
153 ) -> anyhow::Result<Vec<Event>> {
154 self.sqlite
155 .find_related_commits(branch, keyword, decision_event_ids, limit)
156 .context("Ledger::find_related_commits")
157 }
158
159 pub fn find_related_notes(
161 &self,
162 branch: Option<&str>,
163 keyword: &str,
164 limit: usize,
165 ) -> anyhow::Result<Vec<Event>> {
166 self.sqlite
167 .find_related_notes(branch, keyword, limit)
168 .context("Ledger::find_related_notes")
169 }
170
171 pub fn events_after_rowid(&self, after_rowid: i64) -> anyhow::Result<Vec<(i64, Event)>> {
176 self.sqlite
177 .events_after_rowid(after_rowid)
178 .context("Ledger::events_after_rowid")
179 }
180
181 pub fn rowid_for_event_id(&self, event_id: &str) -> anyhow::Result<Option<i64>> {
183 self.sqlite
184 .rowid_for_event_id(event_id)
185 .with_context(|| format!("Ledger::rowid_for_event_id({event_id})"))
186 }
187
188 pub fn branches_json(&self) -> anyhow::Result<serde_json::Value> {
192 self.sqlite.branches_json().context("Ledger::branches_json")
193 }
194
195 pub fn set_branches_json(&self, value: &serde_json::Value) -> anyhow::Result<()> {
197 self.sqlite
198 .set_branches_json(value)
199 .context("Ledger::set_branches_json")
200 }
201
202 pub fn active_decisions(
207 &self,
208 domain: Option<&str>,
209 key_pattern: Option<&str>,
210 after: Option<&str>,
211 before: Option<&str>,
212 ) -> anyhow::Result<Vec<DecisionView>> {
213 let rows = self
214 .sqlite
215 .active_decisions(domain, key_pattern, after, before, None)
216 .with_context(|| format!("Ledger::active_decisions(domain={domain:?})"))?;
217 Ok(rows.iter().map(view::to_view).collect())
218 }
219
220 pub fn active_decisions_limited(
222 &self,
223 domain: Option<&str>,
224 key_pattern: Option<&str>,
225 after: Option<&str>,
226 before: Option<&str>,
227 limit: usize,
228 ) -> anyhow::Result<Vec<DecisionView>> {
229 let rows = self
230 .sqlite
231 .active_decisions(domain, key_pattern, after, before, Some(limit))
232 .with_context(|| format!("Ledger::active_decisions_limited(domain={domain:?})"))?;
233 Ok(rows.iter().map(view::to_view).collect())
234 }
235
236 pub fn decision_timeline(
239 &self,
240 key: &str,
241 after: Option<&str>,
242 before: Option<&str>,
243 ) -> anyhow::Result<Vec<DecisionView>> {
244 let rows = self
245 .sqlite
246 .decision_timeline(key, after, before)
247 .with_context(|| format!("Ledger::decision_timeline(key={key})"))?;
248 Ok(rows.iter().map(view::to_view).collect())
249 }
250
251 pub fn domain_timeline(
254 &self,
255 domain: &str,
256 after: Option<&str>,
257 before: Option<&str>,
258 ) -> anyhow::Result<Vec<DecisionView>> {
259 let rows = self
260 .sqlite
261 .domain_timeline(domain, after, before)
262 .with_context(|| format!("Ledger::domain_timeline(domain={domain})"))?;
263 Ok(rows.iter().map(view::to_view).collect())
264 }
265
266 pub fn list_domains(&self) -> anyhow::Result<Vec<String>> {
268 self.sqlite.list_domains().context("Ledger::list_domains")
269 }
270
271 pub fn village_stats(
273 &self,
274 village_id: &str,
275 after: Option<&str>,
276 before: Option<&str>,
277 ) -> anyhow::Result<crate::domain::VillageStats> {
278 self.sqlite
279 .village_stats(village_id, after, before)
280 .with_context(|| format!("Ledger::village_stats({village_id})"))
281 }
282
283 pub fn detect_village_patterns(
285 &self,
286 village_id: &str,
287 after: &str,
288 min_occurrences: usize,
289 ) -> anyhow::Result<Vec<crate::domain::DetectedPattern>> {
290 self.sqlite
291 .detect_village_patterns(village_id, after, min_occurrences)
292 .with_context(|| format!("Ledger::detect_village_patterns({village_id})"))
293 }
294
295 pub fn find_active_decision(
297 &self,
298 branch: &str,
299 key: &str,
300 ) -> anyhow::Result<Option<DecisionView>> {
301 let row = self
302 .sqlite
303 .find_active_decision(branch, key)
304 .with_context(|| format!("Ledger::find_active_decision(branch={branch}, key={key})"))?;
305 Ok(row.as_ref().map(view::to_view))
306 }
307
308 pub fn query_active_with_paths(
311 &self,
312 branch: Option<&str>,
313 limit: Option<usize>,
314 ) -> anyhow::Result<Vec<DecisionView>> {
315 let rows = self
316 .sqlite
317 .active_decisions_with_paths(branch, limit)
318 .context("Ledger::query_active_with_paths")?;
319 Ok(rows.iter().map(view::to_view).collect())
320 }
321
322 pub fn query_by_paths(
333 &self,
334 paths: &[&str],
335 branch: Option<&str>,
336 limit: Option<usize>,
337 ) -> anyhow::Result<Vec<DecisionView>> {
338 let candidates = self.query_active_with_paths(branch, None)?;
340
341 let mut matched: Vec<DecisionView> = Vec::new();
343 for decision in candidates {
344 let dominated = decision.affected_paths.iter().any(|glob_pattern| {
345 match globset::Glob::new(glob_pattern) {
346 Ok(glob) => {
347 let matcher = glob.compile_matcher();
348 paths.iter().any(|p| matcher.is_match(p))
349 }
350 Err(_) => false, }
352 });
353 if dominated {
354 matched.push(decision);
355 }
356 if let Some(lim) = limit {
357 if matched.len() >= lim {
358 break;
359 }
360 }
361 }
362
363 Ok(matched)
364 }
365
366 pub fn shared_decisions(&self) -> anyhow::Result<Vec<DecisionView>> {
370 let rows = self
371 .sqlite
372 .shared_decisions()
373 .context("Ledger::shared_decisions")?;
374 Ok(rows.iter().map(view::to_view).collect())
375 }
376
377 pub fn is_already_imported(
379 &self,
380 source_project_id: &str,
381 source_event_id: &str,
382 ) -> anyhow::Result<bool> {
383 self.sqlite
384 .is_already_imported(source_project_id, source_event_id)
385 .context("Ledger::is_already_imported")
386 }
387
388 pub fn insert_imported_decision(&self, params: crate::ImportParams<'_>) -> anyhow::Result<()> {
390 self.sqlite
391 .insert_imported_decision(params)
392 .context("Ledger::insert_imported_decision")
393 }
394
395 pub fn insert_dep(
399 &self,
400 source_key: &str,
401 target_key: &str,
402 dep_type: &str,
403 created_event: Option<&str>,
404 ) -> anyhow::Result<()> {
405 self.sqlite
406 .insert_dep(source_key, target_key, dep_type, created_event)
407 .with_context(|| format!("Ledger::insert_dep({source_key} -> {target_key})"))
408 }
409
410 pub fn deps_of(&self, key: &str) -> anyhow::Result<Vec<crate::DependencyEdge>> {
412 self.sqlite
413 .deps_of(key)
414 .with_context(|| format!("Ledger::deps_of({key})"))
415 }
416
417 pub fn dependents_of(&self, key: &str) -> anyhow::Result<Vec<crate::DependencyEdge>> {
419 self.sqlite
420 .dependents_of(key)
421 .with_context(|| format!("Ledger::dependents_of({key})"))
422 }
423
424 pub fn active_dependents_of(
426 &self,
427 key: &str,
428 ) -> anyhow::Result<Vec<(crate::DependencyEdge, DecisionView)>> {
429 let rows = self
430 .sqlite
431 .active_dependents_of(key)
432 .with_context(|| format!("Ledger::active_dependents_of({key})"))?;
433 Ok(rows
434 .into_iter()
435 .map(|(dep, row)| (dep, view::to_view(&row)))
436 .collect())
437 }
438
439 pub fn decision_outcomes(
443 &self,
444 decision_event_id: &str,
445 ) -> anyhow::Result<Option<crate::domain::OutcomeMetrics>> {
446 self.sqlite
447 .decision_outcomes(decision_event_id)
448 .with_context(|| format!("Ledger::decision_outcomes({decision_event_id})"))
449 }
450
451 pub fn executions_for_decision(
453 &self,
454 decision_event_id: &str,
455 ) -> anyhow::Result<Vec<crate::domain::ExecutionLinked>> {
456 self.sqlite
457 .executions_for_decision(decision_event_id)
458 .with_context(|| format!("Ledger::executions_for_decision({decision_event_id})"))
459 }
460
461 pub fn transitive_dependents_of(
464 &self,
465 key: &str,
466 max_depth: usize,
467 ) -> anyhow::Result<Vec<(crate::DependencyEdge, DecisionView, usize)>> {
468 let rows = self
469 .sqlite
470 .transitive_dependents_of(key, max_depth)
471 .with_context(|| format!("Ledger::transitive_dependents_of({key})"))?;
472 Ok(rows
473 .into_iter()
474 .map(|(dep, row, depth)| (dep, view::to_view(&row), depth))
475 .collect())
476 }
477
478 pub fn get_decision_by_event_id(&self, event_id: &str) -> anyhow::Result<Option<DecisionView>> {
482 let row = self
483 .sqlite
484 .get_decision_by_event_id(event_id)
485 .with_context(|| format!("Ledger::get_decision_by_event_id({event_id})"))?;
486 Ok(row.as_ref().map(view::to_view))
487 }
488
489 pub fn causal_chain(
491 &self,
492 event_id: &str,
493 max_depth: usize,
494 ) -> anyhow::Result<Option<(DecisionView, Vec<crate::domain::ChainEntryView>)>> {
495 let result = self
496 .sqlite
497 .causal_chain(event_id, max_depth)
498 .with_context(|| format!("Ledger::causal_chain({event_id})"))?;
499 Ok(result.map(|(root, entries)| {
500 let root_view = view::to_view(&root);
501 let entry_views = entries
502 .into_iter()
503 .map(|e| crate::domain::ChainEntryView {
504 decision: view::to_view(&e.decision),
505 relation: e.relation,
506 depth: e.depth,
507 })
508 .collect();
509 (root_view, entry_views)
510 }))
511 }
512
513 pub fn get_task_brief(&self, task_id: &str) -> anyhow::Result<Option<crate::TaskBriefRow>> {
517 self.sqlite
518 .get_task_brief(task_id)
519 .with_context(|| format!("Ledger::get_task_brief({task_id})"))
520 }
521
522 pub fn list_task_briefs(
524 &self,
525 status: Option<&str>,
526 intent: Option<&str>,
527 ) -> anyhow::Result<Vec<crate::TaskBriefRow>> {
528 self.sqlite
529 .list_task_briefs(status, intent)
530 .context("Ledger::list_task_briefs")
531 }
532
533 pub fn get_bundle(&self, bundle_id: &str) -> anyhow::Result<Option<BundleRow>> {
537 self.sqlite
538 .get_bundle(bundle_id)
539 .with_context(|| format!("Ledger::get_bundle({bundle_id})"))
540 }
541
542 pub fn list_bundles(&self, status: Option<&str>) -> anyhow::Result<Vec<BundleRow>> {
544 self.sqlite
545 .list_bundles(status)
546 .context("Ledger::list_bundles")
547 }
548
549 pub fn insert_device_token(&self, row: &crate::DeviceTokenRow) -> anyhow::Result<()> {
553 self.sqlite
554 .insert_device_token(row)
555 .context("Ledger::insert_device_token")
556 }
557
558 pub fn validate_device_token(
560 &self,
561 token_hash: &str,
562 ) -> anyhow::Result<Option<crate::DeviceTokenRow>> {
563 self.sqlite
564 .validate_device_token(token_hash)
565 .context("Ledger::validate_device_token")
566 }
567
568 pub fn list_device_tokens(&self) -> anyhow::Result<Vec<crate::DeviceTokenRow>> {
570 self.sqlite
571 .list_device_tokens()
572 .context("Ledger::list_device_tokens")
573 }
574
575 pub fn revoke_device_token(
577 &self,
578 device_name: &str,
579 revoke_event_id: &str,
580 ) -> anyhow::Result<bool> {
581 self.sqlite
582 .revoke_device_token(device_name, revoke_event_id)
583 .with_context(|| format!("Ledger::revoke_device_token({device_name})"))
584 }
585
586 pub fn revoke_all_device_tokens(&self, revoke_event_id: &str) -> anyhow::Result<u64> {
588 self.sqlite
589 .revoke_all_device_tokens(revoke_event_id)
590 .context("Ledger::revoke_all_device_tokens")
591 }
592
593 pub fn insert_snapshot(&self, row: &crate::DecideSnapshotRow) -> anyhow::Result<()> {
597 self.sqlite
598 .insert_snapshot(row)
599 .context("Ledger::insert_snapshot")
600 }
601
602 pub fn query_snapshots(
604 &self,
605 village_id: Option<&str>,
606 engine_version: Option<&str>,
607 limit: usize,
608 ) -> anyhow::Result<Vec<crate::DecideSnapshotRow>> {
609 self.sqlite
610 .query_snapshots(village_id, engine_version, limit)
611 .context("Ledger::query_snapshots")
612 }
613
614 pub fn snapshots_by_context_hash(
616 &self,
617 context_hash: &str,
618 ) -> anyhow::Result<Vec<crate::DecideSnapshotRow>> {
619 self.sqlite
620 .snapshots_by_context_hash(context_hash)
621 .with_context(|| format!("Ledger::snapshots_by_context_hash({context_hash})"))
622 }
623
624 pub fn insert_suggestion(&self, row: &crate::SuggestionRow) -> anyhow::Result<()> {
628 self.sqlite
629 .insert_suggestion(row)
630 .context("Ledger::insert_suggestion")
631 }
632
633 pub fn list_suggestions_by_status(
635 &self,
636 status: &str,
637 ) -> anyhow::Result<Vec<crate::SuggestionRow>> {
638 self.sqlite
639 .list_suggestions_by_status(status)
640 .with_context(|| format!("Ledger::list_suggestions_by_status({status})"))
641 }
642
643 pub fn get_suggestion(&self, id: &str) -> anyhow::Result<Option<crate::SuggestionRow>> {
645 self.sqlite
646 .get_suggestion(id)
647 .with_context(|| format!("Ledger::get_suggestion({id})"))
648 }
649
650 pub fn update_suggestion_status(
653 &self,
654 id: &str,
655 status: &str,
656 reviewed_at: &str,
657 ) -> anyhow::Result<bool> {
658 self.sqlite
659 .update_suggestion_status(id, status, reviewed_at)
660 .with_context(|| format!("Ledger::update_suggestion_status({id})"))
661 }
662}
663
664pub fn init_workspace(paths: &EddaPaths) -> anyhow::Result<()> {
670 paths.ensure_layout()?;
671 std::fs::create_dir_all(paths.branch_dir("main"))?;
672 SqliteStore::open_or_create(&paths.ledger_db)?;
673 Ok(())
674}
675
676pub fn init_head(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
678 let store = SqliteStore::open(&paths.ledger_db)?;
679 if store.head_branch().is_err() {
680 store.set_head_branch(branch)?;
681 }
682 Ok(())
683}
684
685pub fn init_branches_json(paths: &EddaPaths, branch: &str) -> anyhow::Result<()> {
687 let now = time_now_rfc3339();
688 let json = serde_json::json!({
689 "branches": {
690 branch: {
691 "created_at": now
692 }
693 }
694 });
695 let store = SqliteStore::open(&paths.ledger_db)?;
696 if store.branches_json().is_err() {
697 store.set_branches_json(&json)?;
698 }
699 Ok(())
700}
701
702fn time_now_rfc3339() -> String {
703 let now = time::OffsetDateTime::now_utc();
704 now.format(&time::format_description::well_known::Rfc3339)
705 .expect("RFC3339 formatting should not fail")
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use edda_core::event::new_note_event;
712 use std::sync::atomic::{AtomicU64, Ordering};
713
714 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
715
716 fn setup_workspace() -> (std::path::PathBuf, Ledger) {
717 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
718 let tmp = std::env::temp_dir().join(format!("edda_ledger_test_{}_{n}", std::process::id()));
719 let _ = std::fs::remove_dir_all(&tmp);
720 let paths = EddaPaths::discover(&tmp);
721 init_workspace(&paths).unwrap();
722 init_head(&paths, "main").unwrap();
723 init_branches_json(&paths, "main").unwrap();
724 let ledger = Ledger::open(&tmp).unwrap();
725 (tmp, ledger)
726 }
727
728 #[test]
729 fn empty_ledger_has_no_hash() {
730 let (tmp, ledger) = setup_workspace();
731 assert_eq!(ledger.last_event_hash().unwrap(), None);
732 let _ = std::fs::remove_dir_all(&tmp);
733 }
734
735 #[test]
736 fn append_and_read_back() {
737 let (tmp, ledger) = setup_workspace();
738 let e1 = new_note_event("main", None, "system", "init", &[]).unwrap();
739 ledger.append_event(&e1).unwrap();
740 assert_eq!(ledger.last_event_hash().unwrap(), Some(e1.hash.clone()));
741
742 let e2 = new_note_event("main", Some(&e1.hash), "user", "hello", &[]).unwrap();
743 ledger.append_event(&e2).unwrap();
744 assert_eq!(ledger.last_event_hash().unwrap(), Some(e2.hash.clone()));
745
746 let events = ledger.iter_events().unwrap();
747 assert_eq!(events.len(), 2);
748 assert_eq!(events[0].event_id, e1.event_id);
749 assert_eq!(events[1].event_id, e2.event_id);
750 assert_eq!(events[1].parent_hash.as_deref(), Some(e1.hash.as_str()));
751
752 let _ = std::fs::remove_dir_all(&tmp);
753 }
754
755 #[test]
756 fn head_branch_read_write() {
757 let (tmp, ledger) = setup_workspace();
758 assert_eq!(ledger.head_branch().unwrap(), "main");
759 ledger.set_head_branch("feat/x").unwrap();
760 assert_eq!(ledger.head_branch().unwrap(), "feat/x");
761 let _ = std::fs::remove_dir_all(&tmp);
762 }
763
764 #[test]
765 fn branches_json_read_write() {
766 let (tmp, ledger) = setup_workspace();
767 let bj = ledger.branches_json().unwrap();
768 assert!(bj["branches"]["main"].is_object());
769
770 let new_json = serde_json::json!({
771 "branches": {
772 "main": { "created_at": "2026-01-01T00:00:00Z" },
773 "dev": { "created_at": "2026-02-01T00:00:00Z" }
774 }
775 });
776 ledger.set_branches_json(&new_json).unwrap();
777 let loaded = ledger.branches_json().unwrap();
778 assert_eq!(loaded, new_json);
779
780 let _ = std::fs::remove_dir_all(&tmp);
781 }
782
783 #[test]
784 fn open_or_init_creates_workspace() {
785 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
786 let tmp = std::env::temp_dir().join(format!("edda_auto_init_{}_{n}", std::process::id()));
787 let _ = std::fs::remove_dir_all(&tmp);
788 std::fs::create_dir_all(&tmp).unwrap();
789
790 assert!(!tmp.join(".edda").exists());
792
793 let ledger = Ledger::open_or_init(&tmp).unwrap();
795 assert!(tmp.join(".edda").exists());
796 assert_eq!(ledger.head_branch().unwrap(), "main");
797
798 let ledger2 = Ledger::open_or_init(&tmp).unwrap();
800 assert_eq!(ledger2.head_branch().unwrap(), "main");
801
802 let _ = std::fs::remove_dir_all(&tmp);
803 }
804
805 #[test]
806 fn open_without_init_fails() {
807 let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
808 let tmp = std::env::temp_dir().join(format!("edda_no_init_{}_{n}", std::process::id()));
809 let _ = std::fs::remove_dir_all(&tmp);
810 std::fs::create_dir_all(&tmp).unwrap();
811 assert!(Ledger::open(&tmp).is_err());
812 let _ = std::fs::remove_dir_all(&tmp);
813 }
814
815 fn make_decision_event(branch: &str, key: &str, value: &str) -> edda_core::Event {
818 use edda_core::event::finalize_event;
819 let text = format!("{key}: {value}");
820 let tags = vec!["decision".to_string()];
821 let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
822 event.payload["decision"] = serde_json::json!({"key": key, "value": value});
823 finalize_event(&mut event).unwrap();
824 event
825 }
826
827 #[test]
828 fn transitive_dependents_chain() {
829 let (tmp, ledger) = setup_workspace();
830
831 ledger
833 .append_event(&make_decision_event("main", "a.root", "v1"))
834 .unwrap();
835 ledger
836 .append_event(&make_decision_event("main", "b.mid", "v2"))
837 .unwrap();
838 ledger
839 .append_event(&make_decision_event("main", "c.leaf", "v3"))
840 .unwrap();
841
842 ledger
843 .insert_dep("b.mid", "a.root", "explicit", None)
844 .unwrap();
845 ledger
846 .insert_dep("c.leaf", "b.mid", "explicit", None)
847 .unwrap();
848
849 let deps = ledger.transitive_dependents_of("a.root", 3).unwrap();
850 assert_eq!(deps.len(), 2);
851
852 let b = deps.iter().find(|(_, d, _)| d.key == "b.mid").unwrap();
853 assert_eq!(b.2, 1);
854
855 let c = deps.iter().find(|(_, d, _)| d.key == "c.leaf").unwrap();
856 assert_eq!(c.2, 2);
857
858 let _ = std::fs::remove_dir_all(&tmp);
859 }
860
861 #[test]
862 fn transitive_dependents_diamond_no_duplicates() {
863 let (tmp, ledger) = setup_workspace();
864
865 for (k, v) in [
867 ("a.root", "v1"),
868 ("b.left", "v2"),
869 ("c.right", "v3"),
870 ("d.leaf", "v4"),
871 ] {
872 ledger
873 .append_event(&make_decision_event("main", k, v))
874 .unwrap();
875 }
876 ledger
877 .insert_dep("b.left", "a.root", "explicit", None)
878 .unwrap();
879 ledger
880 .insert_dep("c.right", "a.root", "explicit", None)
881 .unwrap();
882 ledger
883 .insert_dep("d.leaf", "b.left", "explicit", None)
884 .unwrap();
885 ledger
886 .insert_dep("d.leaf", "c.right", "explicit", None)
887 .unwrap();
888
889 let deps = ledger.transitive_dependents_of("a.root", 3).unwrap();
890 assert_eq!(deps.len(), 3);
892
893 let d_hits: Vec<_> = deps.iter().filter(|(_, d, _)| d.key == "d.leaf").collect();
894 assert_eq!(d_hits.len(), 1, "d.leaf should appear only once");
895 assert_eq!(d_hits[0].2, 2);
896
897 let _ = std::fs::remove_dir_all(&tmp);
898 }
899
900 #[test]
901 fn transitive_dependents_depth_limit() {
902 let (tmp, ledger) = setup_workspace();
903
904 for (k, v) in [
906 ("a.n1", "v1"),
907 ("b.n2", "v2"),
908 ("c.n3", "v3"),
909 ("d.n4", "v4"),
910 ("e.n5", "v5"),
911 ] {
912 ledger
913 .append_event(&make_decision_event("main", k, v))
914 .unwrap();
915 }
916 ledger.insert_dep("b.n2", "a.n1", "explicit", None).unwrap();
917 ledger.insert_dep("c.n3", "b.n2", "explicit", None).unwrap();
918 ledger.insert_dep("d.n4", "c.n3", "explicit", None).unwrap();
919 ledger.insert_dep("e.n5", "d.n4", "explicit", None).unwrap();
920
921 let deps = ledger.transitive_dependents_of("a.n1", 2).unwrap();
923 assert_eq!(deps.len(), 2, "depth limit 2 should return only 2 hops");
924
925 let keys: Vec<&str> = deps.iter().map(|(_, d, _)| d.key.as_str()).collect();
926 assert!(keys.contains(&"b.n2"));
927 assert!(keys.contains(&"c.n3"));
928
929 let _ = std::fs::remove_dir_all(&tmp);
930 }
931
932 #[test]
933 fn transitive_dependents_empty() {
934 let (tmp, ledger) = setup_workspace();
935 ledger
936 .append_event(&make_decision_event("main", "solo.key", "val"))
937 .unwrap();
938
939 let deps = ledger.transitive_dependents_of("solo.key", 3).unwrap();
940 assert!(deps.is_empty());
941
942 let _ = std::fs::remove_dir_all(&tmp);
943 }
944
945 #[test]
946 fn append_event_idempotent_dedup() {
947 let (tmp, ledger) = setup_workspace();
948 let event = new_note_event("main", None, "system", "test idempotent", &[]).unwrap();
949
950 let inserted = ledger.append_event_idempotent(&event).unwrap();
952 assert!(inserted);
953
954 let inserted2 = ledger.append_event_idempotent(&event).unwrap();
956 assert!(!inserted2);
957
958 let events = ledger.iter_events().unwrap();
960 assert_eq!(events.len(), 1);
961 assert_eq!(events[0].event_id, event.event_id);
962
963 let _ = std::fs::remove_dir_all(&tmp);
964 }
965
966 #[test]
967 fn iter_events_by_type_filters_correctly() {
968 use edda_core::event::new_execution_event;
969
970 let (tmp, ledger) = setup_workspace();
971 let note = new_note_event("main", None, "system", "a note", &[]).unwrap();
972 ledger.append_event(¬e).unwrap();
973
974 let payload = serde_json::json!({
975 "runtime": "claude", "model": "claude-3-opus",
976 "usage": { "token_in": 100, "token_out": 50, "cost_usd": 0.01, "latency_ms": 500 },
977 "result": { "status": "success" },
978 "event_type": "step_completed",
979 });
980 let exec = new_execution_event(
981 "main",
982 Some(¬e.hash),
983 "evt_exec_1",
984 "2026-03-11T00:00:00Z",
985 payload,
986 None,
987 )
988 .unwrap();
989 ledger.append_event(&exec).unwrap();
990
991 assert_eq!(ledger.iter_events().unwrap().len(), 2);
992 let exec_events = ledger.iter_events_by_type("execution_event").unwrap();
993 assert_eq!(exec_events.len(), 1);
994 assert_eq!(exec_events[0].event_id, "evt_exec_1");
995 let note_events = ledger.iter_events_by_type("note").unwrap();
996 assert_eq!(note_events.len(), 1);
997 assert_eq!(note_events[0].event_id, note.event_id);
998
999 let _ = std::fs::remove_dir_all(&tmp);
1000 }
1001
1002 #[test]
1003 fn iter_events_by_type_empty_for_unknown() {
1004 let (tmp, ledger) = setup_workspace();
1005 let note = new_note_event("main", None, "system", "a note", &[]).unwrap();
1006 ledger.append_event(¬e).unwrap();
1007 let result = ledger.iter_events_by_type("nonexistent_type").unwrap();
1008 assert!(result.is_empty());
1009 let _ = std::fs::remove_dir_all(&tmp);
1010 }
1011
1012 fn insert_pair_event(ledger: &Ledger) -> String {
1016 let evt = new_note_event("main", None, "system", "device_pair placeholder", &[]).unwrap();
1017 let eid = evt.event_id.clone();
1018 ledger.append_event(&evt).unwrap();
1019 eid
1020 }
1021
1022 #[test]
1023 fn device_token_insert_and_validate() {
1024 use crate::device_token::{generate_device_token, hash_token};
1025
1026 let (tmp, ledger) = setup_workspace();
1027 let pair_eid = insert_pair_event(&ledger);
1028
1029 let raw = generate_device_token();
1030 let hashed = hash_token(&raw);
1031
1032 ledger
1033 .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1034 token_hash: hashed.clone(),
1035 device_name: "test-phone".to_string(),
1036 paired_at: "2026-01-01T00:00:00Z".to_string(),
1037 paired_from_ip: "127.0.0.1".to_string(),
1038 revoked_at: None,
1039 pair_event_id: pair_eid,
1040 revoke_event_id: None,
1041 })
1042 .unwrap();
1043
1044 let found = ledger.validate_device_token(&hashed).unwrap();
1046 assert!(found.is_some());
1047 assert_eq!(found.unwrap().device_name, "test-phone");
1048
1049 let wrong = ledger.validate_device_token("badhash").unwrap();
1051 assert!(wrong.is_none());
1052
1053 let _ = std::fs::remove_dir_all(&tmp);
1054 }
1055
1056 #[test]
1057 fn device_token_revoke_flow() {
1058 use crate::device_token::{generate_device_token, hash_token};
1059
1060 let (tmp, ledger) = setup_workspace();
1061 let pair_eid = insert_pair_event(&ledger);
1062
1063 let raw = generate_device_token();
1064 let hashed = hash_token(&raw);
1065
1066 ledger
1067 .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1068 token_hash: hashed.clone(),
1069 device_name: "my-tablet".to_string(),
1070 paired_at: "2026-01-01T00:00:00Z".to_string(),
1071 paired_from_ip: "127.0.0.1".to_string(),
1072 revoked_at: None,
1073 pair_event_id: pair_eid,
1074 revoke_event_id: None,
1075 })
1076 .unwrap();
1077
1078 assert!(ledger.validate_device_token(&hashed).unwrap().is_some());
1080
1081 let revoked = ledger.revoke_device_token("my-tablet", "evt_rev1").unwrap();
1083 assert!(revoked);
1084
1085 assert!(ledger.validate_device_token(&hashed).unwrap().is_none());
1087
1088 let revoked2 = ledger.revoke_device_token("my-tablet", "evt_rev2").unwrap();
1090 assert!(!revoked2);
1091
1092 let revoked3 = ledger
1094 .revoke_device_token("no-such-device", "evt_rev3")
1095 .unwrap();
1096 assert!(!revoked3);
1097
1098 let _ = std::fs::remove_dir_all(&tmp);
1099 }
1100
1101 #[test]
1102 fn device_token_revoke_all() {
1103 use crate::device_token::{generate_device_token, hash_token};
1104
1105 let (tmp, ledger) = setup_workspace();
1106
1107 for name in ["dev-a", "dev-b"] {
1109 let pair_eid = insert_pair_event(&ledger);
1110 let raw = generate_device_token();
1111 let hashed = hash_token(&raw);
1112 ledger
1113 .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1114 token_hash: hashed,
1115 device_name: name.to_string(),
1116 paired_at: "2026-01-01T00:00:00Z".to_string(),
1117 paired_from_ip: "127.0.0.1".to_string(),
1118 revoked_at: None,
1119 pair_event_id: pair_eid,
1120 revoke_event_id: None,
1121 })
1122 .unwrap();
1123 }
1124
1125 let tokens = ledger.list_device_tokens().unwrap();
1126 assert_eq!(tokens.len(), 2);
1127 let active = tokens.iter().filter(|t| t.revoked_at.is_none()).count();
1128 assert_eq!(active, 2);
1129
1130 let count = ledger.revoke_all_device_tokens("evt_revall").unwrap();
1132 assert_eq!(count, 2);
1133
1134 let tokens2 = ledger.list_device_tokens().unwrap();
1136 let active2 = tokens2.iter().filter(|t| t.revoked_at.is_none()).count();
1137 assert_eq!(active2, 0);
1138
1139 let _ = std::fs::remove_dir_all(&tmp);
1140 }
1141
1142 #[test]
1143 fn device_token_list_shows_all() {
1144 use crate::device_token::{generate_device_token, hash_token};
1145
1146 let (tmp, ledger) = setup_workspace();
1147
1148 let pair_eid1 = insert_pair_event(&ledger);
1149 let pair_eid2 = insert_pair_event(&ledger);
1150
1151 let raw1 = generate_device_token();
1152 let raw2 = generate_device_token();
1153
1154 ledger
1155 .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1156 token_hash: hash_token(&raw1),
1157 device_name: "active-dev".to_string(),
1158 paired_at: "2026-01-01T00:00:00Z".to_string(),
1159 paired_from_ip: "127.0.0.1".to_string(),
1160 revoked_at: None,
1161 pair_event_id: pair_eid1,
1162 revoke_event_id: None,
1163 })
1164 .unwrap();
1165
1166 ledger
1167 .insert_device_token(&crate::sqlite_store::DeviceTokenRow {
1168 token_hash: hash_token(&raw2),
1169 device_name: "revoked-dev".to_string(),
1170 paired_at: "2026-01-01T00:00:00Z".to_string(),
1171 paired_from_ip: "192.168.1.1".to_string(),
1172 revoked_at: None,
1173 pair_event_id: pair_eid2,
1174 revoke_event_id: None,
1175 })
1176 .unwrap();
1177
1178 ledger
1180 .revoke_device_token("revoked-dev", "evt_rev")
1181 .unwrap();
1182
1183 let all = ledger.list_device_tokens().unwrap();
1184 assert_eq!(all.len(), 2, "list should include both active and revoked");
1185
1186 let active: Vec<_> = all.iter().filter(|t| t.revoked_at.is_none()).collect();
1187 assert_eq!(active.len(), 1);
1188 assert_eq!(active[0].device_name, "active-dev");
1189
1190 let _ = std::fs::remove_dir_all(&tmp);
1191 }
1192
1193 fn insert_decision_with_paths(
1197 ledger: &Ledger,
1198 key: &str,
1199 value: &str,
1200 paths: &[&str],
1201 ) -> String {
1202 let event = make_decision_event("main", key, value);
1203 let event_id = event.event_id.clone();
1204 ledger.append_event(&event).unwrap();
1205
1206 let paths_json = serde_json::to_string(paths).unwrap();
1208 let conn = rusqlite::Connection::open(&ledger.paths.ledger_db).unwrap();
1209 conn.execute(
1210 "UPDATE decisions SET affected_paths = ?1 WHERE event_id = ?2",
1211 rusqlite::params![paths_json, event_id],
1212 )
1213 .unwrap();
1214
1215 event_id
1216 }
1217
1218 #[test]
1219 fn query_by_paths_glob_matches() {
1220 let (tmp, ledger) = setup_workspace();
1221 insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/edda-ledger/**"]);
1222
1223 let results = ledger
1224 .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), None)
1225 .unwrap();
1226
1227 assert_eq!(results.len(), 1);
1228 assert_eq!(results[0].key, "db.engine");
1229
1230 let _ = std::fs::remove_dir_all(&tmp);
1231 }
1232
1233 #[test]
1234 fn query_by_paths_no_match() {
1235 let (tmp, ledger) = setup_workspace();
1236 insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/edda-ledger/**"]);
1237
1238 let results = ledger
1239 .query_by_paths(
1240 &["crates/edda-cli/src/main.rs"], Some("main"),
1242 None,
1243 )
1244 .unwrap();
1245
1246 assert!(results.is_empty());
1247
1248 let _ = std::fs::remove_dir_all(&tmp);
1249 }
1250
1251 #[test]
1252 fn query_by_paths_skips_superseded() {
1253 let (tmp, ledger) = setup_workspace();
1254
1255 insert_decision_with_paths(&ledger, "db.engine", "postgres", &["crates/**"]);
1257
1258 insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/**"]);
1260
1261 let results = ledger
1262 .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), None)
1263 .unwrap();
1264
1265 assert_eq!(results.len(), 1);
1267 assert_eq!(results[0].value, "sqlite");
1268
1269 let _ = std::fs::remove_dir_all(&tmp);
1270 }
1271
1272 #[test]
1273 fn query_by_paths_multiple_globs() {
1274 let (tmp, ledger) = setup_workspace();
1275 insert_decision_with_paths(
1276 &ledger,
1277 "error.pattern",
1278 "thiserror",
1279 &["crates/edda-ledger/**", "crates/edda-core/**"],
1280 );
1281
1282 let results = ledger
1284 .query_by_paths(&["crates/edda-core/src/types.rs"], Some("main"), None)
1285 .unwrap();
1286
1287 assert_eq!(results.len(), 1);
1288 assert_eq!(results[0].key, "error.pattern");
1289
1290 let _ = std::fs::remove_dir_all(&tmp);
1291 }
1292
1293 #[test]
1294 fn query_by_paths_respects_limit() {
1295 let (tmp, ledger) = setup_workspace();
1296
1297 insert_decision_with_paths(&ledger, "db.engine", "sqlite", &["crates/**"]);
1299 insert_decision_with_paths(&ledger, "error.pattern", "thiserror", &["crates/**"]);
1300 insert_decision_with_paths(&ledger, "auth.strategy", "jwt", &["crates/**"]);
1301
1302 let results = ledger
1303 .query_by_paths(&["crates/edda-ledger/src/lib.rs"], Some("main"), Some(2))
1304 .unwrap();
1305
1306 assert!(results.len() <= 2);
1307
1308 let _ = std::fs::remove_dir_all(&tmp);
1309 }
1310
1311 #[test]
1312 fn query_active_with_paths_excludes_empty() {
1313 let (tmp, ledger) = setup_workspace();
1314
1315 ledger
1317 .append_event(&make_decision_event("main", "no.paths", "val"))
1318 .unwrap();
1319
1320 insert_decision_with_paths(&ledger, "has.paths", "val", &["src/**"]);
1322
1323 let results = ledger.query_active_with_paths(Some("main"), None).unwrap();
1324 assert_eq!(results.len(), 1);
1325 assert_eq!(results[0].key, "has.paths");
1326
1327 let _ = std::fs::remove_dir_all(&tmp);
1328 }
1329}