1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct FileEditStat {
15 pub path: String,
17 pub edit_count: u64,
19 pub agent_count: usize,
21 #[serde(default)]
23 pub last_edited: String,
24 #[serde(default)]
26 pub revert_count: u64,
27}
28
29#[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 #[serde(default)]
38 pub file_edits: Vec<FileEditStat>,
39 #[serde(default)]
41 pub cost_usd: f64,
42 #[serde(default)]
44 pub execution_count: u64,
45 #[serde(default)]
47 pub success_count: u64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct WeekStat {
53 pub week_start: String,
54 pub events: usize,
55 pub commits: usize,
56 #[serde(default)]
58 pub file_edits: Vec<FileEditStat>,
59 #[serde(default)]
61 pub cost_usd: f64,
62 #[serde(default)]
64 pub execution_count: u64,
65 #[serde(default)]
67 pub success_count: u64,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct MonthStat {
73 pub month: String,
74 pub events: usize,
75 pub commits: usize,
76 #[serde(default)]
78 pub file_edits: Vec<FileEditStat>,
79 #[serde(default)]
81 pub cost_usd: f64,
82 #[serde(default)]
84 pub execution_count: u64,
85 #[serde(default)]
87 pub success_count: u64,
88}
89
90#[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
100pub fn collabs_dir(tool: &str) -> PathBuf {
102 store_root().join("collabs").join(tool)
103}
104
105pub fn rollup_path(tool: &str) -> PathBuf {
107 collabs_dir(tool).join("rollup.json")
108}
109
110pub 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
117pub 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
124pub 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
131pub 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
162pub 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 let cutoff = &existing.last_updated;
169 DateRange {
170 after: if cutoff.is_empty() {
171 None
172 } else {
173 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
195pub 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
219fn 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#[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
273fn 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
303fn 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
332fn 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 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
352fn 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 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 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 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 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 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}