Skip to main content

edda_aggregate/
rollup.rs

1//! Pre-computed rollup statistics cached at `~/.edda/collabs/<tool>/rollup.json`.
2//!
3//! Supports daily, weekly, and monthly granularity with incremental updates.
4
5use crate::aggregate::{self, DateRange};
6use edda_store::registry::ProjectEntry;
7use edda_store::{store_root, write_atomic};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12/// Per-file edit statistics for code heatmap.
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct FileEditStat {
15    /// File path relative to the repository root.
16    pub path: String,
17    /// Number of Edit/Write tool calls on this file.
18    pub edit_count: u64,
19    /// Number of unique agent sessions that edited this file.
20    pub agent_count: usize,
21    /// ISO 8601 timestamp of the last edit.
22    #[serde(default)]
23    pub last_edited: String,
24    /// Number of reverts affecting this file (0 for now).
25    #[serde(default)]
26    pub revert_count: u64,
27}
28
29/// Daily statistics.
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct DayStat {
32    pub date: String,
33    pub events: usize,
34    pub commits: usize,
35    pub sessions: usize,
36    /// Per-file edit statistics for this day.
37    #[serde(default)]
38    pub file_edits: Vec<FileEditStat>,
39    /// Total cost in USD from execution events.
40    #[serde(default)]
41    pub cost_usd: f64,
42    /// Number of execution events.
43    #[serde(default)]
44    pub execution_count: u64,
45    /// Number of successful execution events.
46    #[serde(default)]
47    pub success_count: u64,
48}
49
50/// Weekly statistics.
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct WeekStat {
53    pub week_start: String,
54    pub events: usize,
55    pub commits: usize,
56    /// Per-file edit statistics for this week.
57    #[serde(default)]
58    pub file_edits: Vec<FileEditStat>,
59    /// Total cost in USD from execution events.
60    #[serde(default)]
61    pub cost_usd: f64,
62    /// Number of execution events.
63    #[serde(default)]
64    pub execution_count: u64,
65    /// Number of successful execution events.
66    #[serde(default)]
67    pub success_count: u64,
68}
69
70/// Monthly statistics.
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct MonthStat {
73    pub month: String,
74    pub events: usize,
75    pub commits: usize,
76    /// Per-file edit statistics for this month.
77    #[serde(default)]
78    pub file_edits: Vec<FileEditStat>,
79    /// Total cost in USD from execution events.
80    #[serde(default)]
81    pub cost_usd: f64,
82    /// Number of execution events.
83    #[serde(default)]
84    pub execution_count: u64,
85    /// Number of successful execution events.
86    #[serde(default)]
87    pub success_count: u64,
88}
89
90/// The full rollup cache.
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
92pub struct Rollup {
93    pub tool: String,
94    pub last_updated: String,
95    pub daily: Vec<DayStat>,
96    pub weekly: Vec<WeekStat>,
97    pub monthly: Vec<MonthStat>,
98}
99
100/// Path to the collabs directory for a tool.
101pub fn collabs_dir(tool: &str) -> PathBuf {
102    store_root().join("collabs").join(tool)
103}
104
105/// Path to the rollup cache file for a tool.
106pub fn rollup_path(tool: &str) -> PathBuf {
107    collabs_dir(tool).join("rollup.json")
108}
109
110/// Ensure the collabs directory structure exists.
111pub fn ensure_collabs_dir(tool: &str) -> anyhow::Result<()> {
112    let dir = collabs_dir(tool);
113    std::fs::create_dir_all(&dir)?;
114    Ok(())
115}
116
117/// Load an existing rollup from disk.
118pub fn load_rollup(tool: &str) -> Option<Rollup> {
119    let path = rollup_path(tool);
120    let content = std::fs::read_to_string(&path).ok()?;
121    serde_json::from_str(&content).ok()
122}
123
124/// Save a rollup to disk.
125pub fn save_rollup(rollup: &Rollup) -> anyhow::Result<()> {
126    ensure_collabs_dir(&rollup.tool)?;
127    let json = serde_json::to_string_pretty(rollup)?;
128    write_atomic(&rollup_path(&rollup.tool), json.as_bytes())
129}
130
131/// Compute a full rollup from scratch.
132///
133/// Uses a single-pass aggregation to collect all metrics in one traversal
134/// per project ledger, instead of 5 separate scans.
135pub fn compute_rollup(projects: &[ProjectEntry], range: &DateRange, tool: &str) -> Rollup {
136    let metrics = aggregate::rollup_metrics_by_date(projects, range);
137
138    let daily = build_daily_stats(
139        &metrics.events,
140        &metrics.commits,
141        &metrics.file_edits,
142        &metrics.cost,
143        &metrics.quality,
144    );
145    let weekly = build_weekly_stats(&daily);
146    let monthly = build_monthly_stats(&daily);
147
148    let now = time::OffsetDateTime::now_utc();
149    let last_updated = now
150        .format(&time::format_description::well_known::Rfc3339)
151        .unwrap_or_else(|_| "unknown".to_string());
152
153    Rollup {
154        tool: tool.to_string(),
155        last_updated,
156        daily,
157        weekly,
158        monthly,
159    }
160}
161
162/// Incremental rollup: compute only new data since last update, then merge.
163pub fn compute_rollup_incremental(projects: &[ProjectEntry], tool: &str) -> anyhow::Result<Rollup> {
164    let existing = load_rollup(tool);
165
166    let range = if let Some(ref existing) = existing {
167        // Only fetch events since last_updated (not last daily entry date).
168        let cutoff = &existing.last_updated;
169        DateRange {
170            after: if cutoff.is_empty() {
171                None
172            } else {
173                // Use the date portion (YYYY-MM-DD) of last_updated as the
174                // "after" boundary so we re-scan the partial day.
175                Some(cutoff.chars().take(10).collect())
176            },
177            before: None,
178        }
179    } else {
180        DateRange::default()
181    };
182
183    let new_rollup = compute_rollup(projects, &range, tool);
184
185    let merged = if let Some(existing) = existing {
186        merge_rollups(&existing, &new_rollup)
187    } else {
188        new_rollup
189    };
190
191    save_rollup(&merged)?;
192    Ok(merged)
193}
194
195/// Merge two rollups: new data replaces overlapping days in base.
196pub fn merge_rollups(base: &Rollup, new: &Rollup) -> Rollup {
197    let mut daily_map: BTreeMap<String, DayStat> = BTreeMap::new();
198
199    for d in &base.daily {
200        daily_map.insert(d.date.clone(), d.clone());
201    }
202    for d in &new.daily {
203        daily_map.insert(d.date.clone(), d.clone());
204    }
205
206    let daily: Vec<DayStat> = daily_map.into_values().collect();
207    let weekly = build_weekly_stats(&daily);
208    let monthly = build_monthly_stats(&daily);
209
210    Rollup {
211        tool: new.tool.clone(),
212        last_updated: new.last_updated.clone(),
213        daily,
214        weekly,
215        monthly,
216    }
217}
218
219/// Build daily stats from event and commit counts by date.
220fn build_daily_stats(
221    events_map: &BTreeMap<String, usize>,
222    commits_map: &BTreeMap<String, usize>,
223    file_edits_map: &BTreeMap<String, Vec<FileEditStat>>,
224    cost_map: &BTreeMap<String, f64>,
225    quality_map: &BTreeMap<String, (u64, u64)>,
226) -> Vec<DayStat> {
227    let mut all_dates: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
228    for key in events_map.keys() {
229        all_dates.insert(key.as_str());
230    }
231    for key in commits_map.keys() {
232        all_dates.insert(key.as_str());
233    }
234    for key in file_edits_map.keys() {
235        all_dates.insert(key.as_str());
236    }
237    for key in cost_map.keys() {
238        all_dates.insert(key.as_str());
239    }
240    for key in quality_map.keys() {
241        all_dates.insert(key.as_str());
242    }
243
244    all_dates
245        .into_iter()
246        .map(|date| {
247            let (success_count, execution_count) = quality_map.get(date).copied().unwrap_or((0, 0));
248            DayStat {
249                date: date.to_string(),
250                events: events_map.get(date).copied().unwrap_or(0),
251                commits: commits_map.get(date).copied().unwrap_or(0),
252                sessions: 0,
253                file_edits: file_edits_map.get(date).cloned().unwrap_or_default(),
254                cost_usd: cost_map.get(date).copied().unwrap_or(0.0),
255                execution_count,
256                success_count,
257            }
258        })
259        .collect()
260}
261
262/// Accumulator for aggregating stats across days into weekly/monthly buckets.
263#[derive(Default)]
264struct StatAccum {
265    events: usize,
266    commits: usize,
267    file_edits: Vec<FileEditStat>,
268    cost_usd: f64,
269    execution_count: u64,
270    success_count: u64,
271}
272
273/// Build weekly stats from daily stats.
274fn build_weekly_stats(daily: &[DayStat]) -> Vec<WeekStat> {
275    let mut weekly_map: BTreeMap<String, StatAccum> = BTreeMap::new();
276
277    for d in daily {
278        if let Some(week_start) = iso_week_start(&d.date) {
279            let entry = weekly_map.entry(week_start).or_default();
280            entry.events += d.events;
281            entry.commits += d.commits;
282            merge_file_edits(&mut entry.file_edits, &d.file_edits);
283            entry.cost_usd += d.cost_usd;
284            entry.execution_count += d.execution_count;
285            entry.success_count += d.success_count;
286        }
287    }
288
289    weekly_map
290        .into_iter()
291        .map(|(week_start, acc)| WeekStat {
292            week_start,
293            events: acc.events,
294            commits: acc.commits,
295            file_edits: acc.file_edits,
296            cost_usd: acc.cost_usd,
297            execution_count: acc.execution_count,
298            success_count: acc.success_count,
299        })
300        .collect()
301}
302
303/// Build monthly stats from daily stats.
304fn build_monthly_stats(daily: &[DayStat]) -> Vec<MonthStat> {
305    let mut monthly_map: BTreeMap<String, StatAccum> = BTreeMap::new();
306
307    for d in daily {
308        let month = &d.date[..7.min(d.date.len())];
309        let entry = monthly_map.entry(month.to_string()).or_default();
310        entry.events += d.events;
311        entry.commits += d.commits;
312        merge_file_edits(&mut entry.file_edits, &d.file_edits);
313        entry.cost_usd += d.cost_usd;
314        entry.execution_count += d.execution_count;
315        entry.success_count += d.success_count;
316    }
317
318    monthly_map
319        .into_iter()
320        .map(|(month, acc)| MonthStat {
321            month,
322            events: acc.events,
323            commits: acc.commits,
324            file_edits: acc.file_edits,
325            cost_usd: acc.cost_usd,
326            execution_count: acc.execution_count,
327            success_count: acc.success_count,
328        })
329        .collect()
330}
331
332/// Merge file edit statistics from `source` into `target`.
333/// Sums `edit_count` and `revert_count`; takes the max of `agent_count` (approximate).
334fn merge_file_edits(target: &mut Vec<FileEditStat>, source: &[FileEditStat]) {
335    for src in source {
336        if let Some(existing) = target.iter_mut().find(|t| t.path == src.path) {
337            existing.edit_count += src.edit_count;
338            existing.revert_count += src.revert_count;
339            // Agent count is approximate when merging across days
340            if src.agent_count > existing.agent_count {
341                existing.agent_count = src.agent_count;
342            }
343            if src.last_edited > existing.last_edited {
344                existing.last_edited.clone_from(&src.last_edited);
345            }
346        } else {
347            target.push(src.clone());
348        }
349    }
350}
351
352/// Given a date string "YYYY-MM-DD", return the ISO Monday of that week as "YYYY-MM-DD".
353fn iso_week_start(date_str: &str) -> Option<String> {
354    let format = time::format_description::parse("[year]-[month]-[day]").ok()?;
355    let date = time::Date::parse(date_str, &format).ok()?;
356    let weekday = date.weekday();
357    let days_since_monday = weekday.number_days_from_monday();
358    let monday = date - time::Duration::days(i64::from(days_since_monday));
359    monday.format(&format).ok()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn iso_week_start_monday() {
368        // 2026-03-02 is a Monday
369        assert_eq!(iso_week_start("2026-03-02"), Some("2026-03-02".to_string()));
370    }
371
372    #[test]
373    fn iso_week_start_wednesday() {
374        // 2026-03-04 is a Wednesday
375        assert_eq!(iso_week_start("2026-03-04"), Some("2026-03-02".to_string()));
376    }
377
378    #[test]
379    fn iso_week_start_sunday() {
380        // 2026-03-08 is a Sunday
381        assert_eq!(iso_week_start("2026-03-08"), Some("2026-03-02".to_string()));
382    }
383
384    #[test]
385    fn merge_rollups_replaces_overlapping() {
386        let base = Rollup {
387            tool: "test".to_string(),
388            last_updated: "old".to_string(),
389            daily: vec![
390                DayStat {
391                    date: "2026-03-01".into(),
392                    events: 5,
393                    commits: 1,
394                    ..Default::default()
395                },
396                DayStat {
397                    date: "2026-03-02".into(),
398                    events: 3,
399                    commits: 0,
400                    ..Default::default()
401                },
402            ],
403            weekly: vec![],
404            monthly: vec![],
405        };
406        let new = Rollup {
407            tool: "test".to_string(),
408            last_updated: "new".to_string(),
409            daily: vec![
410                DayStat {
411                    date: "2026-03-02".into(),
412                    events: 10,
413                    commits: 2,
414                    ..Default::default()
415                },
416                DayStat {
417                    date: "2026-03-03".into(),
418                    events: 7,
419                    commits: 3,
420                    ..Default::default()
421                },
422            ],
423            weekly: vec![],
424            monthly: vec![],
425        };
426
427        let merged = merge_rollups(&base, &new);
428        assert_eq!(merged.daily.len(), 3);
429        assert_eq!(merged.last_updated, "new");
430
431        // March 2 should have new data (10 events, not 3)
432        let mar2 = merged
433            .daily
434            .iter()
435            .find(|d| d.date == "2026-03-02")
436            .unwrap();
437        assert_eq!(mar2.events, 10);
438    }
439
440    #[test]
441    fn build_daily_stats_combines_maps() {
442        let mut events = BTreeMap::new();
443        events.insert("2026-03-01".to_string(), 5);
444        events.insert("2026-03-02".to_string(), 3);
445
446        let mut commits = BTreeMap::new();
447        commits.insert("2026-03-01".to_string(), 2);
448        commits.insert("2026-03-03".to_string(), 1);
449
450        let file_edits = BTreeMap::new();
451        let cost_map = BTreeMap::new();
452        let quality_map = BTreeMap::new();
453
454        let daily = build_daily_stats(&events, &commits, &file_edits, &cost_map, &quality_map);
455        assert_eq!(daily.len(), 3);
456        assert_eq!(daily[0].date, "2026-03-01");
457        assert_eq!(daily[0].events, 5);
458        assert_eq!(daily[0].commits, 2);
459        assert_eq!(daily[2].date, "2026-03-03");
460        assert_eq!(daily[2].commits, 1);
461    }
462
463    #[test]
464    fn backward_compat_daystat_without_file_edits() {
465        let json = r#"{"date":"2026-03-01","events":5,"commits":1,"sessions":0}"#;
466        let stat: DayStat = serde_json::from_str(json).unwrap();
467        assert_eq!(stat.date, "2026-03-01");
468        assert!(stat.file_edits.is_empty());
469        // New fields default to zero
470        assert_eq!(stat.cost_usd, 0.0);
471        assert_eq!(stat.execution_count, 0);
472        assert_eq!(stat.success_count, 0);
473    }
474
475    #[test]
476    fn backward_compat_weekstat_without_cost_fields() {
477        let json = r#"{"week_start":"2026-03-02","events":10,"commits":3}"#;
478        let stat: WeekStat = serde_json::from_str(json).unwrap();
479        assert_eq!(stat.week_start, "2026-03-02");
480        assert_eq!(stat.cost_usd, 0.0);
481        assert_eq!(stat.execution_count, 0);
482        assert_eq!(stat.success_count, 0);
483    }
484
485    #[test]
486    fn build_daily_stats_includes_cost_quality() {
487        let mut events = BTreeMap::new();
488        events.insert("2026-03-01".to_string(), 5);
489
490        let commits = BTreeMap::new();
491        let file_edits = BTreeMap::new();
492
493        let mut cost_map = BTreeMap::new();
494        cost_map.insert("2026-03-01".to_string(), 0.05);
495
496        let mut quality_map = BTreeMap::new();
497        quality_map.insert("2026-03-01".to_string(), (8u64, 10u64));
498
499        let daily = build_daily_stats(&events, &commits, &file_edits, &cost_map, &quality_map);
500        assert_eq!(daily.len(), 1);
501        assert!((daily[0].cost_usd - 0.05).abs() < 1e-9);
502        assert_eq!(daily[0].execution_count, 10);
503        assert_eq!(daily[0].success_count, 8);
504    }
505
506    #[test]
507    fn weekly_stats_sum_cost_quality() {
508        let daily = vec![
509            DayStat {
510                date: "2026-03-02".into(),
511                events: 5,
512                commits: 1,
513                cost_usd: 0.10,
514                execution_count: 5,
515                success_count: 4,
516                ..Default::default()
517            },
518            DayStat {
519                date: "2026-03-03".into(),
520                events: 3,
521                commits: 0,
522                cost_usd: 0.05,
523                execution_count: 3,
524                success_count: 3,
525                ..Default::default()
526            },
527        ];
528
529        let weekly = build_weekly_stats(&daily);
530        assert_eq!(weekly.len(), 1);
531        assert!((weekly[0].cost_usd - 0.15).abs() < 1e-9);
532        assert_eq!(weekly[0].execution_count, 8);
533        assert_eq!(weekly[0].success_count, 7);
534    }
535
536    #[test]
537    fn merge_file_edits_sums_correctly() {
538        let mut target = vec![FileEditStat {
539            path: "src/main.rs".to_string(),
540            edit_count: 5,
541            agent_count: 1,
542            last_edited: "2026-03-01T10:00:00Z".to_string(),
543            revert_count: 0,
544        }];
545
546        let source = vec![FileEditStat {
547            path: "src/main.rs".to_string(),
548            edit_count: 3,
549            agent_count: 2,
550            last_edited: "2026-03-01T14:00:00Z".to_string(),
551            revert_count: 0,
552        }];
553
554        merge_file_edits(&mut target, &source);
555
556        assert_eq!(target.len(), 1);
557        assert_eq!(target[0].edit_count, 8);
558        assert_eq!(target[0].agent_count, 2);
559        assert_eq!(target[0].last_edited, "2026-03-01T14:00:00Z");
560    }
561}