Skip to main content

aft/compress/
mod.rs

1//! Output compression for hoisted bash.
2//!
3//! Compression has three tiers, tried in this order:
4//!
5//! 1. **Rust [`Compressor`] modules** — stateful, hand-written parsers for
6//!    high-traffic tools where heuristics like JSON parsing or section
7//!    detection are required. Always wins when matched.
8//! 2. **TOML filters** — declarative strip + truncate + cap + shortcircuit
9//!    rules for the long tail of CLI tools. Loaded from builtin / user /
10//!    project sources via [`toml_filter::build_registry`]. See
11//!    [`toml_filter`] and [`trust`] for the trust model.
12//! 3. **[`generic`] fallback** — ANSI strip + consecutive-dedup +
13//!    middle-truncate. Always applies when no Rust module or TOML filter
14//!    matches.
15
16pub mod biome;
17pub mod builtin_filters;
18pub mod bun;
19pub mod cargo;
20pub mod eslint;
21pub mod generic;
22pub mod git;
23pub mod npm;
24pub mod pnpm;
25pub mod pytest;
26pub mod toml_filter;
27pub mod trust;
28pub mod tsc;
29pub mod vitest;
30
31use crate::context::AppContext;
32use biome::BiomeCompressor;
33use bun::BunCompressor;
34use cargo::CargoCompressor;
35use eslint::EslintCompressor;
36use generic::{strip_ansi, GenericCompressor};
37use git::GitCompressor;
38use npm::NpmCompressor;
39use pnpm::PnpmCompressor;
40use pytest::PytestCompressor;
41use std::path::{Path, PathBuf};
42use std::sync::{Arc, RwLock};
43use toml_filter::{apply_filter, FilterRegistry};
44use tsc::TscCompressor;
45use vitest::VitestCompressor;
46
47/// Thread-safe handle to the TOML filter registry. Shared between
48/// `AppContext::filter_registry()` (for direct use in command handlers) and
49/// `BgTaskRegistry`'s output compression closure (for use from the watchdog
50/// thread).
51pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
52
53/// A `Compressor` knows how to reduce one specific command's output to fewer
54/// tokens while preserving the information the agent needs.
55pub trait Compressor {
56    /// Returns true if this compressor handles the given command head + args.
57    /// Called after generic detection (ANSI strip, dedup) so this is per-command logic only.
58    fn matches(&self, command: &str) -> bool;
59
60    /// Compress the output. Original is left untouched if compression fails.
61    fn compress(&self, command: &str, output: &str) -> String;
62}
63
64/// Top-level dispatch: try Rust modules, then TOML filters, then generic fallback.
65///
66/// Convenience wrapper for command handlers that already hold an `AppContext`.
67/// Backs onto [`compress_with_registry`] which is thread-safe for use from the
68/// `BgTaskRegistry` watchdog.
69pub fn compress(command: &str, output: String, ctx: &AppContext) -> String {
70    if !ctx.config().experimental_bash_compress {
71        return output;
72    }
73    let registry_handle = ctx.shared_filter_registry();
74    let guard = match registry_handle.read() {
75        Ok(g) => g,
76        Err(poisoned) => poisoned.into_inner(),
77    };
78    compress_with_registry(command, &output, &guard)
79}
80
81/// Thread-safe dispatch that does not need `AppContext`. Caller is responsible
82/// for the `experimental_bash_compress` gate (the registry has no opinion).
83///
84/// Used from background threads (notably the `BgTaskRegistry` watchdog and
85/// completion-frame emitter) where lock-free access is required.
86pub fn compress_with_registry(command: &str, output: &str, registry: &FilterRegistry) -> String {
87    let stripped = strip_ansi(output);
88
89    // Tier 1: Rust modules — always win when matched.
90    let compressors: [&dyn Compressor; 10] = [
91        &GitCompressor,
92        &CargoCompressor,
93        &TscCompressor,
94        &NpmCompressor,
95        &BunCompressor,
96        &PnpmCompressor,
97        &PytestCompressor,
98        &EslintCompressor,
99        &VitestCompressor,
100        &BiomeCompressor,
101    ];
102    for compressor in compressors {
103        if compressor.matches(command) {
104            return compressor.compress(command, &stripped);
105        }
106    }
107
108    // Tier 2: TOML filters.
109    if let Some(filter) = registry.lookup(command) {
110        return apply_filter(filter, &stripped);
111    }
112
113    // Tier 3: generic fallback.
114    GenericCompressor.compress(command, &stripped)
115}
116
117/// Build the registry of TOML filters from the standard sources for the
118/// active context. Called lazily by [`AppContext::filter_registry`].
119///
120/// Layering (highest priority first):
121/// 1. Project filters at `<project_root>/.aft/filters/*.toml` — loaded only
122///    when the project is in the trusted set (see [`trust`]).
123/// 2. User filters at `<storage_dir>/filters/*.toml`.
124/// 3. Builtin filters compiled into the binary via [`builtin_filters`].
125pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
126    let config = ctx.config();
127    let storage_dir = config.storage_dir.clone();
128    let project_root = config.project_root.clone();
129    drop(config);
130
131    let user_dir = storage_dir.as_ref().map(|d| d.join("filters"));
132    let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
133        (Some(root), Some(storage)) => {
134            if trust::is_project_trusted(Some(storage), root) {
135                Some(root.join(".aft").join("filters"))
136            } else {
137                None
138            }
139        }
140        _ => None,
141    };
142
143    toml_filter::build_registry(
144        builtin_filters::ALL,
145        user_dir.as_deref(),
146        project_dir.as_deref(),
147    )
148}
149
150/// Resolve the user-filter directory for an arbitrary storage_dir. Used by
151/// `aft doctor filters` to inspect filters without needing a live AppContext.
152pub fn user_filter_dir(storage_dir: &Path) -> PathBuf {
153    storage_dir.join("filters")
154}
155
156/// Resolve the project-filter directory for an arbitrary project root.
157/// Returns the directory regardless of trust state — caller must check trust
158/// separately if it wants to gate loading.
159pub fn project_filter_dir(project_root: &Path) -> PathBuf {
160    project_root.join(".aft").join("filters")
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn user_and_project_filter_dir_helpers() {
169        let storage = Path::new("/tmp/aft-storage");
170        assert_eq!(
171            user_filter_dir(storage),
172            Path::new("/tmp/aft-storage/filters")
173        );
174
175        let project = Path::new("/repo");
176        assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
177    }
178}