1use crate::core::config;
5use crate::core::event::{Event, SessionRecord};
6use crate::core::repo::repo_head;
7use crate::experiment::store as exp_store;
8use crate::experiment::types::{
9 Binding, Classification, Criterion, Direction, Experiment, Metric, State, transition,
10};
11use crate::experiment::{self as exp};
12use crate::shell::cli::{scan_all_agents, workspace_path};
13use crate::store::Store;
14use anyhow::{Context, Result, anyhow};
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub struct NewArgs {
19 pub name: String,
20 pub hypothesis: String,
21 pub change: String,
22 pub metric: String,
23 pub bind: String,
24 pub duration_days: u32,
25 pub target_pct: f64,
26 pub control_commit: Option<String>,
27 pub treatment_commit: Option<String>,
28}
29
30pub fn exp_new_text(workspace: Option<&Path>, args: NewArgs) -> Result<String> {
31 let ws = workspace_path(workspace)?;
32 let db_path = ws.join(".kaizen/kaizen.db");
33 let store = Store::open(&db_path)?;
34 let metric =
35 Metric::parse(&args.metric).ok_or_else(|| anyhow!("unknown metric: {}", args.metric))?;
36 let binding = build_binding(&ws, &args)?;
37 let (direction, target_pct) = split_target(args.target_pct);
38 let exp_rec = Experiment {
39 id: uuid::Uuid::now_v7().to_string(),
40 name: args.name.clone(),
41 hypothesis: args.hypothesis,
42 change_description: args.change,
43 metric,
44 binding,
45 duration_days: args.duration_days,
46 success_criterion: Criterion::Delta {
47 direction,
48 target_pct,
49 },
50 state: State::Running,
51 created_at_ms: now_ms(),
52 concluded_at_ms: None,
53 };
54 exp_store::save_experiment(&store, &exp_rec)?;
55 Ok(format!("created {} · {}\n", exp_rec.id, exp_rec.name))
56}
57
58pub fn cmd_new(workspace: Option<&Path>, args: NewArgs) -> Result<()> {
59 print!("{}", exp_new_text(workspace, args)?);
60 Ok(())
61}
62
63fn build_binding(ws: &Path, args: &NewArgs) -> Result<Binding> {
64 match args.bind.as_str() {
65 "git" => {
66 let treatment = match args.treatment_commit.clone() {
67 Some(v) => v,
68 None => repo_head(ws)?
69 .ok_or_else(|| anyhow!("not a git repo; pass --treatment-commit"))?,
70 };
71 let control = match args.control_commit.clone() {
72 Some(v) => v,
73 None => parent_of(ws, &treatment)?,
74 };
75 Ok(Binding::GitCommit {
76 control_commit: control,
77 treatment_commit: treatment,
78 })
79 }
80 "manual" => Ok(Binding::ManualTag {
81 variant_field: "variant".into(),
82 }),
83 other => Err(anyhow!("unsupported bind: {other} (use git|manual)")),
84 }
85}
86
87fn split_target(pct: f64) -> (Direction, f64) {
88 if pct < 0.0 {
89 (Direction::Decrease, pct)
90 } else {
91 (Direction::Increase, pct)
92 }
93}
94
95fn parent_of(ws: &Path, commit: &str) -> Result<String> {
96 let out = std::process::Command::new("git")
97 .arg("-C")
98 .arg(ws)
99 .args(["rev-parse", &format!("{commit}^")])
100 .output()
101 .context("git rev-parse parent")?;
102 if !out.status.success() {
103 return Err(anyhow!(
104 "git rev-parse failed: {}",
105 String::from_utf8_lossy(&out.stderr)
106 ));
107 }
108 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
109}
110
111pub fn exp_list_text(workspace: Option<&Path>) -> Result<String> {
112 use std::fmt::Write;
113 let ws = workspace_path(workspace)?;
114 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
115 let all = exp_store::list_experiments(&store)?;
116 let mut out = String::new();
117 if all.is_empty() {
118 writeln!(&mut out, "(no experiments)").unwrap();
119 return Ok(out);
120 }
121 writeln!(
122 &mut out,
123 "{:<38} {:<10} {:<24} METRIC",
124 "ID", "STATE", "NAME"
125 )
126 .unwrap();
127 writeln!(&mut out, "{}", "-".repeat(96)).unwrap();
128 for e in &all {
129 writeln!(
130 &mut out,
131 "{:<38} {:<10?} {:<24} {}",
132 e.id,
133 e.state,
134 truncate(&e.name, 24),
135 e.metric.as_str()
136 )
137 .unwrap();
138 }
139 Ok(out)
140}
141
142pub fn cmd_list(workspace: Option<&Path>) -> Result<()> {
143 print!("{}", exp_list_text(workspace)?);
144 Ok(())
145}
146
147pub fn exp_status_text(workspace: Option<&Path>, id: &str) -> Result<String> {
148 use std::fmt::Write;
149 let ws = workspace_path(workspace)?;
150 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
151 let e = exp_store::load_experiment(&store, id)?
152 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
153 let mut out = String::new();
154 writeln!(&mut out, "id: {}", e.id).unwrap();
155 writeln!(&mut out, "name: {}", e.name).unwrap();
156 writeln!(&mut out, "state: {:?}", e.state).unwrap();
157 writeln!(&mut out, "metric: {}", e.metric.as_str()).unwrap();
158 writeln!(&mut out, "duration: {}d", e.duration_days).unwrap();
159 writeln!(&mut out, "created: {}", e.created_at_ms).unwrap();
160 if let Some(c) = e.concluded_at_ms {
161 writeln!(&mut out, "concluded: {c}").unwrap();
162 }
163 writeln!(&mut out, "hypothesis: {}", e.hypothesis).unwrap();
164 writeln!(&mut out, "change: {}", e.change_description).unwrap();
165 match &e.binding {
166 Binding::GitCommit {
167 control_commit,
168 treatment_commit,
169 } => {
170 writeln!(
171 &mut out,
172 "binding: git control={control_commit} treatment={treatment_commit}"
173 )
174 .unwrap();
175 }
176 Binding::Branch {
177 control_branch,
178 treatment_branch,
179 } => {
180 writeln!(
181 &mut out,
182 "binding: branch control={control_branch} treatment={treatment_branch}"
183 )
184 .unwrap();
185 }
186 Binding::ManualTag { variant_field } => {
187 writeln!(&mut out, "binding: manual({variant_field})").unwrap();
188 }
189 }
190 Ok(out)
191}
192
193pub fn cmd_status(workspace: Option<&Path>, id: &str) -> Result<()> {
194 print!("{}", exp_status_text(workspace, id)?);
195 Ok(())
196}
197
198pub fn exp_tag_text(
199 workspace: Option<&Path>,
200 id: &str,
201 session_id: &str,
202 variant: &str,
203) -> Result<String> {
204 let ws = workspace_path(workspace)?;
205 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
206 let v = match variant {
207 "control" => Classification::Control,
208 "treatment" => Classification::Treatment,
209 "excluded" => Classification::Excluded,
210 other => {
211 return Err(anyhow!(
212 "variant must be control|treatment|excluded, got {other}"
213 ));
214 }
215 };
216 exp_store::tag_session(&store, id, session_id, v)?;
217 Ok(format!("tagged {session_id} -> {variant} for {id}\n"))
218}
219
220pub fn cmd_tag(workspace: Option<&Path>, id: &str, session_id: &str, variant: &str) -> Result<()> {
221 print!("{}", exp_tag_text(workspace, id, session_id, variant)?);
222 Ok(())
223}
224
225pub fn exp_report_text(workspace: Option<&Path>, id: &str, json_out: bool) -> Result<String> {
226 let ws = workspace_path(workspace)?;
227 let cfg = config::load(&ws)?;
228 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
229 let ws_str = ws.to_string_lossy().to_string();
230 scan_all_agents(&ws, &cfg, &ws_str, &store)?;
231 let exp_rec = exp_store::load_experiment(&store, id)?
232 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
233 let (start_ms, end_ms) = window_for(&exp_rec);
234 let sessions = sessions_with_events_in(&store, &ws_str, start_ms, end_ms)?;
235 let manual = exp_store::manual_tags(&store, id)?;
236 let report = exp::run(&exp_rec, &sessions, &manual, &ws);
237 if json_out {
238 Ok(serde_json::to_string_pretty(&report)?)
239 } else {
240 Ok(exp::to_markdown(&report))
241 }
242}
243
244pub fn cmd_report(workspace: Option<&Path>, id: &str, json_out: bool) -> Result<()> {
245 print!("{}", exp_report_text(workspace, id, json_out)?);
246 Ok(())
247}
248
249pub fn exp_conclude_text(workspace: Option<&Path>, id: &str) -> Result<String> {
250 let ws = workspace_path(workspace)?;
251 let store = Store::open(&ws.join(".kaizen/kaizen.db"))?;
252 let exp_rec = exp_store::load_experiment(&store, id)?
253 .ok_or_else(|| anyhow!("experiment not found: {id}"))?;
254 let next = transition(exp_rec.state, "conclude")
255 .ok_or_else(|| anyhow!("cannot conclude from {:?}", exp_rec.state))?;
256 exp_store::set_state(&store, id, next, now_ms())?;
257 Ok(format!("concluded {id}\n"))
258}
259
260pub fn cmd_conclude(workspace: Option<&Path>, id: &str) -> Result<()> {
261 print!("{}", exp_conclude_text(workspace, id)?);
262 Ok(())
263}
264
265fn window_for(e: &Experiment) -> (u64, u64) {
266 let end = e
267 .concluded_at_ms
268 .unwrap_or_else(|| e.created_at_ms + (e.duration_days as u64) * 86_400_000);
269 (e.created_at_ms, end.max(e.created_at_ms))
270}
271
272fn sessions_with_events_in(
273 store: &Store,
274 ws: &str,
275 start_ms: u64,
276 end_ms: u64,
277) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
278 let rows = store.retro_events_in_window(ws, start_ms, end_ms)?;
279 let mut by_id: std::collections::BTreeMap<String, (SessionRecord, Vec<Event>)> =
280 std::collections::BTreeMap::new();
281 for (s, e) in rows {
282 by_id
283 .entry(s.id.clone())
284 .or_insert_with(|| (s.clone(), Vec::new()))
285 .1
286 .push(e);
287 }
288 Ok(by_id.into_values().collect())
289}
290
291fn now_ms() -> u64 {
292 SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .unwrap_or_default()
295 .as_millis() as u64
296}
297
298fn truncate(s: &str, max: usize) -> String {
299 if s.len() <= max {
300 return s.to_string();
301 }
302 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
303 out.push('…');
304 out
305}