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;