1use crate::retro::types::Report;
5use anyhow::{Context, Result};
6use std::fs::{self, File, OpenOptions};
7use std::io::Write;
8use std::path::Path;
9
10pub fn iso_week_label_utc() -> String {
12 let d = time::OffsetDateTime::now_utc().date();
13 format!("{}-W{:02}", d.year(), d.iso_week())
14}
15
16pub fn to_json(report: &Report) -> Result<String> {
18 Ok(serde_json::to_string_pretty(report)?)
19}
20
21pub fn to_markdown(report: &Report) -> String {
23 let mut s = String::new();
24 let cost = report.meta.total_cost_usd_e6 as f64 / 1_000_000.0;
25 let title_week = if report.meta.week_label.is_empty() {
26 "—"
27 } else {
28 report.meta.week_label.as_str()
29 };
30 s.push_str(&format!("# Kaizen Retro — Week {}\n\n", title_week));
31 s.push_str(&format!(
32 "Span: {} → {} · Sessions: {} · Cost: ${:.2}\n\n",
33 report.meta.span_start_ms, report.meta.span_end_ms, report.meta.session_count, cost
34 ));
35 s.push_str("## Top Bets\n\n");
36 for (i, b) in report.top_bets.iter().enumerate() {
37 s.push_str(&format!("### {}. {} ({})\n", i + 1, b.title, b.id));
38 s.push_str(&format!("- Hypothesis: {}\n", b.hypothesis));
39 s.push_str(&format!(
40 "- Saves ~{:.0} tokens/week (est.)\n",
41 b.expected_tokens_saved_per_week
42 ));
43 s.push_str(&format!("- Effort: {} min\n", b.effort_minutes));
44 if !b.evidence.is_empty() {
45 s.push_str("- Evidence:\n");
46 for e in &b.evidence {
47 s.push_str(&format!(" - {}\n", e));
48 }
49 }
50 s.push_str(&format!("- Apply: {}\n\n", b.apply_step));
51 }
52 if !report.skipped_deduped.is_empty() {
53 s.push_str("## Skipped Bets (deduped vs prior reports)\n\n");
54 for line in &report.skipped_deduped {
55 s.push_str(&format!("- {}\n", line));
56 }
57 s.push('\n');
58 }
59 s.push_str("## Raw Stats\n\n");
60 s.push_str("| Metric | Value |\n|---|---|\n");
61 s.push_str(&format!("| Sessions | {} |\n", report.stats.sessions));
62 s.push_str(&format!(
63 "| Total cost | ${:.2} |\n",
64 report.stats.total_cost_usd_e6 as f64 / 1_000_000.0
65 ));
66 if let Some(ref m) = report.stats.top_model {
67 let p = report
68 .stats
69 .top_model_pct
70 .map(|x| format!("{}%", x))
71 .unwrap_or_else(|| "—".into());
72 s.push_str(&format!("| Top model | {} ({}) |\n", m, p));
73 }
74 if let Some(ref t) = report.stats.top_tool {
75 let p = report
76 .stats
77 .top_tool_pct
78 .map(|x| format!("{}%", x))
79 .unwrap_or_else(|| "—".into());
80 s.push_str(&format!("| Top tool | {} ({}) |\n", t, p));
81 }
82 if let Some(med) = report.stats.median_session_minutes {
83 s.push_str(&format!("| Median session | {} min |\n", med));
84 }
85 s
86}
87
88pub fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
90 if let Some(parent) = path.parent() {
91 fs::create_dir_all(parent)?;
92 }
93 let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
94 let mut f = File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
95 f.write_all(content)?;
96 f.sync_all().ok();
97 drop(f);
98 fs::rename(&tmp, path)
99 .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
100 Ok(())
101}
102
103pub struct ReportsDirLock(#[allow(dead_code)] File);
105
106impl ReportsDirLock {
107 pub fn acquire(reports_dir: &Path) -> Result<Self> {
108 fs::create_dir_all(reports_dir)?;
109 let p = reports_dir.join(".retro.lock");
110 let f = OpenOptions::new()
111 .create(true)
112 .read(true)
113 .write(true)
114 .truncate(false)
115 .open(&p)
116 .with_context(|| format!("lock {}", p.display()))?;
117 fs4::FileExt::lock(&f).with_context(|| format!("lock {}", p.display()))?;
118 Ok(Self(f))
119 }
120}