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