double_o/classify.rs
1//! Command output classification and intelligent truncation.
2//!
3//! This module is the core of `oo`'s context-efficient output handling. It analyzes
4//! command results and produces one of five [`Classification`] outcomes:
5//!
6//! - **Failure**: Non-zero exit codes → filtered error output (full output indexed for recall when the slice does not match it)
7//! - **Passthrough**: Small successful outputs (≤ 4 KiB, 4096 bytes) → verbatim
8//! - **Success**: Large successful outputs with pattern match → compressed summary
9//! - **Bounded**: Large Content/Unknown output → full output indexed, byte-bounded display
10//! - **Large**: Large Data output without pattern → indexed for recall
11//!
12//! The [`classify`] function combines pattern matching with automatic command category
13//! detection to make intelligent decisions about how to present output.
14
15use crate::exec::CommandOutput;
16use crate::pattern::{self, Pattern};
17
18/// 4 KiB (4096 bytes). Successful output of at most this many bytes passes
19/// through verbatim; larger output is classified.
20pub const SMALL_THRESHOLD: usize = 4096;
21
22/// Minimum savings (bytes) for the `[saved …]` indicator-line suffix.
23///
24/// Deliberately independent of [`SMALL_THRESHOLD`]/[`DISPLAY_CAP`] — the same
25/// 4096 value is a coincidence: this is a noise floor on a savings *delta*,
26/// whereas `SMALL_THRESHOLD` is the passthrough byte budget.
27///
28/// Below this floor the suffix is suppressed entirely: a `[saved 12 B]` tag
29/// on every command is noise and would itself waste context. The floor also
30/// subsumes non-positive deltas (summary nearly as long as the input)
31/// and sub-KiB values that `humansize` would render as `0 B` / `996 B`,
32/// so no separate rule is needed for those cases.
33pub const MIN_SAVINGS: usize = 4096;
34
35/// Total byte budget for the display slice of a bounded (Content/Unknown) output.
36///
37/// Defined as `SMALL_THRESHOLD` so the invariant "we never display more bytes
38/// than the passthrough budget" is compiler-enforced rather than maintained
39/// by convention. Split 60 % head / 40 % tail, mirroring [`smart_truncate`]'s
40/// ratio.
41pub const DISPLAY_CAP: usize = SMALL_THRESHOLD;
42
43/// Maximum lines to show in failure output before smart truncation kicks in.
44const TRUNCATION_THRESHOLD: usize = 80;
45
46/// Hard cap on total lines shown after truncation.
47const MAX_LINES: usize = 120;
48
49/// Command category — determines default output handling when no pattern matches.
50///
51/// Categories are auto-detected from command strings using [`detect_category`].
52/// When a large output has no matching pattern, the category determines the fallback
53/// behavior:
54///
55/// - **Status**: Test runners, builds, linters → quiet success (empty summary)
56/// - **Content**: File viewers and diffs → bounded display + indexed full output
57/// - **Data**: Listing and querying commands → index for recall
58/// - **Unknown**: Anything else → bounded display + indexed full output
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CommandCategory {
61 /// test runners, linters, builds — agent wants pass/fail (quiet success)
62 Status,
63 /// git show, git diff, cat — agent wants the actual output (bounded + indexed)
64 Content,
65 /// git log, gh api, ls — structured/queryable data (index for recall)
66 Data,
67 /// anything else — bounded display + indexed (safe default)
68 Unknown,
69}
70
71/// Command output classification result.
72///
73/// Represents the outcome of analyzing a command's exit code and output.
74/// Each variant determines how the output should be presented to the AI agent.
75///
76/// # Variants
77///
78/// - **Failure**: Command exited non-zero. Contains filtered error output and the full merged output.
79/// - **Passthrough**: Command succeeded with small output. Contains verbatim output.
80/// - **Success**: Command succeeded with large output and pattern match. Contains compressed summary.
81/// - **Bounded**: Content/Unknown command with large output. Full output is indexed; `display` is a byte-bounded head+tail slice.
82/// - **Large**: Data command with large output and no pattern. Output is indexed for recall.
83///
84/// The classification is produced by the [`classify`] function.
85#[derive(Debug)]
86pub enum Classification {
87 /// Exit ≠ 0. Filtered failure output.
88 ///
89 /// # Fields
90 ///
91 /// * `label` - Short label derived from the command (e.g., "cargo", "pytest").
92 /// * `output` - Filtered error output shown to the user, truncated if large.
93 /// * `full_output` - Complete merged stdout+stderr, indexed for recall when
94 /// it differs from `output`.
95 Failure {
96 /// Short label derived from the command (e.g., "cargo", "pytest").
97 label: String,
98 /// Filtered error output shown to the user, truncated if large.
99 output: String,
100 /// Complete merged stdout+stderr, indexed for recall when it differs from `output`.
101 full_output: String,
102 },
103
104 /// Exit 0, output ≤ threshold. Verbatim.
105 ///
106 /// # Fields
107 ///
108 /// * `output` - The full command output (merged stdout and stderr).
109 Passthrough {
110 /// The full command output (merged stdout and stderr).
111 output: String,
112 },
113
114 /// Exit 0, output > threshold, pattern matched with summary.
115 ///
116 /// # Fields
117 ///
118 /// * `label` - Short label derived from the command (e.g., "cargo", "pytest").
119 /// * `summary` - Compressed summary extracted using the pattern's template.
120 Success {
121 /// Short label derived from the command (e.g., "cargo", "pytest").
122 label: String,
123 /// Compressed summary extracted using the pattern's template.
124 summary: String,
125 },
126
127 /// Exit 0, output > threshold, no pattern, Content or Unknown category.
128 ///
129 /// The full output is indexed for recall; `display` is a byte-bounded
130 /// head+tail slice (≤ [`DISPLAY_CAP`] bytes + truncation marker).
131 ///
132 /// # Fields
133 ///
134 /// * `label` - Short label derived from the command (e.g., "git", "gh").
135 /// * `output` - The full command output to be indexed for recall.
136 /// * `display` - Byte-bounded head+tail slice for display (≤ [`DISPLAY_CAP`] + marker).
137 /// * `size` - Size of the full output in bytes.
138 Bounded {
139 /// Short label derived from the command (e.g., "git", "gh").
140 label: String,
141 /// The full command output to be indexed for recall.
142 output: String,
143 /// Byte-bounded head+tail slice for display.
144 display: String,
145 /// Size of the full output in bytes.
146 size: usize,
147 },
148
149 /// Exit 0, output > threshold, no pattern. Data category — index for recall.
150 ///
151 /// # Fields
152 ///
153 /// * `label` - Short label derived from the command (e.g., "git", "gh").
154 /// * `output` - The full command output to be indexed for recall.
155 /// * `size` - Size of the output in bytes.
156 Large {
157 /// Short label derived from the command (e.g., "git", "gh").
158 label: String,
159 /// The full command output to be indexed for recall.
160 output: String,
161 /// Size of the output in bytes.
162 size: usize,
163 },
164}
165
166/// Derive a short label from a command string.
167///
168/// Extracts the binary name, skipping any leading `sudo`/`env` prefix and
169/// stripping any path prefix. For example:
170/// - "cargo test" → "cargo"
171/// - "/usr/bin/python script.py" → "python"
172/// - "gh issue list" → "gh"
173/// - "sudo cargo test" → "cargo"
174/// - "env FOO=bar cargo test" → "cargo"
175///
176/// If stripping the prefix leaves no token, the original first token is
177/// returned (e.g., "sudo" → "sudo", "env" → "env").
178///
179/// # Arguments
180///
181/// * `command` - The command string
182///
183/// # Returns
184///
185/// A short label derived from the command.
186pub fn label(command: &str) -> String {
187 let parts: Vec<&str> = command.split_whitespace().collect();
188 let Some(first) = parts.first() else {
189 return "command".to_string();
190 };
191 // `binary_index` is the single source of truth for the sudo/env skip
192 // rule. When stripping leaves no token (bare "sudo"/"env"), it returns
193 // `None` and we degrade to the original first token (path-stripped).
194 let name = match binary_index(&parts) {
195 Some(i) => parts[i].rsplit('/').next().unwrap_or(parts[i]),
196 None => first.rsplit('/').next().unwrap_or(first),
197 };
198 name.to_string()
199}
200
201/// Position of the binary in a split argv after stripping a leading
202/// `sudo`/`env` prefix — the single source of truth for that skip rule.
203///
204/// Applies only at position 0, and only for exactly `sudo` (after path
205/// stripping) or `env`. For `env`, consecutive leading `KEY=VALUE` assignment
206/// tokens are skipped until the real binary is reached. Strips once — does
207/// NOT loop, so `sudo sudo cargo test` does not strip the second `sudo`.
208///
209/// Returns `Some(0)` when there is no prefix (the binary is the first token
210/// as-is), the index of the stripped binary otherwise, and `None` when
211/// stripping leaves no token (e.g. bare "sudo" or "env").
212fn binary_index(parts: &[&str]) -> Option<usize> {
213 let first = parts.first()?;
214 let base = first.rsplit('/').next().unwrap_or(first);
215 match base {
216 "sudo" => parts.get(1).map(|_| 1),
217 "env" => {
218 let mut i = 1;
219 while i < parts.len() && is_var_value(parts[i]) {
220 i += 1;
221 }
222 (i < parts.len()).then_some(i)
223 }
224 _ => Some(0),
225 }
226}
227
228/// Returns true when a token looks like a shell `KEY=VALUE` assignment.
229///
230/// The leading-dash guard keeps `env` flags out of the skip loop: a flag
231/// like `--split-string=x` contains `=`, and treating it as a `KEY=VALUE`
232/// assignment would silently skip it — misclassifying the command as Status
233/// and suppressing its output to a quiet `✓` line with no recall hint. A
234/// flag stops the loop instead, so the binary becomes the flag token and the
235/// category falls back to Unknown (output preserved).
236fn is_var_value(token: &str) -> bool {
237 !token.starts_with('-') && token.contains('=')
238}
239
240/// Detect command category from command string.
241///
242/// Analyzes the command string to determine its category, which is used as
243/// a fallback when no pattern matches for large outputs.
244///
245/// # Categories
246///
247/// - **Status**: Test runners, builds, linters → quiet success
248/// - **Content**: File viewers and diffs → bounded display + indexed full output
249/// - **Data**: Listing and querying commands → index for recall
250/// - **Unknown**: Anything else → bounded display + indexed full output
251///
252/// # Arguments
253///
254/// * `command` - The command string to analyze
255///
256/// # Returns
257///
258/// A [`CommandCategory`] indicating the command's type.
259pub fn detect_category(command: &str) -> CommandCategory {
260 let parts: Vec<&str> = command.split_whitespace().collect();
261 if parts.is_empty() {
262 return CommandCategory::Unknown;
263 }
264
265 // The binary index is the single source of truth for the sudo/env skip
266 // rule; the binary name and the subcommand (the next token, "" if none)
267 // are both derived from it. Stripping that leaves no token (bare
268 // "sudo"/"env") degrades to the original token 0 — path-stripped, which
269 // is a no-op for bare "sudo"/"env" — and the subcommand to the raw
270 // token 1 ("" if none). `binary_index` is computed once per call; the
271 // `nextest` arm below reuses the same index for its `i + 2` lookup.
272 let binary_idx = binary_index(&parts);
273 let (binary, subcommand) = match binary_idx {
274 Some(i) => (
275 parts[i].rsplit('/').next().unwrap_or(parts[i]),
276 parts.get(i + 1).copied().unwrap_or(""),
277 ),
278 None => (
279 parts[0].rsplit('/').next().unwrap_or(parts[0]),
280 parts.get(1).copied().unwrap_or(""),
281 ),
282 };
283
284 match binary {
285 // Status: test runners, build systems, linters
286 "cargo" => match subcommand {
287 "test" | "clippy" | "build" | "fmt" | "check" => CommandCategory::Status,
288 // `cargo nextest run` → Status (lookup fix only — not a general
289 // argv parser; see issue #149). Use the KNOWN subcommand position
290 // (token after the stripped binary) rather than scanning the whole
291 // argv for "nextest": a later token that happens to equal
292 // "nextest" (e.g. `env FOO=nextest cargo nextest run`) would
293 // otherwise land on the wrong token. Everything after "run" is
294 // irrelevant — any trailing argv (package filters, flags) is
295 // accepted.
296 "nextest" => {
297 // `run` sits at a fixed offset after the stripped binary:
298 // binary index + 2 (binary, "nextest", "run"). Reusing the
299 // index computed above keeps the position single-sourced.
300 let token_after_nextest = binary_idx.and_then(|i| parts.get(i + 2));
301 match token_after_nextest.copied() {
302 Some("run") => CommandCategory::Status,
303 _ => CommandCategory::Unknown,
304 }
305 }
306 _ => CommandCategory::Unknown,
307 },
308 "pytest" | "jest" | "vitest" | "go" | "npm" | "yarn" | "pnpm" | "bun" | "eslint"
309 | "ruff" | "mypy" | "tsc" | "make" | "rubocop" => CommandCategory::Status,
310
311 // Content: file viewers and diffs
312 "git" => match subcommand {
313 "show" | "diff" => CommandCategory::Content,
314 "log" | "status" | "branch" | "tag" => CommandCategory::Data,
315 _ => CommandCategory::Unknown,
316 },
317 "cat" | "bat" | "less" => CommandCategory::Content,
318
319 // Data: listing and querying
320 "gh" => CommandCategory::Data,
321 "ls" | "find" | "grep" | "rg" => CommandCategory::Data,
322
323 _ => CommandCategory::Unknown,
324 }
325}
326
327/// Classify command output using patterns and automatic category detection.
328///
329/// This is the main entry point for output classification. It analyzes the command's
330/// exit code, output size, and applies pattern matching to determine the appropriate
331/// presentation strategy.
332///
333/// # Algorithm
334///
335/// 1. **Failure path** (exit_code ≠ 0): Apply failure pattern or smart truncation
336/// 2. **Small success** (output ≤ 4 KiB): Pass through verbatim
337/// 3. **Pattern match**: Extract summary using success pattern
338/// 4. **Category fallback**: Use command category to determine behavior
339///
340/// # Arguments
341///
342/// * `output` - The command's exit code, stdout, and stderr
343/// * `command` - The command string (used for pattern matching and category detection)
344/// * `patterns` - List of patterns to try (typically [`pattern::builtins`] + user patterns)
345///
346/// # Returns
347///
348/// A [`Classification`] indicating how to present the output.
349///
350/// # Examples
351///
352/// ```
353/// use double_o::{classify, CommandOutput};
354/// use double_o::pattern::builtins;
355///
356/// let output = CommandOutput {
357/// stdout: b"test result: ok. 5 passed; 0 failed; finished in 0.3s".to_vec(),
358/// stderr: Vec::new(),
359/// exit_code: 0,
360/// };
361/// let patterns = builtins();
362/// let result = classify(&output, "cargo test", patterns);
363/// ```
364pub fn classify(output: &CommandOutput, command: &str, patterns: &[Pattern]) -> Classification {
365 let merged = output.merged_lossy();
366 let lbl = label(command);
367
368 // Failure path
369 if output.exit_code != 0 {
370 let filtered = match pattern::find_matching(command, patterns) {
371 Some(pat) => {
372 if let Some(failure) = &pat.failure {
373 pattern::extract_failure(failure, &merged)
374 } else {
375 smart_truncate(&merged)
376 }
377 }
378 None => smart_truncate(&merged),
379 };
380 return Classification::Failure {
381 label: lbl,
382 output: filtered,
383 full_output: merged,
384 };
385 }
386
387 // Success, small output → passthrough
388 if merged.len() <= SMALL_THRESHOLD {
389 return Classification::Passthrough { output: merged };
390 }
391
392 // Success, large output — try pattern
393 if let Some(pat) = pattern::find_matching(command, patterns) {
394 if let Some(sp) = &pat.success {
395 if let Some(summary) = pattern::extract_summary(sp, &merged) {
396 return Classification::Success {
397 label: lbl,
398 summary,
399 };
400 }
401 }
402 }
403
404 // Large, no pattern match — use category to determine behavior
405 let category = detect_category(command);
406 match category {
407 CommandCategory::Status => {
408 // Status commands: quiet success (empty summary)
409 Classification::Success {
410 label: lbl,
411 summary: String::new(),
412 }
413 }
414 CommandCategory::Content | CommandCategory::Unknown => {
415 // Content and Unknown: bounded display, full output indexed for recall
416 let size = merged.len();
417 let display = bounded_truncate(&merged);
418 Classification::Bounded {
419 label: lbl,
420 output: merged,
421 display,
422 size,
423 }
424 }
425 CommandCategory::Data => {
426 // Data: index for recall
427 let size = merged.len();
428 Classification::Large {
429 label: lbl,
430 output: merged,
431 size,
432 }
433 }
434 }
435}
436
437/// Floor a byte offset to the nearest preceding char boundary.
438///
439/// Equivalent to `str::floor_char_boundary` (stable since 1.91) but implemented
440/// for MSRV 1.85 compatibility.
441fn floor_char_boundary(s: &str, idx: usize) -> usize {
442 if idx >= s.len() {
443 return s.len();
444 }
445 let mut i = idx;
446 while i > 0 && !s.is_char_boundary(i) {
447 i -= 1;
448 }
449 i
450}
451
452/// Ceil a byte offset to the nearest following char boundary.
453///
454/// Equivalent to `str::ceil_char_boundary` (stable since 1.91) but implemented
455/// for MSRV 1.85 compatibility.
456fn ceil_char_boundary(s: &str, idx: usize) -> usize {
457 if idx >= s.len() {
458 return s.len();
459 }
460 let mut i = idx;
461 while i < s.len() && !s.is_char_boundary(i) {
462 i += 1;
463 }
464 i
465}
466
467/// Byte-based truncation with UTF-8 char-boundary safety.
468///
469/// Produces a head+tail slice bounded by [`DISPLAY_CAP`] bytes total, with a
470/// single truncation marker line between them. Cuts snap to `\n` boundaries
471/// (at most one line of drift) as a nicety, but the cap is enforced on the
472/// ASSEMBLED slices: line-snapping may only ever shrink a slice relative to
473/// its byte budget, never grow it past it (a single line can be arbitrarily
474/// long — minified JS/JSON, base64). If the output has fewer than 2 newlines,
475/// cuts fall back to char-boundary-only slicing. Never splits a multi-byte
476/// UTF-8 sequence.
477///
478/// Returns the input unchanged when it is ≤ [`DISPLAY_CAP`] bytes.
479pub fn bounded_truncate(output: &str) -> String {
480 if output.len() <= DISPLAY_CAP {
481 return output.to_string();
482 }
483
484 let head_budget = (DISPLAY_CAP as f64 * 0.6) as usize; // 2457
485 let tail_budget = DISPLAY_CAP - head_budget; // 1639
486
487 let (head_end, tail_start) = cut_boundaries(output, head_budget, tail_budget);
488 // Enforce the cap on the assembled result, not just the budgets: line
489 // snapping must never let head + tail grow past the byte budget. Hard-clamp
490 // the head down to its budget (floor) and the tail up to its budget (ceil).
491 let clamped_head = floor_char_boundary(output, head_budget).min(head_end);
492 // `.max(tail_start)` is NOT redundant: cut_boundaries' overlap guard can
493 // return tail_start = output.len(), in which case ceil(raw_tail) would
494 // otherwise restore a tail slice whose head and tail regions OVERLAP
495 // (head_end >= raw_tail means a non-empty output[raw_tail..head_end] would
496 // appear twice — head, then again as tail). The guard's `len` encodes
497 // "no tail slice" and must win over the budget-based fallback. (The head
498 // side's `.min(head_end)` IS load-bearing for the long-line case: a line
499 // can exceed its budget, so the floor must win there.)
500 let clamped_tail =
501 ceil_char_boundary(output, output.len().saturating_sub(tail_budget)).max(tail_start);
502 debug_assert!(
503 clamped_head + (output.len() - clamped_tail) <= DISPLAY_CAP,
504 "head+tail slices ({clamped_head} + {} bytes) must not exceed DISPLAY_CAP",
505 output.len() - clamped_tail
506 );
507
508 let truncated_bytes = output.len() - clamped_head - (output.len() - clamped_tail);
509 let marker =
510 format!("... [{truncated_bytes} bytes truncated → use `oo recall` to query] ...\n");
511
512 let mut result = String::with_capacity(DISPLAY_CAP + marker.len());
513 result.push_str(&output[..clamped_head]);
514 result.push_str(&marker);
515 result.push_str(&output[clamped_tail..]);
516 result
517}
518
519/// Compute head/tail cut byte offsets for [`bounded_truncate`].
520///
521/// Snaps the head cut forward to the next `\n` (≤ one line) and the tail cut
522/// backward to the previous `\n` (≤ one line). A single forward O(1)-memory
523/// pass finds the only three facts that matter: whether ≥ 2 newlines exist,
524/// the first newline at/after `head_budget`, and the last newline strictly
525/// before `raw_tail`. When fewer than 2 newlines exist in the entire output,
526/// falls back to char-boundary-only cuts.
527///
528/// NOTE: the snapped positions may EXCEED the byte budgets (a line can be
529/// arbitrarily long). [`bounded_truncate`] enforces the cap on the assembled
530/// slices — line-snapping is a nicety that may only shrink, never grow.
531fn cut_boundaries(output: &str, head_budget: usize, tail_budget: usize) -> (usize, usize) {
532 let raw_tail = output.len().saturating_sub(tail_budget);
533 let mut newline_count = 0usize;
534 let mut first_nl_at_or_after_head: Option<usize> = None;
535 let mut last_nl_before_raw_tail: Option<usize> = None;
536 for (i, b) in output.as_bytes().iter().enumerate() {
537 if *b != b'\n' {
538 continue;
539 }
540 newline_count += 1;
541 // Early-exit when both facts are settled: we have a newline at/after
542 // head_budget AND this newline is >= raw_tail (so it can no longer be
543 // the last newline before raw_tail). Honest win: it skips only the
544 // trailing tail-budget window; with dense newlines the loop still
545 // scans essentially the whole buffer (it exits at max(raw_tail, first
546 // nl at/after head_budget)). The real win is O(1) memory — no Vec of
547 // newline positions is ever allocated.
548 if i >= raw_tail && first_nl_at_or_after_head.is_some() {
549 break;
550 }
551 if i >= head_budget && first_nl_at_or_after_head.is_none() {
552 first_nl_at_or_after_head = Some(i);
553 }
554 if i < raw_tail {
555 last_nl_before_raw_tail = Some(i);
556 }
557 }
558
559 // Fewer than 2 newlines: line-snapping has nothing to snap to (there is
560 // at most one newline in the entire output, so neither cut can land on
561 // the "right" side of a line and still keep its slice near budget), so
562 // use char-boundary-only cuts directly — exactly the bounds the clamp in
563 // `bounded_truncate` would apply, keeping the display within budget.
564 if newline_count < 2 {
565 let head = floor_char_boundary(output, head_budget);
566 let tail = ceil_char_boundary(output, raw_tail);
567 return (head, tail);
568 }
569
570 // Snap head cut forward to next \n (at most one line of drift). When no
571 // newline falls at/after head_budget, the raw budget itself is used as the
572 // fallback and must be snapped to a char boundary before `+1` — the `+1`
573 // is only a safe "skip the newline" when the offset actually is a newline.
574 let head_end = match first_nl_at_or_after_head {
575 Some(pos) => pos + 1, // include the newline in the head slice
576 None => floor_char_boundary(output, head_budget),
577 };
578
579 // Snap tail cut backward to previous \n (at most one line of drift). Same
580 // fallback hazard on the tail side: when no newline falls before raw_tail,
581 // the raw budget must be ceiled to a char boundary. (In practice this
582 // fallback is also shielded by the overlap guard below, but snapping it
583 // keeps the invariant local and symmetric with the head cut.)
584 let tail_start = match last_nl_before_raw_tail {
585 Some(pos) => pos + 1, // start after the newline
586 None => ceil_char_boundary(output, raw_tail),
587 };
588
589 // Ensure head doesn't overlap tail: returning `tail_start = output.len()`
590 // encodes "no tail slice" — the head covers everything shown (in this
591 // branch head_end ≤ output.len(), so the head is non-empty and the tail
592 // is empty, which is always a valid display).
593 if head_end >= tail_start {
594 return (head_end, output.len());
595 }
596
597 (head_end, tail_start)
598}
599
600/// Smart truncation: first 60% + marker + last 40%, capped at MAX_LINES.
601pub fn smart_truncate(output: &str) -> String {
602 let lines: Vec<&str> = output.lines().collect();
603 let total = lines.len();
604
605 if total <= TRUNCATION_THRESHOLD {
606 return output.to_string();
607 }
608
609 let budget = total.min(MAX_LINES);
610 let head_count = (budget as f64 * 0.6).ceil() as usize;
611 let tail_count = budget - head_count;
612 let truncated = total - head_count - tail_count;
613
614 let mut result = lines[..head_count].join("\n");
615 if truncated > 0 {
616 result.push_str(&format!("\n... [{truncated} lines truncated] ...\n"));
617 }
618 if tail_count > 0 {
619 result.push_str(&lines[total - tail_count..].join("\n"));
620 }
621 result
622}
623
624/// Tests live in `classify_tests.rs` (sibling module, see `#[path]` below) —
625/// this file holds only production code to stay under the 500-line cap.
626#[cfg(test)]
627#[path = "classify_tests.rs"]
628mod tests;