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
15#[cfg(test)]
16use crate::classify_bounded::cut_boundaries;
17pub use crate::classify_bounded::{bounded_truncate, bounded_truncate_indexed};
18use crate::exec::CommandOutput;
19use crate::pattern::{self, Pattern};
20
21/// 4 KiB (4096 bytes). Successful output of at most this many bytes passes
22/// through verbatim; larger output is classified.
23pub const SMALL_THRESHOLD: usize = 4096;
24
25/// Minimum savings (bytes) for the `[saved …]` indicator-line suffix.
26///
27/// Deliberately independent of [`SMALL_THRESHOLD`]/[`DISPLAY_CAP`] — the same
28/// 4096 value is a coincidence: this is a noise floor on a savings *delta*,
29/// whereas `SMALL_THRESHOLD` is the passthrough byte budget.
30///
31/// Below this floor the suffix is suppressed entirely: a `[saved 12 B]` tag
32/// on every command is noise and would itself waste context. The floor also
33/// subsumes non-positive deltas (summary nearly as long as the input)
34/// and sub-KiB values that `humansize` would render as `0 B` / `996 B`,
35/// so no separate rule is needed for those cases.
36pub const MIN_SAVINGS: usize = 4096;
37
38/// Total byte budget for the display slice of a bounded (Content/Unknown) output.
39///
40/// Defined as `SMALL_THRESHOLD` so the invariant "we never display more bytes
41/// than the passthrough budget" is compiler-enforced rather than maintained
42/// by convention. Split 60 % head / 40 % tail, mirroring [`smart_truncate`]'s
43/// ratio.
44pub const DISPLAY_CAP: usize = SMALL_THRESHOLD;
45
46/// Output up to this many lines is returned unchanged (head/tail joins would fuse lines); longer output is capped here.
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/// Smart truncation: first 60% + marker + last 40%, capped at MAX_LINES.
438pub fn smart_truncate(output: &str) -> String {
439    let lines: Vec<&str> = output.lines().collect();
440    let total = lines.len();
441
442    if total <= MAX_LINES {
443        return output.to_string();
444    }
445
446    let budget = total.min(MAX_LINES);
447    let head_count = (budget as f64 * 0.6).ceil() as usize;
448    let tail_count = budget - head_count;
449    let truncated = total - head_count - tail_count;
450
451    let mut result = lines[..head_count].join("\n");
452    if truncated > 0 {
453        result.push_str(&format!("\n... [{truncated} lines truncated] ...\n"));
454    }
455    if tail_count > 0 {
456        result.push_str(&lines[total - tail_count..].join("\n"));
457    }
458    result
459}
460
461/// Tests live in `classify_tests.rs` (sibling module, see `#[path]` below) —
462/// this file holds only production code to stay under the 500-line cap.
463#[cfg(test)]
464#[path = "classify_tests.rs"]
465mod tests;
466#[cfg(test)]
467#[path = "classify_trunc_tests.rs"]
468mod trunc_tests;