Skip to main content

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;