1use 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
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 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
160pub 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
175pub 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}