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_for_generic = 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_for_generic);
105 }
106 }
107
108 // Tier 2: TOML filters. Pass raw output so `[ansi].strip = false` filters
109 // can intentionally match escape sequences; `apply_filter` owns ANSI policy.
110 if let Some(filter) = registry.lookup(command) {
111 return apply_filter(filter, output);
112 }
113
114 // Tier 3: generic fallback.
115 GenericCompressor.compress(command, &stripped_for_generic)
116}
117
118/// Build the registry of TOML filters from the standard sources for the
119/// active context. Called lazily by [`AppContext::filter_registry`].
120///
121/// Layering (highest priority first):
122/// 1. Project filters at `<project_root>/.aft/filters/*.toml` — loaded only
123/// when the project is in the trusted set (see [`trust`]).
124/// 2. User filters at `<storage_dir>/filters/*.toml`.
125/// 3. Builtin filters compiled into the binary via [`builtin_filters`].
126pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
127 let config = ctx.config();
128 let storage_dir = config.storage_dir.clone();
129 let project_root = config.project_root.clone();
130 drop(config);
131
132 let user_dir = storage_dir.as_ref().map(|d| d.join("filters"));
133 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
134 (Some(root), Some(storage)) => {
135 if trust::is_project_trusted(Some(storage), root) {
136 Some(root.join(".aft").join("filters"))
137 } else {
138 None
139 }
140 }
141 _ => None,
142 };
143
144 toml_filter::build_registry(
145 builtin_filters::ALL,
146 user_dir.as_deref(),
147 project_dir.as_deref(),
148 )
149}
150
151/// Resolve the user-filter directory for an arbitrary storage_dir. Used by
152/// `aft doctor filters` to inspect filters without needing a live AppContext.
153pub fn user_filter_dir(storage_dir: &Path) -> PathBuf {
154 storage_dir.join("filters")
155}
156
157/// Resolve the project-filter directory for an arbitrary project root.
158/// Returns the directory regardless of trust state — caller must check trust
159/// separately if it wants to gate loading.
160pub fn project_filter_dir(project_root: &Path) -> PathBuf {
161 project_root.join(".aft").join("filters")
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn user_and_project_filter_dir_helpers() {
170 let storage = Path::new("/tmp/aft-storage");
171 assert_eq!(
172 user_filter_dir(storage),
173 Path::new("/tmp/aft-storage/filters")
174 );
175
176 let project = Path::new("/repo");
177 assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
178 }
179}