1use 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
37const REGEX_SIZE_LIMIT: usize = 2 * 1024 * 1024;
40
41const MAX_PATTERNS_PER_FILTER: usize = 256;
44
45const DEFAULT_LINE_MAX: usize = usize::MAX;
49
50const DEFAULT_MAX_LINES: usize = usize::MAX;
52
53#[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#[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#[derive(Debug, Default, Clone)]
99pub struct FilterRegistry {
100 by_match: HashMap<String, TomlFilter>,
102 all: Vec<TomlFilter>,
105 warnings: Vec<String>,
107}
108
109impl FilterRegistry {
110 pub fn lookup(&self, command: &str) -> Option<&TomlFilter> {
113 let program = program_name(command)?;
114 self.by_match.get(program)
115 }
116
117 pub fn all(&self) -> &[TomlFilter] {
119 &self.all
120 }
121
122 pub fn warnings(&self) -> &[String] {
125 &self.warnings
126 }
127}
128
129pub 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 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 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 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 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 for keyword in &filter.matches {
223 registry.by_match.insert(keyword.clone(), filter.clone());
224 }
225 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
303pub 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 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
412pub 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 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 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 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 if let Some(class_cap) = &filter.class_cap {
495 return cap_class_lines(&truncated, class_cap);
496 }
497
498 cap_lines(
500 &truncated,
501 filter.max_lines,
502 filter.keep,
503 strip_removed_lines,
504 )
505}
506
507fn 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 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
687pub fn program_name(command: &str) -> Option<&str> {
697 for token in command.split_whitespace() {
698 if is_env_assignment(token) {
700 continue;
701 }
702 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 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 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 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 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 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 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}