Skip to main content

edda_aggregate/
aggregate.rs

1//! Cross-repo aggregation queries.
2//!
3//! Lazy aggregation: reads each project's ledger on demand, no persistent DB.
4
5use 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/// Inclusive date range filter using ISO 8601 prefix comparison.
14#[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/// Per-project summary within an aggregation result.
37#[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/// A commit record from any project.
49#[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/// A decision record from any project.
60#[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/// Full cross-repo aggregation result.
74#[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
82/// Aggregate overview across all given projects.
83pub 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
132/// Collect commits across all projects, sorted by timestamp descending.
133pub 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
183/// Collect active decisions across all projects.
184pub 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
217/// Combined rollup metrics collected in a single pass over the event ledger.
218///
219/// Instead of calling 5 separate functions (each opening ledgers and scanning
220/// all events independently), this struct holds all metrics produced by a
221/// single traversal via [`rollup_metrics_by_date`].
222pub 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
230/// Collect all rollup metrics in a single pass over each project's ledger.
231///
232/// This replaces 5 separate calls to `events_by_date`, `commits_by_date`,
233/// `file_edits_by_date`, `cost_by_date`, and `quality_by_date`, reducing
234/// ledger opens and event scans from 5N to 1N (where N = number of projects).
235pub fn rollup_metrics_by_date(projects: &[ProjectEntry], range: &DateRange) -> RollupMetrics {
236    use std::collections::{HashMap, HashSet};
237
238    // Per-file accumulator for file edits: (edit_count, last_ts, unique sessions)
239    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            // Always count events
267            *events.entry(date_str.clone()).or_insert(0) += 1;
268
269            // Count commits
270            if event.event_type == "commit" {
271                *commits.entry(date_str.clone()).or_insert(0) += 1;
272            }
273
274            // Accumulate cost and quality for execution events
275            if event.event_type == "execution_event" {
276                // Cost
277                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                // Quality
288                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            // Accumulate cost from cycle_telemetry events
302            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            // Accumulate file edits from session_stats
315            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    // Convert file edits accumulator to final structure
359    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
384/// Count events by date (YYYY-MM-DD) across all projects.
385pub 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
412/// Count commits by date (YYYY-MM-DD) across all projects.
413pub 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
443/// Aggregate per-file edit counts by date across all projects.
444///
445/// Reads `session_stats.file_edit_counts` from session digest events.
446/// Uses `HashSet` to deduplicate agent sessions per (date, file).
447pub fn file_edits_by_date(
448    projects: &[ProjectEntry],
449    range: &DateRange,
450) -> BTreeMap<String, Vec<FileEditStat>> {
451    use std::collections::{HashMap, HashSet};
452
453    // Per-file accumulator: (edit_count, last_ts, unique sessions)
454    type FileAcc = (u64, String, HashSet<String>);
455
456    // Intermediate accumulator: date -> file -> FileAcc
457    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                // file_edit_counts is Vec<(String, u64)>, serialized as [["path", count], ...]
493                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    // Convert accumulator to final structure
519    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        // Sort by edit_count descending for consistent output
532        edits.sort_by_key(|stat| std::cmp::Reverse(stat.edit_count));
533        result.insert(date, edits);
534    }
535
536    result
537}
538
539/// Aggregate execution event cost by date (YYYY-MM-DD) across all projects.
540pub 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
578/// Aggregate execution event quality by date (YYYY-MM-DD) across all projects.
579///
580/// Returns `BTreeMap<date, (success_count, total_count)>`.
581pub 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/// Per-project metrics with cost, quality, and activity breakdown.
626#[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/// Activity counts for a project within a time range.
637#[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/// Per-model cost breakdown entry.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct ModelCost {
648    pub model: String,
649    pub cost_usd: f64,
650    pub steps: u64,
651}
652
653/// Cost metrics for a project within a time range.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct CostMetrics {
656    pub total_usd: f64,
657    pub daily_avg_usd: f64,
658    /// Cost of the most recent day in the period (for anomaly detection).
659    #[serde(default)]
660    pub last_day_usd: f64,
661    pub by_model: Vec<ModelCost>,
662}
663
664/// Quality metrics for a project within a time range.
665#[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
672/// Compute per-project metrics for all given projects within the specified date range.
673pub 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        // Quality: filter execution events and compute
703        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        // Compute per-day costs to find the most recent day's cost
727        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            // Last entry in BTreeMap is the most recent date
742            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
781/// Count unique session IDs from events.
782fn 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        // Initialize a workspace
847        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        // Create a decision event (note with "decision" tag)
883        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    /// Helper: create a ledger event with session_stats containing file_edit_counts.
914    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        // Two different sessions editing the same file
987        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        // Total edits: 5 + 3 = 8
1010        assert_eq!(main_rs.edit_count, 8);
1011        // Unique sessions: 2 (sess-1 and sess-2)
1012        assert_eq!(main_rs.agent_count, 2);
1013    }
1014
1015    /// Helper: create an execution event with cost and result status.
1016    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    /// Helper: create a commit event.
1028    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        // Add a commit event
1048        ledger.append_event(&make_commit_event()).unwrap();
1049
1050        // Add an execution event with cost
1051        ledger
1052            .append_event(&make_execution_event(0.05, "success"))
1053            .unwrap();
1054
1055        // Add a failed execution event
1056        ledger
1057            .append_event(&make_execution_event(0.02, "failure"))
1058            .unwrap();
1059
1060        // Add a session event with file edits
1061        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        // All 4 events counted
1077        let total_events: usize = metrics.events.values().sum();
1078        assert_eq!(total_events, 4);
1079
1080        // 1 commit
1081        let total_commits: usize = metrics.commits.values().sum();
1082        assert_eq!(total_commits, 1);
1083
1084        // Cost: 0.05 + 0.02 = 0.07
1085        let total_cost: f64 = metrics.cost.values().sum();
1086        assert!((total_cost - 0.07).abs() < 1e-9);
1087
1088        // Quality: 1 success out of 2 execution events
1089        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        // File edits: 2 files
1097        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        // Single-pass
1134        let metrics = rollup_metrics_by_date(projects, &range);
1135
1136        // Individual functions
1137        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        // Compare results
1144        assert_eq!(metrics.events, ind_events);
1145        assert_eq!(metrics.commits, ind_commits);
1146        assert_eq!(metrics.quality, ind_quality);
1147
1148        // Compare cost with floating-point tolerance
1149        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        // Compare file edits structure
1159        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}