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::{Bet, BetCategory, Confidence, 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    let mut index = 1;
36    let high = report
37        .top_bets
38        .iter()
39        .position(|b| b.confidence == Some(Confidence::High));
40    if let Some(high_idx) = high {
41        render_section(
42            &mut s,
43            "High-Confidence Bet",
44            report.top_bets[high_idx..=high_idx].iter(),
45            &mut index,
46        );
47    } else {
48        s.push_str(
49            "> No high-confidence bet this window; treat remaining bets as exploratory.\n\n",
50        );
51    }
52    render_section(
53        &mut s,
54        "To Investigate",
55        report.top_bets.iter().enumerate().filter_map(|(i, b)| {
56            (Some(i) != high && b.category == Some(BetCategory::Investigation)).then_some(b)
57        }),
58        &mut index,
59    );
60    render_section(
61        &mut s,
62        "Quick Hygiene",
63        report.top_bets.iter().enumerate().filter_map(|(i, b)| {
64            (Some(i) != high
65                && matches!(
66                    b.category,
67                    None | Some(BetCategory::QuickWin | BetCategory::Hygiene)
68                ))
69            .then_some(b)
70        }),
71        &mut index,
72    );
73    if !report.skipped_deduped.is_empty() {
74        s.push_str("## Skipped Bets (deduped vs prior reports)\n\n");
75        for line in &report.skipped_deduped {
76            s.push_str(&format!("- {}\n", line));
77        }
78        s.push('\n');
79    }
80    s.push_str("## Raw Stats\n\n");
81    s.push_str("| Metric | Value |\n|---|---|\n");
82    s.push_str(&format!("| Sessions | {} |\n", report.stats.sessions));
83    s.push_str(&format!(
84        "| Total cost | ${:.2} |\n",
85        report.stats.total_cost_usd_e6 as f64 / 1_000_000.0
86    ));
87    if let Some(ref m) = report.stats.top_model {
88        let p = report
89            .stats
90            .top_model_pct
91            .map(|x| format!("{}%", x))
92            .unwrap_or_else(|| "—".into());
93        s.push_str(&format!("| Top model | {} ({}) |\n", m, p));
94    }
95    if let Some(ref t) = report.stats.top_tool {
96        let p = report
97            .stats
98            .top_tool_pct
99            .map(|x| format!("{}%", x))
100            .unwrap_or_else(|| "—".into());
101        s.push_str(&format!("| Top tool | {} ({}) |\n", t, p));
102    }
103    if let Some(med) = report.stats.median_session_minutes {
104        s.push_str(&format!("| Median session | {} min |\n", med));
105    }
106    s
107}
108
109fn render_section<'a, I>(s: &mut String, title: &str, bets: I, index: &mut usize)
110where
111    I: Iterator<Item = &'a Bet>,
112{
113    let start = *index;
114    for bet in bets {
115        if *index == start {
116            s.push_str(&format!("## {}\n\n", title));
117        }
118        render_bet(s, bet, index);
119    }
120}
121
122fn render_bet(s: &mut String, bet: &Bet, index: &mut usize) {
123    s.push_str(&format!(
124        "### {}. {} ({} · {} · {})\n",
125        *index,
126        bet.title,
127        bet.heuristic_id,
128        confidence_label(bet),
129        category_label(bet)
130    ));
131    s.push_str(&format!("- Hypothesis: {}\n", bet.hypothesis));
132    render_evidence(s, bet);
133    s.push_str(&format!(
134        "- Saves ~{:.0} tokens/week (est.) · Confidence: {}\n",
135        bet.expected_tokens_saved_per_week,
136        confidence_label(bet)
137    ));
138    s.push_str(&format!(
139        "- Effort: {} min · Apply: {}\n\n",
140        bet.effort_minutes, bet.apply_step
141    ));
142    *index += 1;
143}
144
145fn render_evidence(s: &mut String, bet: &Bet) {
146    if bet.evidence.is_empty() {
147        return;
148    }
149    s.push_str(&format!("- Evidence: {}\n", bet.evidence.join(" · ")));
150}
151
152fn confidence_label(bet: &Bet) -> &'static str {
153    bet.confidence.map_or("Unknown", Confidence::label)
154}
155
156fn category_label(bet: &Bet) -> &'static str {
157    bet.category.map_or("unknown", BetCategory::label)
158}
159
160/// Write bytes to `path` via temp file + rename.
161pub fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
162    if let Some(parent) = path.parent() {
163        fs::create_dir_all(parent)?;
164    }
165    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
166    let mut f = File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
167    f.write_all(content)?;
168    f.sync_all().ok();
169    drop(f);
170    fs::rename(&tmp, path)
171        .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
172    Ok(())
173}
174
175/// Exclusive lock for the reports directory (released when the inner [`File`] is closed on drop).
176pub struct ReportsDirLock(#[allow(dead_code)] File);
177
178impl ReportsDirLock {
179    pub fn acquire(reports_dir: &Path) -> Result<Self> {
180        fs::create_dir_all(reports_dir)?;
181        let p = reports_dir.join(".retro.lock");
182        let f = OpenOptions::new()
183            .create(true)
184            .read(true)
185            .write(true)
186            .truncate(false)
187            .open(&p)
188            .with_context(|| format!("lock {}", p.display()))?;
189        fs4::FileExt::lock(&f).with_context(|| format!("lock {}", p.display()))?;
190        Ok(Self(f))
191    }
192}