1pub mod classify;
6pub mod compare;
7pub mod envelope;
8pub mod generic;
9pub mod normalize;
10pub mod parsers;
11pub mod redact;
12
13pub use classify::Classification;
14
15use crate::config::Config;
16use crate::store::{Db, RunRecord};
17use crate::util::{estimate_tokens, sha256_hex, short_id};
18use envelope::Envelope;
19
20pub struct ReduceInput<'a> {
21 pub run_id: &'a str,
22 pub created_at: &'a str,
23 pub repo_root: &'a str,
24 pub cwd: &'a str,
25 pub shim_name: &'a str,
26 pub command_original: &'a str,
27 pub command_family: &'a str,
28 pub command_key: &'a str,
29 pub exit_code: i32,
30 pub git_head: Option<&'a str>,
31 pub git_worktree_hash: Option<&'a str>,
32 pub redacted_stdout: &'a [u8],
33 pub redacted_stderr: &'a [u8],
34}
35
36pub struct ReduceOutput {
37 pub classification: Classification,
38 pub normalized: String,
39 pub normalized_hash: String,
40 pub comparison_base_run_id: Option<String>,
41 pub comparison_result: String,
42 pub summary: Option<String>,
43 pub emit_stdout: Vec<u8>,
44 pub emit_stderr: Vec<u8>,
45 pub estimated_raw_tokens: i64,
46 pub estimated_emitted_tokens: i64,
47 pub estimated_saved_tokens: i64,
48}
49
50pub fn reduce(db: &Db, cfg: &Config, input: &ReduceInput) -> anyhow::Result<ReduceOutput> {
52 let combined_raw = combined(input.redacted_stdout, input.redacted_stderr);
53 let raw_tokens = estimate_tokens(&combined_raw) as i64;
54
55 let normalized = normalize::normalize(&combined_raw);
56 let normalized_hash = sha256_hex(normalized.as_bytes());
57
58 let prior = db.find_comparable_prior(
60 input.repo_root,
61 input.cwd,
62 input.command_family,
63 input.command_key,
64 input.created_at,
65 )?;
66 let prior_normalized = prior
67 .as_ref()
68 .and_then(|p| p.normalized_path.as_ref())
69 .and_then(|path| std::fs::read_to_string(path).ok());
70
71 let classification = classify_state(
72 cfg,
73 prior.as_ref(),
74 &normalized,
75 &normalized_hash,
76 &prior_normalized,
77 );
78
79 let emit = build_emit(
80 cfg,
81 input,
82 &normalized,
83 prior.as_ref(),
84 prior_normalized.as_deref(),
85 classification,
86 raw_tokens,
87 );
88
89 let comparison_base_run_id = match classification {
90 Classification::FirstSeen => None,
91 _ => prior.as_ref().map(|p| p.id.clone()),
92 };
93
94 Ok(ReduceOutput {
95 classification,
96 normalized,
97 normalized_hash,
98 comparison_base_run_id,
99 comparison_result: classification.as_str().to_string(),
100 summary: emit.summary,
101 emit_stdout: emit.stdout,
102 emit_stderr: emit.stderr,
103 estimated_raw_tokens: raw_tokens,
104 estimated_emitted_tokens: emit.emitted_tokens,
105 estimated_saved_tokens: emit.saved_tokens,
106 })
107}
108
109fn classify_state(
110 cfg: &Config,
111 prior: Option<&RunRecord>,
112 normalized: &str,
113 normalized_hash: &str,
114 prior_normalized: &Option<String>,
115) -> Classification {
116 let (Some(prior), Some(prior_norm)) = (prior, prior_normalized) else {
117 return Classification::FirstSeen;
118 };
119 if prior.normalized_hash.as_deref() == Some(normalized_hash) {
120 return Classification::Unchanged;
121 }
122 let stats = compare::diff_stats(prior_norm, normalized);
123 if stats.changed_lines <= cfg.small_delta_max_changed_lines
124 && stats.ratio <= cfg.small_delta_max_changed_ratio
125 {
126 Classification::SmallDelta
127 } else {
128 Classification::LargeDelta
129 }
130}
131
132struct Emit {
133 stdout: Vec<u8>,
134 stderr: Vec<u8>,
135 emitted_tokens: i64,
136 saved_tokens: i64,
137 summary: Option<String>,
138}
139
140#[allow(clippy::too_many_arguments)]
141fn build_emit(
142 cfg: &Config,
143 input: &ReduceInput,
144 normalized: &str,
145 prior: Option<&RunRecord>,
146 prior_normalized: Option<&str>,
147 classification: Classification,
148 raw_tokens: i64,
149) -> Emit {
150 use Classification::*;
151
152 if raw_tokens < cfg.min_raw_tokens_to_reduce as i64 {
157 return Emit {
158 emitted_tokens: raw_tokens,
159 saved_tokens: 0,
160 stdout: input.redacted_stdout.to_vec(),
161 stderr: input.redacted_stderr.to_vec(),
162 summary: generic::summarize(normalized, input.exit_code),
163 };
164 }
165
166 let prev_short = prior.map(|p| short_id(&p.id).to_string());
167 let run_short = short_id(input.run_id).to_string();
168 let max_lines = match classification {
169 SmallDelta => cfg.max_emitted_lines_small_delta,
170 LargeDelta => cfg.max_emitted_lines_large_delta,
171 _ => cfg.max_emitted_lines_first_seen,
172 };
173
174 let special = parsers::dispatch(
176 input.command_family,
177 input.shim_name,
178 input.command_key,
179 normalized,
180 prior_normalized,
181 classification,
182 input.exit_code,
183 prev_short.as_deref(),
184 max_lines,
185 );
186
187 let (mut status, mut body, full_label, summary) = match special {
188 Some(sp) => (sp.status, sp.body, sp.full_label, Some(sp.summary)),
189 None => {
190 let body = match classification {
191 SmallDelta => compare::unified_diff(
192 prior_normalized.unwrap_or(""),
193 normalized,
194 3,
195 cfg.max_emitted_lines_small_delta,
196 ),
197 Unchanged => prior
198 .and_then(|p| p.summary.clone())
199 .unwrap_or_else(|| generic::extract(normalized, max_lines, input.exit_code)),
200 FirstSeen | LargeDelta => generic::extract(normalized, max_lines, input.exit_code),
201 };
202 let status = generic_status(classification, input.exit_code, prev_short.as_deref());
203 (
204 status,
205 body,
206 "Full output",
207 generic::summarize(normalized, input.exit_code),
208 )
209 }
210 };
211
212 const BODY_MAX_CHARS: usize = 14_000;
217 const BODY_MAX_LINES: usize = 500;
218 body = clamp_body(body, BODY_MAX_CHARS, BODY_MAX_LINES);
219
220 const UNCHANGED_REPLAY_MAX_LINES: usize = 10;
223 if classification == Unchanged {
224 let n = body.lines().count();
225 if n > UNCHANGED_REPLAY_MAX_LINES {
226 let kept: Vec<&str> = body.lines().take(UNCHANGED_REPLAY_MAX_LINES).collect();
227 body = format!(
228 "{}\n... (+{} more identical lines)",
229 kept.join("\n"),
230 n - UNCHANGED_REPLAY_MAX_LINES
231 );
232 }
233 }
234
235 let exit_changed = prior
238 .map(|p| p.exit_code as i32 != input.exit_code)
239 .unwrap_or(false);
240 let headline = exit_changed.then(|| {
241 let p = prior.unwrap();
242 format!("Exit code changed: {} -> {}", p.exit_code, input.exit_code)
243 });
244 if let Some(p) = prior {
245 if p.exit_code != 0 && input.exit_code == 0 {
246 status = "command now passes.".to_string();
247 if let Some(prev_sum) = p.summary.as_deref() {
248 let capped: Vec<&str> = prev_sum.lines().take(8).collect();
249 if !capped.is_empty() {
250 body = format!(
251 "Previously failing summary:\n{}\n\n{}",
252 capped.join("\n"),
253 body
254 );
255 }
256 }
257 }
258 }
259
260 let note = git_note(
261 prior,
262 input.git_head,
263 input.git_worktree_hash,
264 classification,
265 );
266
267 let body_tokens = estimate_tokens(&body) as i64;
268 let suppressed = (raw_tokens - body_tokens).max(0);
269
270 let rendered = envelope::render(&Envelope {
271 status: &status,
272 command: input.command_original,
273 exit_code: input.exit_code,
274 headline: headline.as_deref(),
275 note: note.as_deref(),
276 body: &body,
277 suppressed_tokens: suppressed,
278 run_id_short: &run_short,
279 prev_id_short: prev_short.as_deref(),
280 full_label,
281 });
282
283 let emitted_tokens = estimate_tokens(&rendered) as i64;
284 let saved_tokens = (raw_tokens - emitted_tokens).max(0);
285 Emit {
286 stdout: rendered.into_bytes(),
287 stderr: Vec::new(),
288 emitted_tokens,
289 saved_tokens,
290 summary,
291 }
292}
293
294fn clamp_body(body: String, max_chars: usize, max_lines: usize) -> String {
297 let over_lines = body.lines().count() > max_lines;
298 let over_chars = body.chars().count() > max_chars;
299 if !over_lines && !over_chars {
300 return body;
301 }
302 let mut kept: String = body.lines().take(max_lines).collect::<Vec<_>>().join("\n");
303 if kept.chars().count() > max_chars {
304 kept = kept.chars().take(max_chars).collect();
305 }
306 kept.push_str("\n... (output clamped)");
307 kept
308}
309
310fn generic_status(classification: Classification, exit_code: i32, prev: Option<&str>) -> String {
311 use Classification::*;
312 let prev = prev.unwrap_or("?");
313 match classification {
314 FirstSeen => {
315 if exit_code != 0 {
316 "command failed.".to_string()
317 } else {
318 "command output.".to_string()
319 }
320 }
321 Unchanged => format!("output unchanged since run {prev}."),
322 SmallDelta => format!("output changed slightly since run {prev}."),
323 LargeDelta => format!("output changed significantly since run {prev}."),
324 }
325}
326
327fn git_note(
329 prior: Option<&RunRecord>,
330 curr_head: Option<&str>,
331 curr_worktree: Option<&str>,
332 classification: Classification,
333) -> Option<String> {
334 let prior = prior?;
335 let git_differs = prior.git_head.as_deref() != curr_head
336 || prior.git_worktree_hash.as_deref() != curr_worktree;
337 match classification {
338 Classification::SmallDelta | Classification::LargeDelta => Some(if git_differs {
339 "Note: output changed across code changes (git state differs).".to_string()
340 } else {
341 "Note: output changed with no code change — possibly flaky/nondeterministic."
342 .to_string()
343 }),
344 Classification::Unchanged if git_differs => {
345 Some("Note: output unchanged across code changes.".to_string())
346 }
347 _ => None,
348 }
349}
350
351fn combined(stdout: &[u8], stderr: &[u8]) -> String {
352 let mut s = String::from_utf8_lossy(stdout).into_owned();
353 if !stderr.is_empty() {
354 if !s.is_empty() && !s.ends_with('\n') {
355 s.push('\n');
356 }
357 s.push_str(&String::from_utf8_lossy(stderr));
358 }
359 s
360}