Skip to main content

kaizen/report/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Atomic report files + Markdown rendering for retro.
3
4use crate::retro::types::Report;
5use anyhow::{Context, Result};
6use std::fs::{self, File, OpenOptions};
7use std::io::Write;
8use std::path::Path;
9
10/// ISO week label e.g. `2026-W17` (UTC).
11pub 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
16/// Serialize report as JSON for `kaizen retro --json`.
17pub fn to_json(report: &Report) -> Result<String> {
18    Ok(serde_json::to_string_pretty(report)?)
19}
20
21/// Markdown matching `docs/retro.md` output shape.
22pub 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
88/// Write bytes to `path` via temp file + rename.
89pub 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
103/// Exclusive lock for the reports directory (released when the inner [`File`] is closed on drop).
104pub 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}