1use crate::rollup::FileEditStat;
6use edda_core::Event;
7use edda_ledger::Ledger;
8use edda_store::registry::ProjectEntry;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::Path;
12
13#[derive(Debug, Clone, Default)]
15pub struct DateRange {
16 pub after: Option<String>,
17 pub before: Option<String>,
18}
19
20impl DateRange {
21 pub fn matches(&self, ts: &str) -> bool {
22 if let Some(ref after) = self.after {
23 if ts < after.as_str() {
24 return false;
25 }
26 }
27 if let Some(ref before) = self.before {
28 if ts > before.as_str() {
29 return false;
30 }
31 }
32 true
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ProjectSummary {
39 pub project_id: String,
40 pub name: String,
41 pub path: String,
42 pub event_count: usize,
43 pub commit_count: usize,
44 pub decision_count: usize,
45 pub session_count: usize,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CommitRecord {
51 pub project_id: String,
52 pub project_name: String,
53 pub event_id: String,
54 pub ts: String,
55 pub title: String,
56 pub branch: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DecisionRecord {
62 pub project_id: String,
63 pub project_name: String,
64 pub event_id: String,
65 pub key: String,
66 pub value: String,
67 pub reason: String,
68 pub domain: String,
69 pub branch: String,
70 pub ts: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct AggregateResult {
76 pub projects: Vec<ProjectSummary>,
77 pub total_events: usize,
78 pub total_commits: usize,
79 pub total_decisions: usize,
80}
81
82pub fn aggregate_overview(projects: &[ProjectEntry], range: &DateRange) -> AggregateResult {
84 let mut summaries = Vec::new();
85 let mut total_events = 0usize;
86 let mut total_commits = 0usize;
87 let mut total_decisions = 0usize;
88
89 for entry in projects {
90 let root = Path::new(&entry.path);
91 let ledger = match Ledger::open(root) {
92 Ok(l) => l,
93 Err(_) => continue,
94 };
95
96 let events = match ledger.iter_events() {
97 Ok(e) => e,
98 Err(_) => continue,
99 };
100
101 let filtered: Vec<&Event> = events.iter().filter(|e| range.matches(&e.ts)).collect();
102 let commit_count = filtered.iter().filter(|e| e.event_type == "commit").count();
103 let decision_count = filtered
104 .iter()
105 .filter(|e| e.event_type == "note" && edda_core::decision::is_decision(&e.payload))
106 .count();
107 let session_count = count_unique_sessions(&filtered);
108
109 summaries.push(ProjectSummary {
110 project_id: entry.project_id.clone(),
111 name: entry.name.clone(),
112 path: entry.path.clone(),
113 event_count: filtered.len(),
114 commit_count,
115 decision_count,
116 session_count,
117 });
118
119 total_events += filtered.len();
120 total_commits += commit_count;
121 total_decisions += decision_count;
122 }
123
124 AggregateResult {
125 projects: summaries,
126 total_events,
127 total_commits,
128 total_decisions,
129 }
130}
131
132pub fn aggregate_commits(
134 projects: &[ProjectEntry],
135 range: &DateRange,
136 limit: usize,
137) -> Vec<CommitRecord> {
138 let mut records = Vec::new();
139
140 for entry in projects {
141 let root = Path::new(&entry.path);
142 let ledger = match Ledger::open(root) {
143 Ok(l) => l,
144 Err(_) => continue,
145 };
146
147 let events = match ledger.iter_events() {
148 Ok(e) => e,
149 Err(_) => continue,
150 };
151
152 for event in &events {
153 if event.event_type != "commit" {
154 continue;
155 }
156 if !range.matches(&event.ts) {
157 continue;
158 }
159
160 let title = event
161 .payload
162 .get("title")
163 .and_then(|v| v.as_str())
164 .unwrap_or("")
165 .to_string();
166
167 records.push(CommitRecord {
168 project_id: entry.project_id.clone(),
169 project_name: entry.name.clone(),
170 event_id: event.event_id.clone(),
171 ts: event.ts.clone(),
172 title,
173 branch: event.branch.clone(),
174 });
175 }
176 }
177
178 records.sort_by(|a, b| b.ts.cmp(&a.ts));
179 records.truncate(limit);
180 records
181}
182
183pub fn aggregate_decisions(projects: &[ProjectEntry]) -> Vec<DecisionRecord> {
185 let mut records = Vec::new();
186
187 for entry in projects {
188 let root = Path::new(&entry.path);
189 let ledger = match Ledger::open(root) {
190 Ok(l) => l,
191 Err(_) => continue,
192 };
193
194 let decisions = match ledger.active_decisions(None, None, None, None) {
195 Ok(d) => d,
196 Err(_) => continue,
197 };
198
199 for d in decisions {
200 records.push(DecisionRecord {
201 project_id: entry.project_id.clone(),
202 project_name: entry.name.clone(),
203 event_id: d.event_id,
204 key: d.key,
205 value: d.value,
206 reason: d.reason,
207 domain: d.domain,
208 branch: d.branch,
209 ts: d.ts,
210 });
211 }
212 }
213
214 records
215}
216
217pub struct RollupMetrics {
223 pub events: BTreeMap<String, usize>,
224 pub commits: BTreeMap<String, usize>,
225 pub file_edits: BTreeMap<String, Vec<FileEditStat>>,
226 pub cost: BTreeMap<String, f64>,
227 pub quality: BTreeMap<String, (u64, u64)>,
228}
229
230pub fn rollup_metrics_by_date(projects: &[ProjectEntry], range: &DateRange) -> RollupMetrics {
236 use std::collections::{HashMap, HashSet};
237
238 type FileAcc = (u64, String, HashSet<String>);
240
241 let mut events: BTreeMap<String, usize> = BTreeMap::new();
242 let mut commits: BTreeMap<String, usize> = BTreeMap::new();
243 let mut cost: BTreeMap<String, f64> = BTreeMap::new();
244 let mut quality: BTreeMap<String, (u64, u64)> = BTreeMap::new();
245 let mut file_edits_acc: HashMap<String, HashMap<String, FileAcc>> = HashMap::new();
246
247 for entry in projects {
248 let root = Path::new(&entry.path);
249 let ledger = match Ledger::open(root) {
250 Ok(l) => l,
251 Err(_) => continue,
252 };
253
254 let all_events = match ledger.iter_events() {
255 Ok(e) => e,
256 Err(_) => continue,
257 };
258
259 for event in &all_events {
260 if !range.matches(&event.ts) {
261 continue;
262 }
263 let date = &event.ts[..10.min(event.ts.len())];
264 let date_str = date.to_string();
265
266 *events.entry(date_str.clone()).or_insert(0) += 1;
268
269 if event.event_type == "commit" {
271 *commits.entry(date_str.clone()).or_insert(0) += 1;
272 }
273
274 if event.event_type == "execution_event" {
276 let cost_val = event
278 .payload
279 .get("usage")
280 .and_then(|u| u.get("cost_usd"))
281 .and_then(|v| v.as_f64())
282 .unwrap_or(0.0);
283 if cost_val > 0.0 {
284 *cost.entry(date_str.clone()).or_insert(0.0) += cost_val;
285 }
286
287 let status = event
289 .payload
290 .get("result")
291 .and_then(|r| r.get("status"))
292 .and_then(|v| v.as_str())
293 .unwrap_or("unknown");
294 let q_entry = quality.entry(date_str.clone()).or_insert((0, 0));
295 if status == "success" {
296 q_entry.0 += 1;
297 }
298 q_entry.1 += 1;
299 }
300
301 if event.event_type == "cycle_telemetry" {
303 let cost_val = event
304 .payload
305 .get("cost")
306 .and_then(|c| c.get("total_usd"))
307 .and_then(|v| v.as_f64())
308 .unwrap_or(0.0);
309 if cost_val > 0.0 {
310 *cost.entry(date_str.clone()).or_insert(0.0) += cost_val;
311 }
312 }
313
314 let stats = match event.payload.get("session_stats") {
316 Some(s) => s,
317 None => continue,
318 };
319
320 let file_edit_counts = match stats.get("file_edit_counts").and_then(|v| v.as_array()) {
321 Some(arr) => arr,
322 None => continue,
323 };
324
325 let session_id = event
326 .payload
327 .get("session_id")
328 .and_then(|v| v.as_str())
329 .unwrap_or("")
330 .to_string();
331
332 for edit in file_edit_counts {
333 let arr = match edit.as_array() {
334 Some(a) if a.len() == 2 => a,
335 _ => continue,
336 };
337 let path = match arr[0].as_str() {
338 Some(p) => p.to_string(),
339 None => continue,
340 };
341 let count = arr[1].as_u64().unwrap_or(0);
342
343 let day_map = file_edits_acc.entry(date_str.clone()).or_default();
344 let file_entry = day_map
345 .entry(path)
346 .or_insert_with(|| (0, String::new(), HashSet::new()));
347 file_entry.0 += count;
348 if event.ts > file_entry.1 {
349 file_entry.1.clone_from(&event.ts);
350 }
351 if !session_id.is_empty() {
352 file_entry.2.insert(session_id.clone());
353 }
354 }
355 }
356 }
357
358 let mut file_edits: BTreeMap<String, Vec<FileEditStat>> = BTreeMap::new();
360 for (date, files) in file_edits_acc {
361 let mut edits: Vec<FileEditStat> = files
362 .into_iter()
363 .map(|(path, (edit_count, last_edited, sessions))| FileEditStat {
364 path,
365 edit_count,
366 agent_count: sessions.len(),
367 last_edited,
368 revert_count: 0,
369 })
370 .collect();
371 edits.sort_by_key(|stat| std::cmp::Reverse(stat.edit_count));
372 file_edits.insert(date, edits);
373 }
374
375 RollupMetrics {
376 events,
377 commits,
378 file_edits,
379 cost,
380 quality,
381 }
382}
383
384pub fn events_by_date(projects: &[ProjectEntry], range: &DateRange) -> BTreeMap<String, usize> {
386 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
387
388 for entry in projects {
389 let root = Path::new(&entry.path);
390 let ledger = match Ledger::open(root) {
391 Ok(l) => l,
392 Err(_) => continue,
393 };
394
395 let events = match ledger.iter_events() {
396 Ok(e) => e,
397 Err(_) => continue,
398 };
399
400 for event in &events {
401 if !range.matches(&event.ts) {
402 continue;
403 }
404 let date = &event.ts[..10.min(event.ts.len())];
405 *counts.entry(date.to_string()).or_insert(0) += 1;
406 }
407 }
408
409 counts
410}
411
412pub fn commits_by_date(projects: &[ProjectEntry], range: &DateRange) -> BTreeMap<String, usize> {
414 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
415
416 for entry in projects {
417 let root = Path::new(&entry.path);
418 let ledger = match Ledger::open(root) {
419 Ok(l) => l,
420 Err(_) => continue,
421 };
422
423 let events = match ledger.iter_events() {
424 Ok(e) => e,
425 Err(_) => continue,
426 };
427
428 for event in &events {
429 if event.event_type != "commit" {
430 continue;
431 }
432 if !range.matches(&event.ts) {
433 continue;
434 }
435 let date = &event.ts[..10.min(event.ts.len())];
436 *counts.entry(date.to_string()).or_insert(0) += 1;
437 }
438 }
439
440 counts
441}
442
443pub fn file_edits_by_date(
448 projects: &[ProjectEntry],
449 range: &DateRange,
450) -> BTreeMap<String, Vec<FileEditStat>> {
451 use std::collections::{HashMap, HashSet};
452
453 type FileAcc = (u64, String, HashSet<String>);
455
456 let mut acc: HashMap<String, HashMap<String, FileAcc>> = HashMap::new();
458
459 for entry in projects {
460 let root = Path::new(&entry.path);
461 let ledger = match Ledger::open(root) {
462 Ok(l) => l,
463 Err(_) => continue,
464 };
465
466 let events = match ledger.iter_events() {
467 Ok(e) => e,
468 Err(_) => continue,
469 };
470
471 for event in events.iter().filter(|e| range.matches(&e.ts)) {
472 let date = event.ts[..10.min(event.ts.len())].to_string();
473
474 let session_id = event
475 .payload
476 .get("session_id")
477 .and_then(|v| v.as_str())
478 .unwrap_or("")
479 .to_string();
480
481 let stats = match event.payload.get("session_stats") {
482 Some(s) => s,
483 None => continue,
484 };
485
486 let file_edits = match stats.get("file_edit_counts").and_then(|v| v.as_array()) {
487 Some(arr) => arr,
488 None => continue,
489 };
490
491 for edit in file_edits {
492 let arr = match edit.as_array() {
494 Some(a) if a.len() == 2 => a,
495 _ => continue,
496 };
497 let path = match arr[0].as_str() {
498 Some(p) => p.to_string(),
499 None => continue,
500 };
501 let count = arr[1].as_u64().unwrap_or(0);
502
503 let day_map = acc.entry(date.clone()).or_default();
504 let file_entry = day_map
505 .entry(path)
506 .or_insert_with(|| (0, String::new(), HashSet::new()));
507 file_entry.0 += count;
508 if event.ts > file_entry.1 {
509 file_entry.1.clone_from(&event.ts);
510 }
511 if !session_id.is_empty() {
512 file_entry.2.insert(session_id.clone());
513 }
514 }
515 }
516 }
517
518 let mut result: BTreeMap<String, Vec<FileEditStat>> = BTreeMap::new();
520 for (date, files) in acc {
521 let mut edits: Vec<FileEditStat> = files
522 .into_iter()
523 .map(|(path, (edit_count, last_edited, sessions))| FileEditStat {
524 path,
525 edit_count,
526 agent_count: sessions.len(),
527 last_edited,
528 revert_count: 0,
529 })
530 .collect();
531 edits.sort_by_key(|stat| std::cmp::Reverse(stat.edit_count));
533 result.insert(date, edits);
534 }
535
536 result
537}
538
539pub fn cost_by_date(projects: &[ProjectEntry], range: &DateRange) -> BTreeMap<String, f64> {
541 let mut costs: BTreeMap<String, f64> = BTreeMap::new();
542
543 for entry in projects {
544 let root = Path::new(&entry.path);
545 let ledger = match Ledger::open(root) {
546 Ok(l) => l,
547 Err(_) => continue,
548 };
549
550 let events = match ledger.iter_events() {
551 Ok(e) => e,
552 Err(_) => continue,
553 };
554
555 for event in &events {
556 if event.event_type != "execution_event" {
557 continue;
558 }
559 if !range.matches(&event.ts) {
560 continue;
561 }
562 let cost = event
563 .payload
564 .get("usage")
565 .and_then(|u| u.get("cost_usd"))
566 .and_then(|v| v.as_f64())
567 .unwrap_or(0.0);
568 if cost > 0.0 {
569 let date = &event.ts[..10.min(event.ts.len())];
570 *costs.entry(date.to_string()).or_insert(0.0) += cost;
571 }
572 }
573 }
574
575 costs
576}
577
578pub fn quality_by_date(
582 projects: &[ProjectEntry],
583 range: &DateRange,
584) -> BTreeMap<String, (u64, u64)> {
585 let mut quality: BTreeMap<String, (u64, u64)> = BTreeMap::new();
586
587 for entry in projects {
588 let root = Path::new(&entry.path);
589 let ledger = match Ledger::open(root) {
590 Ok(l) => l,
591 Err(_) => continue,
592 };
593
594 let events = match ledger.iter_events() {
595 Ok(e) => e,
596 Err(_) => continue,
597 };
598
599 for event in &events {
600 if event.event_type != "execution_event" {
601 continue;
602 }
603 if !range.matches(&event.ts) {
604 continue;
605 }
606 let status = event
607 .payload
608 .get("result")
609 .and_then(|r| r.get("status"))
610 .and_then(|v| v.as_str())
611 .unwrap_or("unknown");
612
613 let date = &event.ts[..10.min(event.ts.len())];
614 let entry = quality.entry(date.to_string()).or_insert((0, 0));
615 if status == "success" {
616 entry.0 += 1;
617 }
618 entry.1 += 1;
619 }
620 }
621
622 quality
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct ProjectMetrics {
628 pub project_id: String,
629 pub name: String,
630 pub group: Option<String>,
631 pub activity: ActivityMetrics,
632 pub cost: CostMetrics,
633 pub quality: QualityMetrics,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct ActivityMetrics {
639 pub events: usize,
640 pub commits: usize,
641 pub decisions: usize,
642 pub sessions: usize,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct ModelCost {
648 pub model: String,
649 pub cost_usd: f64,
650 pub steps: u64,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct CostMetrics {
656 pub total_usd: f64,
657 pub daily_avg_usd: f64,
658 #[serde(default)]
660 pub last_day_usd: f64,
661 pub by_model: Vec<ModelCost>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct QualityMetrics {
667 pub success_rate: f64,
668 pub avg_latency_ms: f64,
669 pub total_steps: u64,
670}
671
672pub fn per_project_metrics(
674 projects: &[ProjectEntry],
675 range: &DateRange,
676 days: usize,
677) -> Vec<ProjectMetrics> {
678 use crate::quality::model_quality_from_events;
679
680 let mut results = Vec::new();
681
682 for entry in projects {
683 let root = Path::new(&entry.path);
684 let ledger = match Ledger::open(root) {
685 Ok(l) => l,
686 Err(_) => continue,
687 };
688
689 let events = match ledger.iter_events() {
690 Ok(e) => e,
691 Err(_) => continue,
692 };
693
694 let filtered: Vec<&Event> = events.iter().filter(|e| range.matches(&e.ts)).collect();
695 let commit_count = filtered.iter().filter(|e| e.event_type == "commit").count();
696 let decision_count = filtered
697 .iter()
698 .filter(|e| e.event_type == "note" && edda_core::decision::is_decision(&e.payload))
699 .count();
700 let session_count = count_unique_sessions(&filtered);
701
702 let exec_events: Vec<Event> = events
704 .iter()
705 .filter(|e| e.event_type == "execution_event" && range.matches(&e.ts))
706 .cloned()
707 .collect();
708 let quality_report = model_quality_from_events(&exec_events, range);
709
710 let by_model: Vec<ModelCost> = quality_report
711 .models
712 .iter()
713 .map(|m| ModelCost {
714 model: m.model.clone(),
715 cost_usd: m.total_cost_usd,
716 steps: m.total_steps,
717 })
718 .collect();
719
720 let daily_avg = if days > 0 {
721 quality_report.total_cost_usd / days as f64
722 } else {
723 0.0
724 };
725
726 let last_day_usd = {
728 let mut day_costs: BTreeMap<&str, f64> = BTreeMap::new();
729 for event in &exec_events {
730 let cost = event
731 .payload
732 .get("usage")
733 .and_then(|u| u.get("cost_usd"))
734 .and_then(|v| v.as_f64())
735 .unwrap_or(0.0);
736 if cost > 0.0 {
737 let date = &event.ts[..10.min(event.ts.len())];
738 *day_costs.entry(date).or_insert(0.0) += cost;
739 }
740 }
741 day_costs.values().last().copied().unwrap_or(0.0)
743 };
744
745 results.push(ProjectMetrics {
746 project_id: entry.project_id.clone(),
747 name: entry.name.clone(),
748 group: entry.group.clone(),
749 activity: ActivityMetrics {
750 events: filtered.len(),
751 commits: commit_count,
752 decisions: decision_count,
753 sessions: session_count,
754 },
755 cost: CostMetrics {
756 total_usd: quality_report.total_cost_usd,
757 daily_avg_usd: daily_avg,
758 last_day_usd,
759 by_model,
760 },
761 quality: QualityMetrics {
762 success_rate: quality_report.overall_success_rate,
763 avg_latency_ms: if quality_report.total_steps > 0 {
764 quality_report
765 .models
766 .iter()
767 .map(|m| m.avg_latency_ms * m.total_steps as f64)
768 .sum::<f64>()
769 / quality_report.total_steps as f64
770 } else {
771 0.0
772 },
773 total_steps: quality_report.total_steps,
774 },
775 });
776 }
777
778 results
779}
780
781fn count_unique_sessions(events: &[&Event]) -> usize {
783 let mut sessions = std::collections::HashSet::new();
784 for event in events {
785 if let Some(sid) = event.payload.get("session_id").and_then(|v| v.as_str()) {
786 sessions.insert(sid.to_string());
787 }
788 }
789 sessions.len()
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 #[test]
797 fn date_range_matches_open() {
798 let range = DateRange::default();
799 assert!(range.matches("2026-03-01T00:00:00Z"));
800 }
801
802 #[test]
803 fn date_range_matches_after() {
804 let range = DateRange {
805 after: Some("2026-03-01".to_string()),
806 before: None,
807 };
808 assert!(range.matches("2026-03-02T00:00:00Z"));
809 assert!(!range.matches("2026-02-28T23:59:59Z"));
810 }
811
812 #[test]
813 fn date_range_matches_before() {
814 let range = DateRange {
815 after: None,
816 before: Some("2026-03-01".to_string()),
817 };
818 assert!(range.matches("2026-02-28T23:59:59Z"));
819 assert!(!range.matches("2026-03-02T00:00:00Z"));
820 }
821
822 #[test]
823 fn date_range_matches_bounded() {
824 let range = DateRange {
825 after: Some("2026-03-01".to_string()),
826 before: Some("2026-03-31".to_string()),
827 };
828 assert!(range.matches("2026-03-15T12:00:00Z"));
829 assert!(!range.matches("2026-02-28T00:00:00Z"));
830 assert!(!range.matches("2026-04-01T00:00:00Z"));
831 }
832
833 #[test]
834 fn aggregate_overview_empty_projects() {
835 let result = aggregate_overview(&[], &DateRange::default());
836 assert_eq!(result.total_events, 0);
837 assert_eq!(result.total_commits, 0);
838 assert!(result.projects.is_empty());
839 }
840
841 #[test]
842 fn aggregate_overview_with_real_ledger() {
843 let tmp = tempfile::tempdir().unwrap();
844 let root = tmp.path();
845
846 let paths = edda_ledger::EddaPaths::discover(root);
848 edda_ledger::ledger::init_workspace(&paths).unwrap();
849 edda_ledger::ledger::init_head(&paths, "main").unwrap();
850
851 let ledger = Ledger::open(root).unwrap();
852 let event =
853 edda_core::event::new_note_event("main", None, "system", "test note", &[]).unwrap();
854 ledger.append_event(&event).unwrap();
855
856 let entry = ProjectEntry {
857 project_id: "test".to_string(),
858 path: root.to_string_lossy().to_string(),
859 name: "test-project".to_string(),
860 registered_at: "2026-03-01T00:00:00Z".to_string(),
861 last_seen: "2026-03-01T00:00:00Z".to_string(),
862 group: None,
863 };
864
865 let result = aggregate_overview(&[entry], &DateRange::default());
866 assert_eq!(result.total_events, 1);
867 assert_eq!(result.projects.len(), 1);
868 assert_eq!(result.projects[0].event_count, 1);
869 }
870
871 #[test]
872 fn aggregate_overview_counts_decisions() {
873 let tmp = tempfile::tempdir().unwrap();
874 let root = tmp.path();
875
876 let paths = edda_ledger::EddaPaths::discover(root);
877 edda_ledger::ledger::init_workspace(&paths).unwrap();
878 edda_ledger::ledger::init_head(&paths, "main").unwrap();
879
880 let ledger = Ledger::open(root).unwrap();
881
882 let decision = edda_core::types::DecisionPayload {
884 key: "db.engine".to_string(),
885 value: "sqlite".to_string(),
886 reason: Some("embedded".to_string()),
887 scope: None,
888 authority: None,
889 affected_paths: None,
890 tags: None,
891 review_after: None,
892 reversibility: None,
893 village_id: None,
894 };
895 let event =
896 edda_core::event::new_decision_event("main", None, "system", &decision).unwrap();
897 ledger.append_event(&event).unwrap();
898
899 let entry = ProjectEntry {
900 project_id: "test".to_string(),
901 path: root.to_string_lossy().to_string(),
902 name: "test-project".to_string(),
903 registered_at: "2026-03-01T00:00:00Z".to_string(),
904 last_seen: "2026-03-01T00:00:00Z".to_string(),
905 group: None,
906 };
907
908 let result = aggregate_overview(&[entry], &DateRange::default());
909 assert_eq!(result.total_decisions, 1);
910 assert_eq!(result.projects[0].decision_count, 1);
911 }
912
913 fn make_session_event(session_id: &str, file_edits: &[(&str, u64)]) -> edda_core::types::Event {
915 let edits_json: Vec<serde_json::Value> = file_edits
916 .iter()
917 .map(|(path, count)| serde_json::json!([path, count]))
918 .collect();
919
920 let mut event =
921 edda_core::event::new_note_event("main", None, "system", "test", &[]).unwrap();
922 event.payload = serde_json::json!({
923 "session_id": session_id,
924 "session_stats": {
925 "file_edit_counts": edits_json,
926 }
927 });
928 event
929 }
930
931 #[test]
932 fn file_edits_by_date_empty_projects() {
933 let result = file_edits_by_date(&[], &DateRange::default());
934 assert!(result.is_empty());
935 }
936
937 #[test]
938 fn file_edits_by_date_parses_tuples() {
939 let tmp = tempfile::tempdir().unwrap();
940 let root = tmp.path();
941
942 let paths = edda_ledger::EddaPaths::discover(root);
943 edda_ledger::ledger::init_workspace(&paths).unwrap();
944 edda_ledger::ledger::init_head(&paths, "main").unwrap();
945
946 let ledger = Ledger::open(root).unwrap();
947 let event = make_session_event("sess-1", &[("src/main.rs", 5), ("src/lib.rs", 3)]);
948 ledger.append_event(&event).unwrap();
949
950 let entry = ProjectEntry {
951 project_id: "test".to_string(),
952 path: root.to_string_lossy().to_string(),
953 name: "test".to_string(),
954 registered_at: "2026-01-01".to_string(),
955 last_seen: "2026-01-01".to_string(),
956 group: None,
957 };
958
959 let result = file_edits_by_date(&[entry], &DateRange::default());
960 assert_eq!(result.len(), 1);
961
962 let date_key = result.keys().next().unwrap();
963 let edits = &result[date_key];
964 assert_eq!(edits.len(), 2);
965
966 let main_rs = edits.iter().find(|e| e.path == "src/main.rs").unwrap();
967 assert_eq!(main_rs.edit_count, 5);
968 assert_eq!(main_rs.agent_count, 1);
969
970 let lib_rs = edits.iter().find(|e| e.path == "src/lib.rs").unwrap();
971 assert_eq!(lib_rs.edit_count, 3);
972 assert_eq!(lib_rs.agent_count, 1);
973 }
974
975 #[test]
976 fn file_edits_by_date_deduplicates_sessions() {
977 let tmp = tempfile::tempdir().unwrap();
978 let root = tmp.path();
979
980 let paths = edda_ledger::EddaPaths::discover(root);
981 edda_ledger::ledger::init_workspace(&paths).unwrap();
982 edda_ledger::ledger::init_head(&paths, "main").unwrap();
983
984 let ledger = Ledger::open(root).unwrap();
985
986 let event1 = make_session_event("sess-1", &[("src/main.rs", 5)]);
988 ledger.append_event(&event1).unwrap();
989
990 let event2 = make_session_event("sess-2", &[("src/main.rs", 3)]);
991 ledger.append_event(&event2).unwrap();
992
993 let entry = ProjectEntry {
994 project_id: "test".to_string(),
995 path: root.to_string_lossy().to_string(),
996 name: "test".to_string(),
997 registered_at: "2026-01-01".to_string(),
998 last_seen: "2026-01-01".to_string(),
999 group: None,
1000 };
1001
1002 let result = file_edits_by_date(&[entry], &DateRange::default());
1003 assert_eq!(result.len(), 1);
1004
1005 let date_key = result.keys().next().unwrap();
1006 let edits = &result[date_key];
1007 let main_rs = edits.iter().find(|e| e.path == "src/main.rs").unwrap();
1008
1009 assert_eq!(main_rs.edit_count, 8);
1011 assert_eq!(main_rs.agent_count, 2);
1013 }
1014
1015 fn make_execution_event(cost_usd: f64, status: &str) -> edda_core::types::Event {
1017 let mut event =
1018 edda_core::event::new_note_event("main", None, "system", "exec", &[]).unwrap();
1019 event.event_type = "execution_event".to_string();
1020 event.payload = serde_json::json!({
1021 "usage": { "cost_usd": cost_usd },
1022 "result": { "status": status },
1023 });
1024 event
1025 }
1026
1027 fn make_commit_event() -> edda_core::types::Event {
1029 let mut event =
1030 edda_core::event::new_note_event("main", None, "system", "commit", &[]).unwrap();
1031 event.event_type = "commit".to_string();
1032 event.payload = serde_json::json!({ "title": "test commit" });
1033 event
1034 }
1035
1036 #[test]
1037 fn rollup_metrics_single_pass_collects_all_metrics() {
1038 let tmp = tempfile::tempdir().unwrap();
1039 let root = tmp.path();
1040
1041 let paths = edda_ledger::EddaPaths::discover(root);
1042 edda_ledger::ledger::init_workspace(&paths).unwrap();
1043 edda_ledger::ledger::init_head(&paths, "main").unwrap();
1044
1045 let ledger = Ledger::open(root).unwrap();
1046
1047 ledger.append_event(&make_commit_event()).unwrap();
1049
1050 ledger
1052 .append_event(&make_execution_event(0.05, "success"))
1053 .unwrap();
1054
1055 ledger
1057 .append_event(&make_execution_event(0.02, "failure"))
1058 .unwrap();
1059
1060 let session_event = make_session_event("sess-1", &[("src/main.rs", 3), ("src/lib.rs", 1)]);
1062 ledger.append_event(&session_event).unwrap();
1063
1064 let entry = ProjectEntry {
1065 project_id: "test".to_string(),
1066 path: root.to_string_lossy().to_string(),
1067 name: "test".to_string(),
1068 registered_at: "2026-01-01".to_string(),
1069 last_seen: "2026-01-01".to_string(),
1070 group: None,
1071 };
1072
1073 let range = DateRange::default();
1074 let metrics = rollup_metrics_by_date(&[entry], &range);
1075
1076 let total_events: usize = metrics.events.values().sum();
1078 assert_eq!(total_events, 4);
1079
1080 let total_commits: usize = metrics.commits.values().sum();
1082 assert_eq!(total_commits, 1);
1083
1084 let total_cost: f64 = metrics.cost.values().sum();
1086 assert!((total_cost - 0.07).abs() < 1e-9);
1087
1088 let (total_success, total_exec): (u64, u64) = metrics
1090 .quality
1091 .values()
1092 .fold((0, 0), |acc, &(s, t)| (acc.0 + s, acc.1 + t));
1093 assert_eq!(total_success, 1);
1094 assert_eq!(total_exec, 2);
1095
1096 let total_files: usize = metrics.file_edits.values().map(|v| v.len()).sum();
1098 assert_eq!(total_files, 2);
1099 }
1100
1101 #[test]
1102 fn rollup_metrics_matches_individual_functions() {
1103 let tmp = tempfile::tempdir().unwrap();
1104 let root = tmp.path();
1105
1106 let paths = edda_ledger::EddaPaths::discover(root);
1107 edda_ledger::ledger::init_workspace(&paths).unwrap();
1108 edda_ledger::ledger::init_head(&paths, "main").unwrap();
1109
1110 let ledger = Ledger::open(root).unwrap();
1111 ledger.append_event(&make_commit_event()).unwrap();
1112 ledger
1113 .append_event(&make_execution_event(0.10, "success"))
1114 .unwrap();
1115 ledger
1116 .append_event(&make_execution_event(0.03, "failure"))
1117 .unwrap();
1118 let session_event = make_session_event("sess-1", &[("src/main.rs", 7)]);
1119 ledger.append_event(&session_event).unwrap();
1120
1121 let entry = ProjectEntry {
1122 project_id: "test".to_string(),
1123 path: root.to_string_lossy().to_string(),
1124 name: "test".to_string(),
1125 registered_at: "2026-01-01".to_string(),
1126 last_seen: "2026-01-01".to_string(),
1127 group: None,
1128 };
1129
1130 let range = DateRange::default();
1131 let projects = &[entry];
1132
1133 let metrics = rollup_metrics_by_date(projects, &range);
1135
1136 let ind_events = events_by_date(projects, &range);
1138 let ind_commits = commits_by_date(projects, &range);
1139 let ind_cost = cost_by_date(projects, &range);
1140 let ind_quality = quality_by_date(projects, &range);
1141 let ind_file_edits = file_edits_by_date(projects, &range);
1142
1143 assert_eq!(metrics.events, ind_events);
1145 assert_eq!(metrics.commits, ind_commits);
1146 assert_eq!(metrics.quality, ind_quality);
1147
1148 assert_eq!(metrics.cost.len(), ind_cost.len());
1150 for (date, val) in &metrics.cost {
1151 let ind_val = ind_cost.get(date).copied().unwrap_or(0.0);
1152 assert!(
1153 (val - ind_val).abs() < 1e-9,
1154 "cost mismatch on {date}: {val} vs {ind_val}"
1155 );
1156 }
1157
1158 assert_eq!(metrics.file_edits.len(), ind_file_edits.len());
1160 for (date, edits) in &metrics.file_edits {
1161 let ind_edits = &ind_file_edits[date];
1162 assert_eq!(edits.len(), ind_edits.len());
1163 for edit in edits {
1164 let ind_edit = ind_edits.iter().find(|e| e.path == edit.path).unwrap();
1165 assert_eq!(edit.edit_count, ind_edit.edit_count);
1166 assert_eq!(edit.agent_count, ind_edit.agent_count);
1167 }
1168 }
1169 }
1170}