1use std::collections::HashMap;
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 let original_line_count = output.lines().count();
458 let kept: Vec<&str> = output
459 .lines()
460 .filter(|line| !filter.strip.iter().any(|re| re.is_match(line)))
461 .collect();
462 let strip_removed_lines = kept.len() < original_line_count;
463 if let (Some(when), Some(replacement)) =
465 (&filter.shortcircuit_when, &filter.shortcircuit_replacement)
466 {
467 let after_strip = kept.join("\n");
468 let shortcircuit_safe = match exit_code {
469 Some(code) => code == 0,
470 None => !super::text_has_failure_signal(&after_strip),
471 };
472 if shortcircuit_safe && when.is_match(&after_strip) {
473 return CompressionResult::new(replacement.clone());
474 }
475 }
476
477 let truncated: Vec<String> = if filter.line_max == usize::MAX {
479 kept.iter().map(|s| (*s).to_string()).collect()
480 } else {
481 kept.iter()
482 .map(|line| truncate_line(line, filter.line_max))
483 .collect()
484 };
485
486 if let Some(class_cap) = &filter.class_cap {
488 return cap_class_lines(&truncated, class_cap);
489 }
490
491 cap_lines(
493 &truncated,
494 filter.max_lines,
495 filter.keep,
496 strip_removed_lines,
497 )
498}
499
500fn truncate_line(line: &str, line_max: usize) -> String {
501 if line.chars().count() <= line_max {
502 return line.to_string();
503 }
504 let keep_each_side = line_max.saturating_sub(3) / 2;
506 let head: String = line.chars().take(keep_each_side).collect();
507 let tail: String = line
508 .chars()
509 .rev()
510 .take(keep_each_side)
511 .collect::<Vec<_>>()
512 .into_iter()
513 .rev()
514 .collect();
515 format!("{head}…{tail}")
516}
517
518fn cap_class_lines(lines: &[String], class_cap: &TomlClassCap) -> CompressionResult {
519 let blocks = lines
520 .iter()
521 .map(|line| {
522 if class_cap.patterns.is_empty()
523 || class_cap
524 .patterns
525 .iter()
526 .any(|pattern| pattern.is_match(line))
527 {
528 ClassifiedBlock::new(class_cap.class, line.clone())
529 } else {
530 ClassifiedBlock::unclassified(line.clone())
531 }
532 })
533 .collect();
534 let capped = cap_classified_blocks_with(blocks, |class| {
535 if class == class_cap.class {
536 class_cap.max
537 } else {
538 class.default_cap()
539 }
540 });
541 CompressionResult::with_class_drops(capped.text, capped.dropped_by_class)
542}
543
544fn cap_lines(
545 lines: &[String],
546 max_lines: usize,
547 keep: KeepMode,
548 had_prior_line_drop: bool,
549) -> CompressionResult {
550 if lines.len() <= max_lines || max_lines == usize::MAX {
551 return CompressionResult::new(lines.join("\n"));
552 }
553
554 if max_lines == 0 {
555 return CompressionResult::with_inner_drop(String::new(), false);
556 }
557
558 let kept = match keep {
559 KeepMode::Head => lines.iter().take(max_lines).cloned().collect::<Vec<_>>(),
560 KeepMode::Tail => lines
561 .iter()
562 .skip(lines.len().saturating_sub(max_lines))
563 .cloned()
564 .collect::<Vec<_>>(),
565 KeepMode::Middle => {
566 let head_count = max_lines / 2;
567 let tail_count = max_lines - head_count;
568 let mut kept: Vec<String> = lines.iter().take(head_count).cloned().collect();
569 kept.extend(lines.iter().skip(lines.len() - tail_count).cloned());
570 kept
571 }
572 };
573 if matches!(keep, KeepMode::Tail) && !had_prior_line_drop {
574 let dropped_prefix_lines = lines.len().saturating_sub(max_lines);
575 CompressionResult::with_prefix_drop(kept.join("\n"), dropped_prefix_lines + 1)
576 } else {
577 CompressionResult::with_inner_drop(kept.join("\n"), false)
578 }
579}
580
581fn parse_drop_class(value: &str) -> Result<DropClass, String> {
582 match value {
583 "error" | "errors" => Ok(DropClass::Error),
584 "warning" | "warnings" => Ok(DropClass::Warning),
585 "failure" | "failures" => Ok(DropClass::Failure),
586 "issue" | "issues" => Ok(DropClass::Issue),
587 "list" | "list_item" | "list-items" | "list items" => Ok(DropClass::List),
588 "inventory" | "inventory_item" | "inventory-items" | "inventory items" => {
589 Ok(DropClass::Inventory)
590 }
591 "timing" | "timing_line" | "timing-lines" | "timing lines" => Ok(DropClass::Timing),
592 other => Err(format!("invalid class_cap.class {other:?}")),
593 }
594}
595
596pub fn program_name(command: &str) -> Option<&str> {
606 for token in command.split_whitespace() {
607 if is_env_assignment(token) {
609 continue;
610 }
611 return Some(basename(token));
613 }
614 None
615}
616
617fn is_env_assignment(token: &str) -> bool {
618 let Some(eq) = token.find('=') else {
619 return false;
620 };
621 let key = &token[..eq];
622 !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
623}
624
625fn basename(token: &str) -> &str {
626 let last_unix = token.rfind('/');
628 let last_win = token.rfind('\\');
629 let split_at = match (last_unix, last_win) {
630 (Some(u), Some(w)) => u.max(w),
631 (Some(u), None) => u,
632 (None, Some(w)) => w,
633 (None, None) => return token,
634 };
635 &token[split_at + 1..]
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 fn parse(content: &str) -> TomlFilter {
643 parse_filter("test", content, FilterSource::Builtin).expect("parse")
644 }
645
646 #[test]
647 fn parses_minimal_filter() {
648 let filter = parse(
649 r#"
650[filter]
651matches = ["make"]
652"#,
653 );
654 assert_eq!(filter.matches, vec!["make"]);
655 assert_eq!(filter.line_max, usize::MAX);
656 assert_eq!(filter.max_lines, usize::MAX);
657 assert!(filter.strip.is_empty());
658 assert!(filter.shortcircuit_when.is_none());
659 assert!(filter.strip_ansi);
660 }
661
662 #[test]
663 fn filename_default_match() {
664 let filter = parse_filter("ls", "", FilterSource::Builtin).expect("parse");
666 assert_eq!(filter.matches, vec!["ls"]);
667 }
668
669 #[test]
670 fn rejects_invalid_match_keyword() {
671 let err = parse_filter(
672 "bad",
673 r#"[filter]
674matches = ["has whitespace"]
675"#,
676 FilterSource::Builtin,
677 )
678 .unwrap_err();
679 assert!(err.contains("invalid match keyword"), "got: {err}");
680 }
681
682 #[test]
683 fn rejects_bad_strip_regex() {
684 let err = parse_filter(
685 "bad",
686 r#"
687[filter]
688matches = ["bad"]
689
690[strip]
691patterns = ["[unclosed"]
692"#,
693 FilterSource::Builtin,
694 )
695 .unwrap_err();
696 assert!(err.contains("strip pattern"), "got: {err}");
697 }
698
699 #[test]
700 fn strip_drops_matching_lines() {
701 let filter = parse(
702 r#"
703[filter]
704matches = ["x"]
705
706[strip]
707patterns = ['^Entering directory', '^Leaving directory']
708"#,
709 );
710 let input = "Entering directory `/tmp`\ngcc -c foo.c\nLeaving directory `/tmp`";
711 let out = apply_filter(&filter, input).text;
712 assert_eq!(out, "gcc -c foo.c");
713 }
714
715 #[test]
716 fn shortcircuit_replaces_empty_after_strip() {
717 let filter = parse(
718 r#"
719[filter]
720matches = ["x"]
721
722[strip]
723patterns = ['^make\[\d+\]:.*']
724
725[shortcircuit]
726when = '\A\z'
727replacement = "make: ok"
728"#,
729 );
730 let input = "make[1]: Entering directory `/tmp`\nmake[1]: Leaving directory `/tmp`";
731 let out = apply_filter(&filter, input).text;
732 assert_eq!(out, "make: ok");
733 }
734
735 #[test]
736 fn shortcircuit_line_anchors_do_not_match_inner_blank_lines() {
737 let filter = parse(
738 r#"
739[filter]
740matches = ["x"]
741
742[shortcircuit]
743when = '^\s*$'
744replacement = "ok"
745"#,
746 );
747 let out = apply_filter(&filter, "error\n\nhint").text;
748 assert_eq!(out, "error\n\nhint");
749 }
750
751 #[test]
752 fn cap_tail_keeps_last_n_lines() {
753 let filter = parse(
754 r#"
755[filter]
756matches = ["x"]
757
758[cap]
759max_lines = 3
760keep = "tail"
761"#,
762 );
763 let input = "1\n2\n3\n4\n5";
764 let out = apply_filter(&filter, input);
765 assert_eq!(out.text, "3\n4\n5");
766 assert!(out.had_inner_drop);
767 assert!(out.offset_hint_eligible);
768 assert_eq!(out.text.lines().count(), 3);
769 }
770
771 #[test]
772 fn cap_tail_after_strip_disables_offset_hint() {
773 let filter = parse(
774 r#"
775[filter]
776matches = ["x"]
777
778[strip]
779patterns = ["^strip-me"]
780
781[cap]
782max_lines = 2
783keep = "tail"
784"#,
785 );
786 let out = apply_filter(
787 &filter,
788 "strip-me
7891
7902
7913
7924",
793 );
794
795 assert_eq!(
796 out.text,
797 "3
7984"
799 );
800 assert!(out.had_inner_drop);
801 assert!(!out.offset_hint_eligible);
802 assert_eq!(out.offset_start_line, None);
803 }
804
805 #[test]
806 fn cap_head_keeps_first_n_lines() {
807 let filter = parse(
808 r#"
809[filter]
810matches = ["x"]
811
812[cap]
813max_lines = 2
814keep = "head"
815"#,
816 );
817 let input = "1\n2\n3\n4";
818 let out = apply_filter(&filter, input);
819 assert_eq!(out.text, "1\n2");
820 assert!(out.had_inner_drop);
821 assert!(!out.offset_hint_eligible);
822 assert_eq!(out.text.lines().count(), 2);
823 }
824
825 #[test]
826 fn cap_middle_keeps_head_and_tail() {
827 let filter = parse(
828 r#"
829[filter]
830matches = ["x"]
831
832[cap]
833max_lines = 4
834keep = "middle"
835"#,
836 );
837 let input = "1\n2\n3\n4\n5\n6\n7\n8";
838 let out = apply_filter(&filter, input);
839 assert_eq!(out.text, "1\n2\n7\n8");
840 assert!(out.had_inner_drop);
841 assert!(!out.offset_hint_eligible);
842 assert_eq!(out.text.lines().count(), 4);
843 }
844
845 #[test]
846 fn cap_zero_keeps_no_lines() {
847 let filter = parse(
848 r#"
849[filter]
850matches = ["x"]
851
852[cap]
853max_lines = 0
854keep = "head"
855"#,
856 );
857 let out = apply_filter(&filter, "1\n2\n3");
858 assert_eq!(out.text, "");
859 assert!(out.had_inner_drop);
860 }
861
862 #[test]
863 fn cap_one_keeps_one_tail_line_without_marker() {
864 let filter = parse(
865 r#"
866[filter]
867matches = ["x"]
868
869[cap]
870max_lines = 1
871keep = "tail"
872"#,
873 );
874 let out = apply_filter(&filter, "1\n2\n3");
875 assert_eq!(out.text, "3");
876 assert!(out.had_inner_drop);
877 assert!(out.offset_hint_eligible);
878 assert_eq!(out.text.lines().count(), 1);
879 }
880
881 #[test]
882 fn cap_two_keeps_two_tail_lines_without_marker() {
883 let filter = parse(
884 r#"
885[filter]
886matches = ["x"]
887
888[cap]
889max_lines = 2
890keep = "tail"
891"#,
892 );
893 let out = apply_filter(&filter, "1\n2\n3\n4");
894 assert_eq!(out.text, "3\n4");
895 assert!(out.had_inner_drop);
896 assert!(out.offset_hint_eligible);
897 assert_eq!(out.text.lines().count(), 2);
898 }
899
900 #[test]
901 fn class_cap_replaces_plain_cap_without_stacking() {
902 let filter = parse(
903 r#"
904[filter]
905matches = ["x"]
906
907[class_cap]
908class = "warning"
909max = 2
910patterns = ["^warning"]
911
912[cap]
913max_lines = 1
914keep = "head"
915"#,
916 );
917 let out = apply_filter(&filter, "warning 1\nkeep me\nwarning 2\nwarning 3");
918
919 assert!(out.text.contains("warning 1"));
920 assert!(out.text.contains("keep me"));
921 assert!(out.text.contains("warning 2"));
922 assert!(!out.text.contains("warning 3"));
923 assert_eq!(out.dropped_by_class.get(&DropClass::Warning), Some(&1));
924 assert!(out.text.lines().count() > 1, "plain [cap] must not stack");
925 }
926
927 #[test]
928 fn truncate_per_line() {
929 let filter = parse(
930 r#"
931[filter]
932matches = ["x"]
933
934[truncate]
935line_max = 10
936"#,
937 );
938 let input = "shortline\nthis is a very long line indeed";
939 let out = apply_filter(&filter, input).text;
940 assert!(out.contains("shortline"));
941 assert!(out.contains("…"));
942 assert!(out.lines().any(|l| l.chars().count() <= 10));
943 }
944
945 #[test]
946 fn ansi_strip_default_true() {
947 let filter = parse(
948 r#"
949[filter]
950matches = ["x"]
951"#,
952 );
953 let input = "\x1b[31mred\x1b[0m text";
954 let out = apply_filter(&filter, input).text;
955 assert_eq!(out, "red text");
956 }
957
958 #[test]
959 fn ansi_strip_can_be_disabled() {
960 let filter = parse(
961 r#"
962[filter]
963matches = ["x"]
964
965[ansi]
966strip = false
967"#,
968 );
969 let input = "\x1b[31mred\x1b[0m text";
970 let out = apply_filter(&filter, input).text;
971 assert_eq!(out, input);
972 }
973
974 #[test]
975 fn shortcircuit_runs_on_after_strip_body() {
976 let filter = parse(
978 r#"
979[filter]
980matches = ["x"]
981
982[strip]
983patterns = ['^.*$']
984
985[shortcircuit]
986when = '^$'
987replacement = "ok"
988"#,
989 );
990 assert_eq!(apply_filter(&filter, "anything\nat all").text, "ok");
991 }
992
993 #[test]
994 fn program_name_handles_env_and_paths() {
995 assert_eq!(program_name("make build"), Some("make"));
996 assert_eq!(program_name("FOO=1 BAR=2 make build"), Some("make"));
997 assert_eq!(program_name("/usr/bin/cargo build"), Some("cargo"));
998 assert_eq!(program_name("./node_modules/.bin/eslint ."), Some("eslint"));
999 assert_eq!(program_name("FOO=bar /opt/x/y subcmd"), Some("y"));
1001 assert_eq!(program_name(""), None);
1002 assert_eq!(program_name(" "), None);
1003 }
1004
1005 #[test]
1006 fn program_name_unquoted_windows_path() {
1007 assert_eq!(
1014 program_name(r"C:\Program Files\Git\bin\git.exe status"),
1015 Some("Program")
1016 );
1017 }
1018
1019 #[test]
1020 fn program_name_does_not_skip_non_assignment_token_with_equals() {
1021 assert_eq!(program_name("=oops echo hi"), Some("=oops"));
1023 }
1024
1025 #[test]
1026 fn registry_lookup_by_program_name() {
1027 let registry = build_registry(
1028 &[(
1029 "make",
1030 r#"[filter]
1031matches = ["make"]
1032
1033[strip]
1034patterns = ['^Entering']
1035"#,
1036 )],
1037 None,
1038 None,
1039 );
1040 let f = registry.lookup("make build foo").unwrap();
1041 assert_eq!(f.matches, vec!["make"]);
1042 assert!(matches!(f.source, FilterSource::Builtin));
1043 }
1044
1045 #[test]
1046 fn registry_user_overrides_builtin() {
1047 let tmp = tempfile::tempdir().unwrap();
1048 let user_path = tmp.path().join("make.toml");
1049 fs::write(
1050 &user_path,
1051 r#"[filter]
1052matches = ["make"]
1053description = "user override"
1054"#,
1055 )
1056 .unwrap();
1057
1058 let registry = build_registry(
1059 &[(
1060 "make",
1061 r#"[filter]
1062matches = ["make"]
1063description = "builtin"
1064"#,
1065 )],
1066 Some(tmp.path()),
1067 None,
1068 );
1069 let f = registry.lookup("make build").unwrap();
1070 assert_eq!(f.description.as_deref(), Some("user override"));
1071 assert!(matches!(f.source, FilterSource::User { .. }));
1072 }
1073
1074 #[test]
1075 fn registry_project_overrides_user() {
1076 let user_dir = tempfile::tempdir().unwrap();
1077 let project_dir = tempfile::tempdir().unwrap();
1078 fs::write(
1079 user_dir.path().join("make.toml"),
1080 r#"[filter]
1081matches = ["make"]
1082description = "user"
1083"#,
1084 )
1085 .unwrap();
1086 fs::write(
1087 project_dir.path().join("make.toml"),
1088 r#"[filter]
1089matches = ["make"]
1090description = "project"
1091"#,
1092 )
1093 .unwrap();
1094
1095 let registry = build_registry(&[], Some(user_dir.path()), Some(project_dir.path()));
1096 let f = registry.lookup("make").unwrap();
1097 assert_eq!(f.description.as_deref(), Some("project"));
1098 assert!(matches!(f.source, FilterSource::Project { .. }));
1099 }
1100
1101 #[test]
1102 fn bad_filter_files_warn_not_panic() {
1103 let tmp = tempfile::tempdir().unwrap();
1104 fs::write(
1105 tmp.path().join("good.toml"),
1106 r#"[filter]
1107matches = ["good"]
1108"#,
1109 )
1110 .unwrap();
1111 fs::write(tmp.path().join("bad.toml"), "not valid = toml = at all =").unwrap();
1112
1113 let registry = build_registry(&[], Some(tmp.path()), None);
1114 assert!(registry.lookup("good").is_some());
1115 assert!(registry.lookup("bad").is_none());
1116 assert!(
1117 registry.warnings().iter().any(|w| w.contains("bad.toml")),
1118 "warnings: {:?}",
1119 registry.warnings()
1120 );
1121 }
1122
1123 #[test]
1124 fn missing_dir_does_not_warn() {
1125 let registry = build_registry(&[], Some(Path::new("/nonexistent/path/12345")), None);
1126 assert!(registry.warnings().is_empty());
1127 }
1128}