Skip to main content

aft/compress/
toml_filter.rs

1//! Declarative TOML output filters for hoisted bash compression.
2//!
3//! TOML filters are a complement to the Rust `Compressor` modules. They cover
4//! the long tail of CLI tools whose output is amenable to simple
5//! strip + truncate + cap + shortcircuit pipelines without requiring stateful
6//! parsing or invocation rewrite.
7//!
8//! ## Pipeline
9//!
10//! For a matched filter, output flows through:
11//! 1. `[strip]` — drop lines matching any regex (compiled with multiline mode)
12//! 2. `[shortcircuit]` — if remaining content matches `when`, replace with `replacement`
13//!    (compiled without multiline mode; use `(?m)` explicitly for line anchors,
14//!    and use `\A...\z` for full-body anchors such as empty output)
15//! 3. `[truncate]` — middle-truncate lines longer than `line_max`
16//! 4. `[cap]` — keep at most `max_lines` lines (head/tail/middle)
17//!
18//! ## Sources
19//!
20//! Filters come from three sources, layered project > user > builtin by filename:
21//! - **builtin**: shipped via `include_str!()` from `compress/builtin_filters/`
22//! - **user**: `~/.config/aft/filters/*.toml` (or `$XDG_CONFIG_HOME`-aware path)
23//! - **project**: `<project>/.cortexkit/aft/filters/*.toml` — trust-gated, see [`crate::compress::trust`]
24//!
25//! Bad filters are skipped with a warning, never panic.
26
27use std::collections::{HashMap, VecDeque};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use regex::{Regex, RegexBuilder};
32use serde::Deserialize;
33
34use crate::compress::caps::{cap_classified_blocks_with, ClassifiedBlock, DropClass};
35use crate::compress::CompressionResult;
36
37/// Approximate per-regex byte budget. Matches the budget RTK uses for its
38/// declarative filters; far more than any realistic compress regex needs.
39const REGEX_SIZE_LIMIT: usize = 2 * 1024 * 1024;
40
41/// Hard ceiling on a single filter's combined regex set. Prevents pathologically
42/// large filter files from inflating startup cost or memory.
43const MAX_PATTERNS_PER_FILTER: usize = 256;
44
45/// Default per-line truncation when `[truncate]` is omitted entirely. Matches
46/// existing AFT generic compressor behavior of "tolerate long lines unless told
47/// otherwise".
48const DEFAULT_LINE_MAX: usize = usize::MAX;
49
50/// Default line cap when `[cap]` is omitted. Matches the inline-cap budget.
51const DEFAULT_MAX_LINES: usize = usize::MAX;
52
53/// One TOML filter, parsed and ready to apply.
54#[derive(Debug, Clone)]
55pub struct TomlFilter {
56    pub name: String,
57    pub source: FilterSource,
58    pub matches: Vec<String>,
59    pub description: Option<String>,
60    pub strip: Vec<Regex>,
61    pub line_max: usize,
62    pub max_lines: usize,
63    pub keep: KeepMode,
64    pub class_cap: Option<TomlClassCap>,
65    pub shortcircuit_when: Option<Regex>,
66    pub shortcircuit_replacement: Option<String>,
67    pub strip_ansi: bool,
68}
69
70/// Where a filter came from. Drives priority and trust handling.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum FilterSource {
73    Builtin,
74    User { path: PathBuf },
75    Project { path: PathBuf },
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum KeepMode {
80    Head,
81    #[default]
82    Tail,
83    Middle,
84}
85
86#[derive(Debug, Clone)]
87pub struct TomlClassCap {
88    pub class: DropClass,
89    pub max: usize,
90    pub patterns: Vec<Regex>,
91}
92
93/// Aggregate registry of all loaded filters across all sources.
94///
95/// Lookup is by command program name (first non-env, non-path token of the
96/// command). Project filters override user filters override builtin filters
97/// when their `matches[]` overlap.
98#[derive(Debug, Default, Clone)]
99pub struct FilterRegistry {
100    /// Map from program name → resolved filter (already merged across sources).
101    by_match: HashMap<String, TomlFilter>,
102    /// All filters, indexed by `(source-priority, name)` for tooling/listing.
103    /// Order is builtin → user → project so lower-priority entries appear first.
104    all: Vec<TomlFilter>,
105    /// Non-fatal load warnings the agent or doctor command should surface.
106    warnings: Vec<String>,
107}
108
109impl FilterRegistry {
110    /// Look up a filter for a command. Returns the highest-priority filter
111    /// whose `matches[]` contains the command's program name.
112    pub fn lookup(&self, command: &str) -> Option<&TomlFilter> {
113        let program = program_name(command)?;
114        self.by_match.get(program)
115    }
116
117    /// All filters loaded into this registry, in builtin → user → project order.
118    pub fn all(&self) -> &[TomlFilter] {
119        &self.all
120    }
121
122    /// Non-fatal warnings emitted during load. Use these for doctor / configure
123    /// warning surfacing.
124    pub fn warnings(&self) -> &[String] {
125        &self.warnings
126    }
127}
128
129/// Build a registry from sources in priority order.
130///
131/// `builtin_inputs` is supplied by the caller (shipped via `include_str!`)
132/// because constants live in `crate::compress::mod`.
133pub fn build_registry(
134    builtin_inputs: &[(&'static str, &'static str)],
135    user_dir: Option<&Path>,
136    project_dir: Option<&Path>,
137) -> FilterRegistry {
138    let mut registry = FilterRegistry::default();
139
140    // Builtin: always loaded.
141    for (name, content) in builtin_inputs {
142        match parse_filter(name, content, FilterSource::Builtin) {
143            Ok(filter) => insert_filter(&mut registry, filter),
144            Err(e) => registry
145                .warnings
146                .push(format!("builtin filter {name}: {e}")),
147        }
148    }
149
150    // User: loaded if dir exists.
151    if let Some(dir) = user_dir {
152        load_dir(dir, &mut registry, |path| FilterSource::User {
153            path: path.to_path_buf(),
154        });
155    }
156
157    // Project: loaded if dir exists. Caller is responsible for trust gating
158    // *before* calling this — pass `None` for `project_dir` if the project
159    // is untrusted.
160    if let Some(dir) = project_dir {
161        load_dir(dir, &mut registry, |path| FilterSource::Project {
162            path: path.to_path_buf(),
163        });
164    }
165
166    registry
167}
168
169fn load_dir<F>(dir: &Path, registry: &mut FilterRegistry, source_for: F)
170where
171    F: Fn(&Path) -> FilterSource,
172{
173    let entries = match fs::read_dir(dir) {
174        Ok(entries) => entries,
175        Err(e) => {
176            // Missing dir is normal; only warn on real IO errors.
177            if e.kind() != std::io::ErrorKind::NotFound {
178                registry
179                    .warnings
180                    .push(format!("filter dir {}: {e}", dir.display()));
181            }
182            return;
183        }
184    };
185
186    let mut paths: Vec<PathBuf> = entries
187        .filter_map(|res| res.ok())
188        .map(|entry| entry.path())
189        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("toml"))
190        .collect();
191    paths.sort();
192
193    for path in paths {
194        let content = match fs::read_to_string(&path) {
195            Ok(s) => s,
196            Err(e) => {
197                registry
198                    .warnings
199                    .push(format!("filter {}: read failed: {e}", path.display()));
200                continue;
201            }
202        };
203        let name = path
204            .file_stem()
205            .and_then(|s| s.to_str())
206            .unwrap_or("<unknown>")
207            .to_string();
208        let source = source_for(&path);
209        match parse_filter(&name, &content, source) {
210            Ok(filter) => insert_filter(registry, filter),
211            Err(e) => registry
212                .warnings
213                .push(format!("filter {}: {e}", path.display())),
214        }
215    }
216}
217
218fn insert_filter(registry: &mut FilterRegistry, filter: TomlFilter) {
219    // Higher-priority sources (project > user > builtin) overwrite earlier
220    // entries with the same `match` keyword. Filename-keyed override is also
221    // implicit because higher-priority filters arrive later in build order.
222    for keyword in &filter.matches {
223        registry.by_match.insert(keyword.clone(), filter.clone());
224    }
225    // Replace any existing entry in `all` for the same logical name+source so
226    // re-loads don't duplicate (mainly relevant in tests).
227    registry
228        .all
229        .retain(|existing| !(existing.name == filter.name && existing.source == filter.source));
230    registry.all.push(filter);
231}
232
233#[derive(Debug, Deserialize)]
234struct RawFilter {
235    #[serde(default)]
236    filter: RawFilterMeta,
237    #[serde(default)]
238    strip: Option<RawStrip>,
239    #[serde(default)]
240    truncate: Option<RawTruncate>,
241    #[serde(default)]
242    cap: Option<RawCap>,
243    #[serde(default)]
244    class_cap: Option<RawClassCap>,
245    #[serde(default)]
246    shortcircuit: Option<RawShortcircuit>,
247    #[serde(default)]
248    ansi: Option<RawAnsi>,
249}
250
251#[derive(Debug, Deserialize, Default)]
252struct RawFilterMeta {
253    #[serde(default)]
254    matches: Vec<String>,
255    #[serde(default)]
256    description: Option<String>,
257}
258
259#[derive(Debug, Deserialize, Default)]
260struct RawStrip {
261    #[serde(default)]
262    patterns: Vec<String>,
263}
264
265#[derive(Debug, Deserialize, Default)]
266struct RawTruncate {
267    #[serde(default)]
268    line_max: Option<usize>,
269}
270
271#[derive(Debug, Deserialize, Default)]
272struct RawCap {
273    #[serde(default)]
274    max_lines: Option<usize>,
275    #[serde(default)]
276    keep: Option<String>,
277}
278
279#[derive(Debug, Deserialize, Default)]
280struct RawClassCap {
281    #[serde(default)]
282    class: Option<String>,
283    #[serde(default)]
284    max: Option<usize>,
285    #[serde(default)]
286    patterns: Vec<String>,
287}
288
289#[derive(Debug, Deserialize, Default)]
290struct RawShortcircuit {
291    #[serde(default)]
292    when: Option<String>,
293    #[serde(default)]
294    replacement: Option<String>,
295}
296
297#[derive(Debug, Deserialize, Default)]
298struct RawAnsi {
299    #[serde(default)]
300    strip: Option<bool>,
301}
302
303/// Parse one filter from TOML text. Returns a load-time error string suitable
304/// for surfacing in warnings; never panics.
305pub fn parse_filter(name: &str, content: &str, source: FilterSource) -> Result<TomlFilter, String> {
306    let raw: RawFilter = toml::from_str(content).map_err(|e| format!("invalid TOML: {e}"))?;
307
308    let mut matches = raw.filter.matches;
309    if matches.is_empty() {
310        // Default to filename-as-program when [filter].matches is omitted.
311        matches.push(name.to_string());
312    }
313    for keyword in &matches {
314        if keyword.is_empty() || keyword.contains(char::is_whitespace) {
315            return Err(format!("invalid match keyword {keyword:?}"));
316        }
317    }
318
319    let strip_patterns = raw.strip.unwrap_or_default().patterns;
320    if strip_patterns.len() > MAX_PATTERNS_PER_FILTER {
321        return Err(format!(
322            "too many strip patterns ({} > {MAX_PATTERNS_PER_FILTER})",
323            strip_patterns.len()
324        ));
325    }
326    let mut strip = Vec::with_capacity(strip_patterns.len());
327    for pattern in strip_patterns {
328        let regex =
329            build_regex(&pattern, true).map_err(|e| format!("strip pattern {pattern:?}: {e}"))?;
330        strip.push(regex);
331    }
332
333    let line_max = raw
334        .truncate
335        .as_ref()
336        .and_then(|t| t.line_max)
337        .unwrap_or(DEFAULT_LINE_MAX);
338
339    let cap = raw.cap.unwrap_or_default();
340    let max_lines = cap.max_lines.unwrap_or(DEFAULT_MAX_LINES);
341    let keep = match cap.keep.as_deref() {
342        None => KeepMode::default(),
343        Some("head") => KeepMode::Head,
344        Some("tail") => KeepMode::Tail,
345        Some("middle") => KeepMode::Middle,
346        Some(other) => return Err(format!("invalid cap.keep {other:?}")),
347    };
348
349    let class_cap = match raw.class_cap {
350        Some(raw_class_cap) => {
351            if raw_class_cap.patterns.len() > MAX_PATTERNS_PER_FILTER {
352                return Err(format!(
353                    "too many class_cap patterns ({} > {MAX_PATTERNS_PER_FILTER})",
354                    raw_class_cap.patterns.len()
355                ));
356            }
357            let class = parse_drop_class(raw_class_cap.class.as_deref().unwrap_or("list"))?;
358            let mut patterns = Vec::with_capacity(raw_class_cap.patterns.len());
359            for pattern in raw_class_cap.patterns {
360                let regex = build_regex(&pattern, true)
361                    .map_err(|e| format!("class_cap pattern {pattern:?}: {e}"))?;
362                patterns.push(regex);
363            }
364            Some(TomlClassCap {
365                class,
366                max: raw_class_cap.max.unwrap_or_else(|| class.default_cap()),
367                patterns,
368            })
369        }
370        None => None,
371    };
372
373    let shortcircuit = raw.shortcircuit.unwrap_or_default();
374    let (shortcircuit_when, shortcircuit_replacement) =
375        match (shortcircuit.when, shortcircuit.replacement) {
376            (Some(when), Some(replacement)) => {
377                let regex = build_regex(&when, false)
378                    .map_err(|e| format!("shortcircuit.when {when:?}: {e}"))?;
379                (Some(regex), Some(replacement))
380            }
381            (Some(_), None) => return Err("shortcircuit.when set but replacement missing".into()),
382            (None, Some(_)) => return Err("shortcircuit.replacement set but when missing".into()),
383            (None, None) => (None, None),
384        };
385
386    let strip_ansi = raw.ansi.and_then(|a| a.strip).unwrap_or(true);
387
388    Ok(TomlFilter {
389        name: name.to_string(),
390        source,
391        matches,
392        description: raw.filter.description,
393        strip,
394        line_max,
395        max_lines,
396        keep,
397        class_cap,
398        shortcircuit_when,
399        shortcircuit_replacement,
400        strip_ansi,
401    })
402}
403
404fn build_regex(pattern: &str, multiline: bool) -> Result<Regex, String> {
405    RegexBuilder::new(pattern)
406        .size_limit(REGEX_SIZE_LIMIT)
407        .multi_line(multiline)
408        .build()
409        .map_err(|e| e.to_string())
410}
411
412/// Run the filter pipeline on `output`. Returns compressed text.
413///
414/// Pipeline (in order):
415/// 1. ANSI strip (if `filter.strip_ansi`)
416/// 2. `[strip]` — drop matching lines
417/// 3. `[shortcircuit]` — if remainder matches `when`, return `replacement`
418/// 4. `[truncate]` — middle-truncate per line at `line_max`
419/// 5. `[cap]` — apply `max_lines` with `keep` mode
420pub fn apply_filter(filter: &TomlFilter, output: &str) -> CompressionResult {
421    apply_filter_with_exit_code(filter, output, None)
422}
423
424pub fn apply_filter_with_exit_code(
425    filter: &TomlFilter,
426    output: &str,
427    exit_code: Option<i32>,
428) -> CompressionResult {
429    if filter.strip_ansi {
430        let stripped = crate::compress::generic::strip_ansi(output);
431        apply_filter_to_text(filter, &stripped, exit_code)
432    } else {
433        apply_filter_to_text(filter, output, exit_code)
434    }
435}
436
437pub(crate) fn apply_filter_with_exit_code_prestripped(
438    filter: &TomlFilter,
439    raw_output: &str,
440    stripped_output: &str,
441    exit_code: Option<i32>,
442) -> CompressionResult {
443    let output = if filter.strip_ansi {
444        stripped_output
445    } else {
446        raw_output
447    };
448    apply_filter_to_text(filter, output, exit_code)
449}
450
451fn apply_filter_to_text(
452    filter: &TomlFilter,
453    output: &str,
454    exit_code: Option<i32>,
455) -> CompressionResult {
456    if filter.shortcircuit_when.is_none()
457        && filter.class_cap.is_none()
458        && filter.max_lines != usize::MAX
459    {
460        return apply_plain_cap_streaming(filter, output);
461    }
462
463    // Phase 1: line strip
464    let original_line_count = output.lines().count();
465    let kept: Vec<&str> = output
466        .lines()
467        .filter(|line| !filter.strip.iter().any(|re| re.is_match(line)))
468        .collect();
469    let strip_removed_lines = kept.len() < original_line_count;
470    // Phase 2: shortcircuit (against the after-strip body)
471    if let (Some(when), Some(replacement)) =
472        (&filter.shortcircuit_when, &filter.shortcircuit_replacement)
473    {
474        let after_strip = kept.join("\n");
475        let shortcircuit_safe = match exit_code {
476            Some(code) => code == 0,
477            None => !super::text_has_failure_signal(&after_strip),
478        };
479        if shortcircuit_safe && when.is_match(&after_strip) {
480            return CompressionResult::new(replacement.clone());
481        }
482    }
483
484    // Phase 3: per-line truncation
485    let truncated: Vec<String> = if filter.line_max == usize::MAX {
486        kept.iter().map(|s| (*s).to_string()).collect()
487    } else {
488        kept.iter()
489            .map(|line| truncate_line(line, filter.line_max))
490            .collect()
491    };
492
493    // Phase 4: class cap replaces plain [cap] when present; the two never stack.
494    if let Some(class_cap) = &filter.class_cap {
495        return cap_class_lines(&truncated, class_cap);
496    }
497
498    // Phase 5: plain line cap
499    cap_lines(
500        &truncated,
501        filter.max_lines,
502        filter.keep,
503        strip_removed_lines,
504    )
505}
506
507/// Apply a finite plain cap without materializing lines that the cap will discard.
508fn apply_plain_cap_streaming(filter: &TomlFilter, output: &str) -> CompressionResult {
509    let max_lines = filter.max_lines;
510    let head_count = match filter.keep {
511        KeepMode::Head => max_lines,
512        KeepMode::Tail => 0,
513        KeepMode::Middle => max_lines / 2,
514    };
515    let tail_count = match filter.keep {
516        KeepMode::Head => 0,
517        KeepMode::Tail => max_lines,
518        KeepMode::Middle => max_lines - head_count,
519    };
520    let mut head = Vec::new();
521    let mut tail = VecDeque::new();
522    let mut kept_line_count = 0usize;
523    let mut strip_removed_lines = false;
524
525    for line in output.lines() {
526        if filter.strip.iter().any(|pattern| pattern.is_match(line)) {
527            strip_removed_lines = true;
528            continue;
529        }
530
531        kept_line_count += 1;
532        if head.len() < head_count {
533            head.push(line);
534            continue;
535        }
536        if tail_count == 0 {
537            continue;
538        }
539        if tail.len() == tail_count {
540            tail.pop_front();
541        }
542        tail.push_back(line);
543    }
544
545    head.extend(tail);
546    let selected: Vec<String> = head
547        .into_iter()
548        .map(|line| truncate_line(line, filter.line_max))
549        .collect();
550    let text = selected.join("\n");
551
552    if kept_line_count <= max_lines {
553        return CompressionResult::new(text);
554    }
555    if max_lines == 0 {
556        return CompressionResult::with_inner_drop(String::new(), false);
557    }
558    if matches!(filter.keep, KeepMode::Tail) && !strip_removed_lines {
559        return CompressionResult::with_prefix_drop(
560            text,
561            kept_line_count.saturating_sub(max_lines) + 1,
562        );
563    }
564    CompressionResult::with_inner_drop(text, false)
565}
566
567fn truncate_line(line: &str, line_max: usize) -> String {
568    record_truncate_line_call();
569    if line.chars().count() <= line_max {
570        return line.to_string();
571    }
572    // Reserve 3 chars for the ellipsis marker.
573    let keep_each_side = line_max.saturating_sub(3) / 2;
574    let head: String = line.chars().take(keep_each_side).collect();
575    let tail: String = line
576        .chars()
577        .rev()
578        .take(keep_each_side)
579        .collect::<Vec<_>>()
580        .into_iter()
581        .rev()
582        .collect();
583    format!("{head}…{tail}")
584}
585
586#[cfg(test)]
587thread_local! {
588    static TRUNCATE_LINE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
589}
590
591#[cfg(test)]
592fn record_truncate_line_call() {
593    TRUNCATE_LINE_CALLS.with(|calls| calls.set(calls.get() + 1));
594}
595
596#[cfg(not(test))]
597fn record_truncate_line_call() {}
598
599#[cfg(test)]
600fn reset_truncate_line_calls() {
601    TRUNCATE_LINE_CALLS.with(|calls| calls.set(0));
602}
603
604#[cfg(test)]
605fn truncate_line_calls() -> usize {
606    TRUNCATE_LINE_CALLS.with(std::cell::Cell::get)
607}
608
609fn cap_class_lines(lines: &[String], class_cap: &TomlClassCap) -> CompressionResult {
610    let blocks = lines
611        .iter()
612        .map(|line| {
613            if class_cap.patterns.is_empty()
614                || class_cap
615                    .patterns
616                    .iter()
617                    .any(|pattern| pattern.is_match(line))
618            {
619                ClassifiedBlock::new(class_cap.class, line.clone())
620            } else {
621                ClassifiedBlock::unclassified(line.clone())
622            }
623        })
624        .collect();
625    let capped = cap_classified_blocks_with(blocks, |class| {
626        if class == class_cap.class {
627            class_cap.max
628        } else {
629            class.default_cap()
630        }
631    });
632    CompressionResult::with_class_drops(capped.text, capped.dropped_by_class)
633}
634
635fn cap_lines(
636    lines: &[String],
637    max_lines: usize,
638    keep: KeepMode,
639    had_prior_line_drop: bool,
640) -> CompressionResult {
641    if lines.len() <= max_lines || max_lines == usize::MAX {
642        return CompressionResult::new(lines.join("\n"));
643    }
644
645    if max_lines == 0 {
646        return CompressionResult::with_inner_drop(String::new(), false);
647    }
648
649    let kept = match keep {
650        KeepMode::Head => lines.iter().take(max_lines).cloned().collect::<Vec<_>>(),
651        KeepMode::Tail => lines
652            .iter()
653            .skip(lines.len().saturating_sub(max_lines))
654            .cloned()
655            .collect::<Vec<_>>(),
656        KeepMode::Middle => {
657            let head_count = max_lines / 2;
658            let tail_count = max_lines - head_count;
659            let mut kept: Vec<String> = lines.iter().take(head_count).cloned().collect();
660            kept.extend(lines.iter().skip(lines.len() - tail_count).cloned());
661            kept
662        }
663    };
664    if matches!(keep, KeepMode::Tail) && !had_prior_line_drop {
665        let dropped_prefix_lines = lines.len().saturating_sub(max_lines);
666        CompressionResult::with_prefix_drop(kept.join("\n"), dropped_prefix_lines + 1)
667    } else {
668        CompressionResult::with_inner_drop(kept.join("\n"), false)
669    }
670}
671
672fn parse_drop_class(value: &str) -> Result<DropClass, String> {
673    match value {
674        "error" | "errors" => Ok(DropClass::Error),
675        "warning" | "warnings" => Ok(DropClass::Warning),
676        "failure" | "failures" => Ok(DropClass::Failure),
677        "issue" | "issues" => Ok(DropClass::Issue),
678        "list" | "list_item" | "list-items" | "list items" => Ok(DropClass::List),
679        "inventory" | "inventory_item" | "inventory-items" | "inventory items" => {
680            Ok(DropClass::Inventory)
681        }
682        "timing" | "timing_line" | "timing-lines" | "timing lines" => Ok(DropClass::Timing),
683        other => Err(format!("invalid class_cap.class {other:?}")),
684    }
685}
686
687/// Extract the program name from a command line, stripping leading env-var
688/// assignments (`FOO=bar `) and absolute or relative paths (`/usr/bin/make`,
689/// `./node_modules/.bin/eslint`).
690///
691/// Examples:
692/// - `"make build"` → `Some("make")`
693/// - `"FOO=1 BAR=2 make"` → `Some("make")`
694/// - `"/usr/bin/cargo build"` → `Some("cargo")`
695/// - `""` → `None`
696pub fn program_name(command: &str) -> Option<&str> {
697    for token in command.split_whitespace() {
698        // Skip leading env-var assignments (key=value with no shell metachars).
699        if is_env_assignment(token) {
700            continue;
701        }
702        // Strip path prefix.
703        return Some(basename(token));
704    }
705    None
706}
707
708fn is_env_assignment(token: &str) -> bool {
709    let Some(eq) = token.find('=') else {
710        return false;
711    };
712    let key = &token[..eq];
713    !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
714}
715
716fn basename(token: &str) -> &str {
717    // Handle both Unix and Windows separators.
718    let last_unix = token.rfind('/');
719    let last_win = token.rfind('\\');
720    let split_at = match (last_unix, last_win) {
721        (Some(u), Some(w)) => u.max(w),
722        (Some(u), None) => u,
723        (None, Some(w)) => w,
724        (None, None) => return token,
725    };
726    &token[split_at + 1..]
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    fn parse(content: &str) -> TomlFilter {
734        parse_filter("test", content, FilterSource::Builtin).expect("parse")
735    }
736
737    fn materialized_plain_cap_reference(filter: &TomlFilter, output: &str) -> CompressionResult {
738        let original_line_count = output.lines().count();
739        let kept: Vec<&str> = output
740            .lines()
741            .filter(|line| !filter.strip.iter().any(|pattern| pattern.is_match(line)))
742            .collect();
743        let strip_removed_lines = kept.len() < original_line_count;
744        let truncated: Vec<String> = kept
745            .iter()
746            .map(|line| truncate_line(line, filter.line_max))
747            .collect();
748        cap_lines(
749            &truncated,
750            filter.max_lines,
751            filter.keep,
752            strip_removed_lines,
753        )
754    }
755
756    #[test]
757    fn parses_minimal_filter() {
758        let filter = parse(
759            r#"
760[filter]
761matches = ["make"]
762"#,
763        );
764        assert_eq!(filter.matches, vec!["make"]);
765        assert_eq!(filter.line_max, usize::MAX);
766        assert_eq!(filter.max_lines, usize::MAX);
767        assert!(filter.strip.is_empty());
768        assert!(filter.shortcircuit_when.is_none());
769        assert!(filter.strip_ansi);
770    }
771
772    #[test]
773    fn filename_default_match() {
774        // Empty matches array → filter name is used as the program keyword.
775        let filter = parse_filter("ls", "", FilterSource::Builtin).expect("parse");
776        assert_eq!(filter.matches, vec!["ls"]);
777    }
778
779    #[test]
780    fn rejects_invalid_match_keyword() {
781        let err = parse_filter(
782            "bad",
783            r#"[filter]
784matches = ["has whitespace"]
785"#,
786            FilterSource::Builtin,
787        )
788        .unwrap_err();
789        assert!(err.contains("invalid match keyword"), "got: {err}");
790    }
791
792    #[test]
793    fn rejects_bad_strip_regex() {
794        let err = parse_filter(
795            "bad",
796            r#"
797[filter]
798matches = ["bad"]
799
800[strip]
801patterns = ["[unclosed"]
802"#,
803            FilterSource::Builtin,
804        )
805        .unwrap_err();
806        assert!(err.contains("strip pattern"), "got: {err}");
807    }
808
809    #[test]
810    fn strip_drops_matching_lines() {
811        let filter = parse(
812            r#"
813[filter]
814matches = ["x"]
815
816[strip]
817patterns = ['^Entering directory', '^Leaving directory']
818"#,
819        );
820        let input = "Entering directory `/tmp`\ngcc -c foo.c\nLeaving directory `/tmp`";
821        let out = apply_filter(&filter, input).text;
822        assert_eq!(out, "gcc -c foo.c");
823    }
824
825    #[test]
826    fn shortcircuit_replaces_empty_after_strip() {
827        let filter = parse(
828            r#"
829[filter]
830matches = ["x"]
831
832[strip]
833patterns = ['^make\[\d+\]:.*']
834
835[shortcircuit]
836when = '\A\z'
837replacement = "make: ok"
838"#,
839        );
840        let input = "make[1]: Entering directory `/tmp`\nmake[1]: Leaving directory `/tmp`";
841        let out = apply_filter(&filter, input).text;
842        assert_eq!(out, "make: ok");
843    }
844
845    #[test]
846    fn shortcircuit_line_anchors_do_not_match_inner_blank_lines() {
847        let filter = parse(
848            r#"
849[filter]
850matches = ["x"]
851
852[shortcircuit]
853when = '^\s*$'
854replacement = "ok"
855"#,
856        );
857        let out = apply_filter(&filter, "error\n\nhint").text;
858        assert_eq!(out, "error\n\nhint");
859    }
860
861    #[test]
862    fn cap_tail_keeps_last_n_lines() {
863        let filter = parse(
864            r#"
865[filter]
866matches = ["x"]
867
868[cap]
869max_lines = 3
870keep = "tail"
871"#,
872        );
873        let input = "1\n2\n3\n4\n5";
874        let out = apply_filter(&filter, input);
875        assert_eq!(out.text, "3\n4\n5");
876        assert!(out.had_inner_drop);
877        assert!(out.offset_hint_eligible);
878        assert_eq!(out.text.lines().count(), 3);
879    }
880
881    #[test]
882    fn cap_tail_after_strip_disables_offset_hint() {
883        let filter = parse(
884            r#"
885[filter]
886matches = ["x"]
887
888[strip]
889patterns = ["^strip-me"]
890
891[cap]
892max_lines = 2
893keep = "tail"
894"#,
895        );
896        let out = apply_filter(
897            &filter,
898            "strip-me
8991
9002
9013
9024",
903        );
904
905        assert_eq!(
906            out.text,
907            "3
9084"
909        );
910        assert!(out.had_inner_drop);
911        assert!(!out.offset_hint_eligible);
912        assert_eq!(out.offset_start_line, None);
913    }
914
915    #[test]
916    fn cap_head_keeps_first_n_lines() {
917        let filter = parse(
918            r#"
919[filter]
920matches = ["x"]
921
922[cap]
923max_lines = 2
924keep = "head"
925"#,
926        );
927        let input = "1\n2\n3\n4";
928        let out = apply_filter(&filter, input);
929        assert_eq!(out.text, "1\n2");
930        assert!(out.had_inner_drop);
931        assert!(!out.offset_hint_eligible);
932        assert_eq!(out.text.lines().count(), 2);
933    }
934
935    #[test]
936    fn cap_middle_keeps_head_and_tail() {
937        let filter = parse(
938            r#"
939[filter]
940matches = ["x"]
941
942[cap]
943max_lines = 4
944keep = "middle"
945"#,
946        );
947        let input = "1\n2\n3\n4\n5\n6\n7\n8";
948        let out = apply_filter(&filter, input);
949        assert_eq!(out.text, "1\n2\n7\n8");
950        assert!(out.had_inner_drop);
951        assert!(!out.offset_hint_eligible);
952        assert_eq!(out.text.lines().count(), 4);
953    }
954
955    #[test]
956    fn cap_zero_keeps_no_lines() {
957        let filter = parse(
958            r#"
959[filter]
960matches = ["x"]
961
962[cap]
963max_lines = 0
964keep = "head"
965"#,
966        );
967        let out = apply_filter(&filter, "1\n2\n3");
968        assert_eq!(out.text, "");
969        assert!(out.had_inner_drop);
970    }
971
972    #[test]
973    fn cap_one_keeps_one_tail_line_without_marker() {
974        let filter = parse(
975            r#"
976[filter]
977matches = ["x"]
978
979[cap]
980max_lines = 1
981keep = "tail"
982"#,
983        );
984        let out = apply_filter(&filter, "1\n2\n3");
985        assert_eq!(out.text, "3");
986        assert!(out.had_inner_drop);
987        assert!(out.offset_hint_eligible);
988        assert_eq!(out.text.lines().count(), 1);
989    }
990
991    #[test]
992    fn cap_two_keeps_two_tail_lines_without_marker() {
993        let filter = parse(
994            r#"
995[filter]
996matches = ["x"]
997
998[cap]
999max_lines = 2
1000keep = "tail"
1001"#,
1002        );
1003        let out = apply_filter(&filter, "1\n2\n3\n4");
1004        assert_eq!(out.text, "3\n4");
1005        assert!(out.had_inner_drop);
1006        assert!(out.offset_hint_eligible);
1007        assert_eq!(out.text.lines().count(), 2);
1008    }
1009
1010    #[test]
1011    fn class_cap_replaces_plain_cap_without_stacking() {
1012        let filter = parse(
1013            r#"
1014[filter]
1015matches = ["x"]
1016
1017[class_cap]
1018class = "warning"
1019max = 2
1020patterns = ["^warning"]
1021
1022[cap]
1023max_lines = 1
1024keep = "head"
1025"#,
1026        );
1027        let out = apply_filter(&filter, "warning 1\nkeep me\nwarning 2\nwarning 3");
1028
1029        assert!(out.text.contains("warning 1"));
1030        assert!(out.text.contains("keep me"));
1031        assert!(out.text.contains("warning 2"));
1032        assert!(!out.text.contains("warning 3"));
1033        assert_eq!(out.dropped_by_class.get(&DropClass::Warning), Some(&1));
1034        assert!(out.text.lines().count() > 1, "plain [cap] must not stack");
1035    }
1036
1037    #[test]
1038    fn streaming_plain_caps_match_materialized_reference() {
1039        let input = "keep first\ndrop chatter\nkeep a very long line\nkeep middle\n\
1040                     drop more chatter\nkeep penultimate\nkeep last\n";
1041        for keep in ["head", "tail", "middle"] {
1042            for max_lines in 0..=6 {
1043                let filter = parse(&format!(
1044                    r#"
1045[filter]
1046matches = ["x"]
1047
1048[strip]
1049patterns = ["^drop"]
1050
1051[truncate]
1052line_max = 10
1053
1054[cap]
1055max_lines = {max_lines}
1056keep = "{keep}"
1057"#
1058                ));
1059
1060                assert_eq!(
1061                    apply_filter_to_text(&filter, input, None),
1062                    materialized_plain_cap_reference(&filter, input),
1063                    "keep={keep}, max_lines={max_lines}"
1064                );
1065            }
1066        }
1067    }
1068
1069    #[test]
1070    fn finite_plain_cap_only_truncates_retained_lines() {
1071        let filter = parse(
1072            r#"
1073[filter]
1074matches = ["x"]
1075
1076[cap]
1077max_lines = 3
1078keep = "tail"
1079"#,
1080        );
1081        let input = (0..100)
1082            .map(|index| format!("line {index}"))
1083            .collect::<Vec<_>>()
1084            .join("\n");
1085        reset_truncate_line_calls();
1086
1087        let output = apply_filter(&filter, &input);
1088
1089        assert_eq!(output.text, "line 97\nline 98\nline 99");
1090        assert_eq!(truncate_line_calls(), 3);
1091    }
1092
1093    #[test]
1094    fn truncate_per_line() {
1095        let filter = parse(
1096            r#"
1097[filter]
1098matches = ["x"]
1099
1100[truncate]
1101line_max = 10
1102"#,
1103        );
1104        let input = "shortline\nthis is a very long line indeed";
1105        let out = apply_filter(&filter, input).text;
1106        assert!(out.contains("shortline"));
1107        assert!(out.contains("…"));
1108        assert!(out.lines().any(|l| l.chars().count() <= 10));
1109    }
1110
1111    #[test]
1112    fn ansi_strip_default_true() {
1113        let filter = parse(
1114            r#"
1115[filter]
1116matches = ["x"]
1117"#,
1118        );
1119        let input = "\x1b[31mred\x1b[0m text";
1120        let out = apply_filter(&filter, input).text;
1121        assert_eq!(out, "red text");
1122    }
1123
1124    #[test]
1125    fn ansi_strip_can_be_disabled() {
1126        let filter = parse(
1127            r#"
1128[filter]
1129matches = ["x"]
1130
1131[ansi]
1132strip = false
1133"#,
1134        );
1135        let input = "\x1b[31mred\x1b[0m text";
1136        let out = apply_filter(&filter, input).text;
1137        assert_eq!(out, input);
1138    }
1139
1140    #[test]
1141    fn shortcircuit_runs_on_after_strip_body() {
1142        // After stripping all lines we have empty string; shortcircuit `^$` matches.
1143        let filter = parse(
1144            r#"
1145[filter]
1146matches = ["x"]
1147
1148[strip]
1149patterns = ['^.*$']
1150
1151[shortcircuit]
1152when = '^$'
1153replacement = "ok"
1154"#,
1155        );
1156        assert_eq!(apply_filter(&filter, "anything\nat all").text, "ok");
1157    }
1158
1159    #[test]
1160    fn program_name_handles_env_and_paths() {
1161        assert_eq!(program_name("make build"), Some("make"));
1162        assert_eq!(program_name("FOO=1 BAR=2 make build"), Some("make"));
1163        assert_eq!(program_name("/usr/bin/cargo build"), Some("cargo"));
1164        assert_eq!(program_name("./node_modules/.bin/eslint ."), Some("eslint"));
1165        // Path is the program; subsequent tokens are arguments.
1166        assert_eq!(program_name("FOO=bar /opt/x/y subcmd"), Some("y"));
1167        assert_eq!(program_name(""), None);
1168        assert_eq!(program_name("   "), None);
1169    }
1170
1171    #[test]
1172    fn program_name_unquoted_windows_path() {
1173        // Unquoted Windows paths with spaces won't round-trip cleanly because
1174        // split_whitespace breaks on the embedded space. This is acceptable —
1175        // bash would fail to execute these without quoting too, and AFT's
1176        // shell handlers run the literal command. Document the behavior.
1177        // basename strips through the last backslash even on the broken-by-whitespace
1178        // first token, leaving "Program".
1179        assert_eq!(
1180            program_name(r"C:\Program Files\Git\bin\git.exe status"),
1181            Some("Program")
1182        );
1183    }
1184
1185    #[test]
1186    fn program_name_does_not_skip_non_assignment_token_with_equals() {
1187        // `=value` (no key) is not an env assignment.
1188        assert_eq!(program_name("=oops echo hi"), Some("=oops"));
1189    }
1190
1191    #[test]
1192    fn registry_lookup_by_program_name() {
1193        let registry = build_registry(
1194            &[(
1195                "make",
1196                r#"[filter]
1197matches = ["make"]
1198
1199[strip]
1200patterns = ['^Entering']
1201"#,
1202            )],
1203            None,
1204            None,
1205        );
1206        let f = registry.lookup("make build foo").unwrap();
1207        assert_eq!(f.matches, vec!["make"]);
1208        assert!(matches!(f.source, FilterSource::Builtin));
1209    }
1210
1211    #[test]
1212    fn registry_user_overrides_builtin() {
1213        let tmp = tempfile::tempdir().unwrap();
1214        let user_path = tmp.path().join("make.toml");
1215        fs::write(
1216            &user_path,
1217            r#"[filter]
1218matches = ["make"]
1219description = "user override"
1220"#,
1221        )
1222        .unwrap();
1223
1224        let registry = build_registry(
1225            &[(
1226                "make",
1227                r#"[filter]
1228matches = ["make"]
1229description = "builtin"
1230"#,
1231            )],
1232            Some(tmp.path()),
1233            None,
1234        );
1235        let f = registry.lookup("make build").unwrap();
1236        assert_eq!(f.description.as_deref(), Some("user override"));
1237        assert!(matches!(f.source, FilterSource::User { .. }));
1238    }
1239
1240    #[test]
1241    fn registry_project_overrides_user() {
1242        let user_dir = tempfile::tempdir().unwrap();
1243        let project_dir = tempfile::tempdir().unwrap();
1244        fs::write(
1245            user_dir.path().join("make.toml"),
1246            r#"[filter]
1247matches = ["make"]
1248description = "user"
1249"#,
1250        )
1251        .unwrap();
1252        fs::write(
1253            project_dir.path().join("make.toml"),
1254            r#"[filter]
1255matches = ["make"]
1256description = "project"
1257"#,
1258        )
1259        .unwrap();
1260
1261        let registry = build_registry(&[], Some(user_dir.path()), Some(project_dir.path()));
1262        let f = registry.lookup("make").unwrap();
1263        assert_eq!(f.description.as_deref(), Some("project"));
1264        assert!(matches!(f.source, FilterSource::Project { .. }));
1265    }
1266
1267    #[test]
1268    fn bad_filter_files_warn_not_panic() {
1269        let tmp = tempfile::tempdir().unwrap();
1270        fs::write(
1271            tmp.path().join("good.toml"),
1272            r#"[filter]
1273matches = ["good"]
1274"#,
1275        )
1276        .unwrap();
1277        fs::write(tmp.path().join("bad.toml"), "not valid = toml = at all =").unwrap();
1278
1279        let registry = build_registry(&[], Some(tmp.path()), None);
1280        assert!(registry.lookup("good").is_some());
1281        assert!(registry.lookup("bad").is_none());
1282        assert!(
1283            registry.warnings().iter().any(|w| w.contains("bad.toml")),
1284            "warnings: {:?}",
1285            registry.warnings()
1286        );
1287    }
1288
1289    #[test]
1290    fn missing_dir_does_not_warn() {
1291        let registry = build_registry(&[], Some(Path::new("/nonexistent/path/12345")), None);
1292        assert!(registry.warnings().is_empty());
1293    }
1294}