File Coverage

lib/MCP/Run/Compress.pm
Criterion Covered Total %
statement 160 209 76.5
branch 47 80 58.7
condition 36 59 61.0
subroutine 12 23 52.1
pod 1 3 33.3
total 256 374 68.4


line stmt bran cond sub pod time code
1             package MCP::Run::Compress;
2             our $VERSION = '0.004';
3 1     1   82933 use Mojo::Base -base;
  1         8480  
  1         6  
4              
5             # ABSTRACT: Output compression for LLMs
6              
7              
8 1     1   730 use Text::Trim qw(trim);
  1         530  
  1         53  
9 1     1   7 use List::Util qw(max min);
  1         1  
  1         49  
10 1     1   664 use Getopt::Long qw(GetOptionsFromArray);
  1         12503  
  1         5  
11              
12             has filters => sub { +{} };
13              
14              
15             sub _parse_command {
16 9     9   11 my ($self, $command) = @_;
17 9         32 my @words = split /\s+/, $command;
18 9 50       15 return { program => '', subcommand => undef, flags => {}, args => [] } unless @words;
19              
20 9         24 my $program = shift @words;
21 9         9 my ($subcommand, @remaining);
22              
23             # Find the subcommand (first word not starting with -)
24 9         19 for my $i (0 .. $#words) {
25 7 100       26 if ($words[$i] !~ /^-/) {
26 3         4 $subcommand = $words[$i];
27 3         8 @remaining = @words[$i+1 .. $#words];
28 3         5 last;
29             }
30             }
31 9 100       17 @remaining = @words unless defined $subcommand;
32              
33             # Parse flags with Getopt::Long
34 9         9 my %flags;
35              
36             # Build a Getopt::Long spec for common flag types
37             my @spec = (
38 0     0   0 'stat' => sub { $flags{stat} = 1 },
39 0     0   0 'numstat' => sub { $flags{numstat} = 1 },
40 0     0   0 'shortstat' => sub { $flags{shortstat} = 1 },
41             'w=i' => \$flags{w},
42             'width=i' => \$flags{width},
43             'stat-width=i' => \$flags{'stat-width'},
44             'stat-name-width=i' => \$flags{'stat-name-width'},
45             'M=s' => \$flags{M},
46 0     0   0 'ignore-space-change' => sub { $flags{'ignore-space-change'} = 1 },
47 0     0   0 'ignore-all-space' => sub { $flags{'ignore-all-space'} = 1 },
48 0     0   0 'ignore-blank-lines' => sub { $flags{'ignore-blank-lines'} = 1 },
49             'U=i' => \$flags{U},
50             'unified=i' => \$flags{unified},
51 0     0   0 'color' => sub { $flags{color} = 1 },
52 0     0   0 'no-color' => sub { $flags{color} = 0 },
53 0     0   0 'cached' => sub { $flags{cached} = 1 },
54 0     0   0 'no-pager' => sub { $flags{'no-pager'} = 1 },
55 9         130 );
56              
57             # Use GetOptionsFromArray to parse - suppress warnings for unknown flags
58 9         13 my $warn_handler = $SIG{__WARN__};
59 9     3   35 local $SIG{__WARN__} = sub { }; # Suppress warnings during parsing
60 9         33 GetOptionsFromArray(\@remaining, @spec);
61              
62             return {
63 9         4494 program => $program,
64             subcommand => $subcommand,
65             flags => \%flags,
66             args => \@remaining,
67             };
68             }
69              
70             sub register_filter {
71 423     423 0 331 my $self = shift;
72 423         630 my %args = @_;
73              
74 423         353 my $command = delete $args{command};
75 423         322 my $parsed_command = delete $args{parsed_command};
76              
77             # Determine storage key: parsed commands use a special key format
78 423         314 my $key;
79 423 100       379 if ($parsed_command) {
80 333         215 my @flag_parts;
81 333   100     225 for my $flag (sort keys %{$parsed_command->{flags} // {}}) {
  333         699  
82 9   50     30 push @flag_parts, "$flag=" . ($parsed_command->{flags}{$flag} // 1);
83             }
84 333   50     657 $key = 'parsed:' . ($parsed_command->{program} // '') . ':' . ($parsed_command->{subcommand} // '') . ':' . join(',', @flag_parts);
      100        
85             } else {
86 90         67 $key = $command;
87             }
88              
89             $self->filters->{$key} = {
90             command => $command,
91             parsed_command => $parsed_command,
92             strip_ansi => $args{strip_ansi} // 0,
93             strip_lines_matching => $args{strip_lines_matching} // [],
94             keep_lines_matching => $args{keep_lines_matching} // [],
95             truncate_lines_at => $args{truncate_lines_at} // 0,
96             max_lines => $args{max_lines} // 0,
97             tail_lines => $args{tail_lines} // 0,
98             head_lines => $args{head_lines} // 0,
99             on_empty => $args{on_empty} // '',
100             replace => $args{replace} // [],
101             match_output => $args{match_output} // [],
102             filter_stderr => $args{filter_stderr} // 0,
103             output_detect => $args{output_detect} // undef,
104             transform => $args{transform} // undef,
105 423   50     4828 };
      100        
      50        
      100        
      50        
      100        
      100        
      100        
      50        
      100        
      100        
      100        
      100        
106              
107 423         1326 return;
108             }
109              
110             sub _match_parsed_command {
111 186     186   177 my ($self, $filter_spec, $parsed) = @_;
112 186 50 33     283 return 0 unless $parsed && $filter_spec;
113              
114             # Must match program
115 186 100 50     386 return 0 if ($filter_spec->{program} // '') ne ($parsed->{program} // '');
      50        
116              
117             # Subcommand must match if specified
118 2 50       4 if (defined $filter_spec->{subcommand}) {
119 2 50 50     9 return 0 if ($filter_spec->{subcommand} // '') ne ($parsed->{subcommand} // '');
      50        
120             }
121              
122             # All specified flags must be present and truthy
123 0   0     0 for my $flag (keys %{$filter_spec->{flags} // {}}) {
  0         0  
124 0 0       0 return 0 unless $parsed->{flags}{$flag};
125             }
126              
127 0         0 return 1;
128             }
129              
130             sub _build_default_filters {
131 9     9   9 my $self = shift;
132              
133             # ls: ultra-compact for long listing - only keep type + filename
134             $self->register_filter(
135             command => '^ls\b',
136             output_detect => qr(^[d-][rwx-]{9}.*\s+\d+\s+),
137             strip_lines_matching => [
138             qr(^\s*$),
139             qr(^total\s+\d+),
140             qr(^\s*Device:),
141             qr(^\s*Inode:),
142             qr(^\s*Birth:),
143             qr(^/node_modules/),
144             qr(^/\.git/),
145             qr(^/\.target/),
146             qr(^/\.next/),
147             qr(^/\.nuxt/),
148             qr(^/\.cache/),
149             qr(^/__pycache__/),
150             qr(^/\.DS_Store/),
151             qr(^/vendor/bundle/),
152             ],
153             transform => sub {
154 9     9   6 my ($line) = @_;
155 9 100       21 if ($line =~ m{^([d-])[rwx-]{9}\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+[\d:]+\s+(.+)$}) {
156 6         13 return $1 . " " . $2;
157             }
158 3         12 return $line;
159             },
160 9         136 truncate_lines_at => 100,
161             max_lines => 50,
162             );
163              
164             # stat: strip device/inode/birth (Linux)
165 9         39 $self->register_filter(
166             command => '^stat\b',
167             strip_lines_matching => [
168             qr(^\s*$),
169             qr(^\s*Device:),
170             qr(^\s*Inode:),
171             qr(^\s*Birth:),
172             ],
173             truncate_lines_at => 120,
174             max_lines => 20,
175             );
176              
177             # grep: truncate lines, group by file
178 9         16 $self->register_filter(
179             command => '^grep\b',
180             truncate_lines_at => 150,
181             max_lines => 100,
182             );
183              
184             # make: strip entering/leaving directory
185 9         63 $self->register_filter(
186             command => '^make\b',
187             strip_lines_matching => [
188             qr(^\s*$),
189             qr(^make\[\d+\]:),
190             qr(^Entering directory),
191             qr(^Leaving directory),
192             qr(Nothing to be done),
193             qr(^make:\s+Nothing to be done),
194             ],
195             match_output => [
196             { pattern => qr(^make\[\d+\]:.*?make\[\d+\]:), message => 'make: circular dependency detected' },
197             { pattern => qr(^gcc.*?Error), message => 'make: compilation error' },
198             ],
199             max_lines => 50,
200             on_empty => 'make: ok',
201             );
202              
203             # git status: compact output
204 9         42 $self->register_filter(
205             parsed_command => {
206             program => 'git',
207             subcommand => 'status',
208             },
209             strip_lines_matching => [
210             qr(^\s*$),
211             qr(^On branch),
212             qr(^Your branch is),
213             qr(^Initial commit),
214             ],
215             max_lines => 30,
216             );
217              
218             # git diff: keep only diff content
219 9         50 $self->register_filter(
220             command => '^git\s+diff\b',
221             strip_lines_matching => [
222             qr(^\s*$),
223             qr(^diff --git),
224             qr(^index ),
225             qr(^---\s+a/),
226             qr(^\+\+\+\s+b/),
227             ],
228             truncate_lines_at => 150,
229             max_lines => 200,
230             );
231              
232             # git diff --stat: compact format "N+M- filename"
233             $self->register_filter(
234             parsed_command => {
235             program => 'git',
236             subcommand => 'diff',
237             flags => { stat => 1 },
238             },
239             transform => sub {
240 0     0   0 my ($line) = @_;
241             # Format: " 1 file changed, 3 insertions(+), 2 deletions(-)" or
242             # " file1.txt | 5 +++ ---"
243             # Transform to "N+M- filename" for file lines
244 0 0       0 if ($line =~ /^\s*(\S+)\s*\|\s*(\d+)\s*\+(\d+)\s*-\s*(\d+)/) {
245             # "| 5 +++ ---" format -> "3+2- filename"
246 0         0 return "$2+$3-$1";
247             }
248 0 0       0 if ($line =~ /^\s*(\S+)\s*\|\s*(\d+)\s+\+(\d+),?\s*(\d+)?\s*-/) {
249             # "| 5 +3 -2" format
250 0   0     0 my ($file, $changes, $add, $del) = ($1, $2, $3, $4 // 0);
251 0         0 return "$add+$del-$file";
252             }
253             # Remove summary line "X files changed"
254 0 0       0 if ($line =~ /^\s*\d+\s+files?\s+changed/) {
255 0         0 return undef; # Skip this line
256             }
257 0         0 return $line;
258             },
259 9         62 strip_lines_matching => [
260             qr(^\s*$),
261             qr(^\s*\d+\s+files?\s+changed),
262             qr(^\s*\d+\s+insertions?\(\+\)),
263             qr(^\s*\d+\s+deletions?\(\-\)),
264             ],
265             max_lines => 100,
266             );
267              
268             # cat: detect and filter code
269 9         22 $self->register_filter(
270             command => '^cat\b',
271             strip_lines_matching => [qr(^\s*$)],
272             truncate_lines_at => 500,
273             max_lines => 100,
274             );
275              
276             # find: strip permission denied, limit results
277 9         24 $self->register_filter(
278             command => '^find\b',
279             strip_lines_matching => [
280             qr(^\s*$),
281             qr(^find:.*permission denied),
282             ],
283             max_lines => 50,
284             );
285              
286             # ps: compact output
287 9         24 $self->register_filter(
288             command => '^ps\b',
289             strip_lines_matching => [
290             qr(^\s*$),
291             ],
292             truncate_lines_at => 120,
293             max_lines => 30,
294             );
295              
296             # df: truncate wide columns
297 9         22 $self->register_filter(
298             command => '^df\b',
299             strip_lines_matching => [qr(^\s*$)],
300             truncate_lines_at => 80,
301             max_lines => 20,
302             );
303              
304             # docker ps: compact output
305 9         28 $self->register_filter(
306             parsed_command => {
307             program => 'docker',
308             subcommand => 'ps',
309             },
310             strip_lines_matching => [qr(^\s*$)],
311             truncate_lines_at => 120,
312             max_lines => 30,
313             );
314              
315             # docker images: compact output
316 9         26 $self->register_filter(
317             parsed_command => {
318             program => 'docker',
319             subcommand => 'images',
320             },
321             strip_lines_matching => [qr(^\s*$)],
322             truncate_lines_at => 120,
323             max_lines => 30,
324             );
325              
326             # terraform plan: strip refresh progress
327 9         43 $self->register_filter(
328             parsed_command => {
329             program => 'terraform',
330             subcommand => 'plan',
331             },
332             strip_lines_matching => [
333             qr(^\s*$),
334             qr(^Refreshing state\.\.\.),
335             qr(^Terraform used the),
336             qr(^tfe-outputs:),
337             ],
338             max_lines => 100,
339             );
340              
341             # terraform apply: strip progress
342 9         43 $self->register_filter(
343             parsed_command => {
344             program => 'terraform',
345             subcommand => 'apply',
346             },
347             strip_lines_matching => [
348             qr(^\s*$),
349             qr(^Refreshing state\.\.\.),
350             qr(^Terraform will perform),
351             qr(^Proceeding with the following),
352             qr(^tfe-outputs:),
353             ],
354             max_lines => 100,
355             );
356              
357             # docker build: strip build progress
358 9         38 $self->register_filter(
359             parsed_command => {
360             program => 'docker',
361             subcommand => 'build',
362             },
363             strip_lines_matching => [
364             qr(^\s*$),
365             qr(^#\s*\d+\s+\[\s*\d+\s+/\s*\d+\]),
366             qr(^Step \d+/\d+:),
367             ],
368             max_lines => 50,
369             );
370              
371             # docker run: compact output
372 9         50 $self->register_filter(
373             parsed_command => {
374             program => 'docker',
375             subcommand => 'run',
376             },
377             strip_lines_matching => [
378             qr(^\s*$),
379             qr(^Unable to find image),
380             qr(^Pulling from library/),
381             qr(^Digest:),
382             qr(^Status:),
383             ],
384             max_lines => 30,
385             );
386              
387             # kubectl get: compact output
388 9         26 $self->register_filter(
389             parsed_command => {
390             program => 'kubectl',
391             subcommand => 'get',
392             },
393             strip_lines_matching => [qr(^\s*$)],
394             truncate_lines_at => 150,
395             max_lines => 50,
396             );
397              
398             # kubectl describe: strip noise
399 9         132 $self->register_filter(
400             parsed_command => {
401             program => 'kubectl',
402             subcommand => 'describe',
403             },
404             strip_lines_matching => [
405             qr(^\s*$),
406             qr(^Name:\s+\w+),
407             qr(^Namespace:\s+\w+),
408             qr(^Labels:\s*$),
409             qr(^Annotations:\s*$/),
410             ],
411             truncate_lines_at => 200,
412             max_lines => 100,
413             );
414              
415             # cargo build: strip compile progress
416 9         44 $self->register_filter(
417             parsed_command => {
418             program => 'cargo',
419             subcommand => 'build',
420             },
421             strip_lines_matching => [
422             qr(^\s*$),
423             qr(^Compiling\s+\w+),
424             qr(^Fresh\s+\w+),
425             qr(^Finished\s+),
426             ],
427             max_lines => 50,
428             );
429              
430             # cargo test: compact output
431 9         38 $self->register_filter(
432             parsed_command => {
433             program => 'cargo',
434             subcommand => 'test',
435             },
436             strip_lines_matching => [
437             qr(^\s*$),
438             qr(^Compiling\s+\w+),
439             qr(^Running\s+/),
440             qr(^test result:),
441             ],
442             max_lines => 100,
443             );
444              
445             # cpanm: strip cpanm noise
446 9         64 $self->register_filter(
447             command => '^cpanm\b',
448             strip_lines_matching => [
449             qr(^\s*$),
450             qr(^--> ),
451             qr(^OK$),
452             qr(^FAIL$),
453             qr(^Working on),
454             qr(^Fetching),
455             qr(^Configuring),
456             qr(^Building and testing),
457             ],
458             match_output => [
459             { pattern => qr{^\s*Successfully installed}, message => 'cpanm: ok' },
460             ],
461             max_lines => 30,
462             );
463              
464             # npm install: strip noise
465 9         39 $self->register_filter(
466             parsed_command => {
467             program => 'npm',
468             subcommand => 'install',
469             },
470             strip_lines_matching => [
471             qr(^\s*$),
472             qr(^added\s+\d+\s+packages?),
473             qr(^found\s+\d+\s+packages?),
474             qr(^npm warn),
475             ],
476             max_lines => 30,
477             );
478              
479             # yarn install: similar to npm
480 9         38 $self->register_filter(
481             parsed_command => {
482             program => 'yarn',
483             subcommand => 'install',
484             },
485             strip_lines_matching => [
486             qr(^\s*$),
487             qr(^Done in\s+),
488             qr(^Resolving completed),
489             qr(^Linking completed),
490             ],
491             max_lines => 30,
492             );
493              
494             # yarn add: similar to npm
495 9         39 $self->register_filter(
496             parsed_command => {
497             program => 'yarn',
498             subcommand => 'add',
499             },
500             strip_lines_matching => [
501             qr(^\s*$),
502             qr(^Done in\s+),
503             qr(^Resolving completed),
504             qr(^Linking completed),
505             ],
506             max_lines => 30,
507             );
508              
509             # pnpm install: similar to npm
510 9         55 $self->register_filter(
511             parsed_command => {
512             program => 'pnpm',
513             subcommand => 'install',
514             },
515             strip_lines_matching => [
516             qr(^\s*$),
517             qr(^Done in\s+),
518             qr(^Resolving completed),
519             qr(^Linking completed),
520             ],
521             max_lines => 30,
522             );
523              
524             # pnpm add: similar to npm
525 9         39 $self->register_filter(
526             parsed_command => {
527             program => 'pnpm',
528             subcommand => 'add',
529             },
530             strip_lines_matching => [
531             qr(^\s*$),
532             qr(^Done in\s+),
533             qr(^Resolving completed),
534             qr(^Linking completed),
535             ],
536             max_lines => 30,
537             );
538              
539             # pip install: strip progress
540 9         46 $self->register_filter(
541             parsed_command => {
542             program => 'pip',
543             subcommand => 'install',
544             },
545             strip_lines_matching => [
546             qr(^\s*$),
547             qr(^Collecting\s+),
548             qr(^Downloading\s+),
549             qr(^Installing collected packages:),
550             qr(^Successfully installed),
551             ],
552             max_lines => 50,
553             );
554              
555             # pytest: compact output
556 9         66 $self->register_filter(
557             parsed_command => {
558             program => 'pytest',
559             },
560             strip_lines_matching => [
561             qr(^\s*$),
562             qr(^=+.*=+$/),
563             qr(^Coverage report:),
564             qr(^HTML report:),
565             ],
566             truncate_lines_at => 150,
567             max_lines => 100,
568             );
569              
570             # curl: strip headers
571 9         45 $self->register_filter(
572             parsed_command => {
573             program => 'curl',
574             },
575             filter_stderr => 1,
576             strip_lines_matching => [
577             qr(^\s*$),
578             qr(^ % Total),
579             qr(^ Resolving),
580             qr(^Connected to),
581             qr(^HTTP/\d[\d.]*\s+\d+),
582             ],
583             truncate_lines_at => 200,
584             max_lines => 50,
585             );
586              
587             # wget: strip progress
588 9         53 $self->register_filter(
589             parsed_command => {
590             program => 'wget',
591             },
592             strip_lines_matching => [
593             qr(^\s*$),
594             qr(^--\d{4}-\d{2}-\d{2}),
595             qr(^Resolving),
596             qr(^Connecting to),
597             qr(^Length:\s+\d+),
598             qr(^Saving to:),
599             qr(^\d+%\s+\[),
600             ],
601             truncate_lines_at => 200,
602             max_lines => 50,
603             );
604              
605             # helm install: strip progress
606 9         52 $self->register_filter(
607             parsed_command => {
608             program => 'helm',
609             subcommand => 'install',
610             },
611             strip_lines_matching => [
612             qr(^\s*$),
613             qr(^NAME:\s+\w+),
614             qr(^NAMESPACE:\s+\w+),
615             qr(^STATUS:\s+),
616             qr(^REVISION:\s+\d+),
617             qr(^NOTES:$),
618             ],
619             max_lines => 50,
620             );
621              
622             # helm upgrade: strip progress
623 9         57 $self->register_filter(
624             parsed_command => {
625             program => 'helm',
626             subcommand => 'upgrade',
627             },
628             strip_lines_matching => [
629             qr(^\s*$),
630             qr(^NAME:\s+\w+),
631             qr(^NAMESPACE:\s+\w+),
632             qr(^STATUS:\s+),
633             qr(^REVISION:\s+\d+),
634             qr(^NOTES:$),
635             ],
636             max_lines => 50,
637             );
638              
639             # ansible-playbook: strip progress
640 9         52 $self->register_filter(
641             parsed_command => {
642             program => 'ansible-playbook',
643             },
644             strip_lines_matching => [
645             qr(^\s*$),
646             qr(^PLAY\s+\[),
647             qr(^TASK\s+\[),
648             qr(^RUNNING HANDLER),
649             qr(^changed:\s+\[\d+\]),
650             qr(^ok:\s+\[\d+\]),
651             qr(^fatal:\s+\[\d+\]),
652             qr(^PLAY RECAP),
653             ],
654             max_lines => 100,
655             );
656              
657             # rsync: strip progress
658 9         39 $self->register_filter(
659             parsed_command => {
660             program => 'rsync',
661             },
662             strip_lines_matching => [
663             qr(^\s*$),
664             qr(^sent\s+\d+\s+bytes),
665             qr(^received\s+\d+\s+bytes),
666             qr(^total size is),
667             ],
668             max_lines => 30,
669             );
670              
671             # iptables -L: compact
672 9         26 $self->register_filter(
673             parsed_command => {
674             program => 'iptables',
675             },
676             strip_lines_matching => [qr(^\s*$)],
677             truncate_lines_at => 150,
678             max_lines => 50,
679             );
680              
681             # ping: strip progress
682 9         38 $self->register_filter(
683             parsed_command => {
684             program => 'ping',
685             },
686             strip_lines_matching => [
687             qr(^\s*$),
688             qr(^PING\s+),
689             qr(^64 bytes from),
690             qr(^---.*ping statistics---),
691             ],
692             max_lines => 20,
693             );
694              
695             # netstat: compact output for -tulpn
696 9         23 $self->register_filter(
697             parsed_command => {
698             program => 'netstat',
699             },
700             strip_lines_matching => [qr(^\s*$)],
701             truncate_lines_at => 150,
702             max_lines => 50,
703             );
704              
705             # ip addr: compact
706 9         24 $self->register_filter(
707             parsed_command => {
708             program => 'ip',
709             subcommand => 'addr',
710             },
711             strip_lines_matching => [qr(^\s*$)],
712             truncate_lines_at => 150,
713             max_lines => 50,
714             );
715              
716             # ip route: compact
717 9         23 $self->register_filter(
718             parsed_command => {
719             program => 'ip',
720             subcommand => 'route',
721             },
722             strip_lines_matching => [qr(^\s*$)],
723             max_lines => 30,
724             );
725              
726             # ip link: compact
727 9         30 $self->register_filter(
728             parsed_command => {
729             program => 'ip',
730             subcommand => 'link',
731             },
732             strip_lines_matching => [qr(^\s*$)],
733             max_lines => 30,
734             );
735              
736             # mount: strip noise
737 9         27 $self->register_filter(
738             parsed_command => {
739             program => 'mount',
740             },
741             strip_lines_matching => [qr(^\s*$)],
742             truncate_lines_at => 200,
743             max_lines => 50,
744             );
745              
746             # lsblk: compact block device listing
747 9         26 $self->register_filter(
748             parsed_command => {
749             program => 'lsblk',
750             },
751             strip_lines_matching => [qr(^\s*$)],
752             truncate_lines_at => 150,
753             max_lines => 50,
754             );
755              
756             # blkid: compact block device attributes
757 9         26 $self->register_filter(
758             parsed_command => {
759             program => 'blkid',
760             },
761             strip_lines_matching => [qr(^\s*$)],
762             truncate_lines_at => 200,
763             max_lines => 50,
764             );
765              
766             # git log: compact
767 9         44 $self->register_filter(
768             parsed_command => {
769             program => 'git',
770             subcommand => 'log',
771             },
772             strip_lines_matching => [
773             qr(^\s*$),
774             qr(^commit\s+[a-f0-9]+),
775             qr(^Author:\s+),
776             qr(^Date:\s+),
777             ],
778             head_lines => 20,
779             tail_lines => 10,
780             max_lines => 30,
781             );
782              
783             # git branch: compact
784 9         24 $self->register_filter(
785             parsed_command => {
786             program => 'git',
787             subcommand => 'branch',
788             },
789             strip_lines_matching => [qr(^\s*$)],
790             max_lines => 30,
791             );
792              
793             # git stash: compact
794 9         26 $self->register_filter(
795             parsed_command => {
796             program => 'git',
797             subcommand => 'stash',
798             },
799             strip_lines_matching => [qr(^\s*$)],
800             max_lines => 30,
801             );
802              
803 9         8 return;
804             }
805              
806             sub new {
807 9     9 1 247604 my $self = shift->SUPER::new(@_);
808 9         52 $self->_build_default_filters;
809 9         15 return $self;
810             }
811              
812             sub compress {
813 9     9 0 113 my ($self, $command, $stdout, $stderr) = @_;
814              
815 9         8 my $matched_filter;
816 9         15 my $parsed = $self->_parse_command($command);
817              
818 9         14 for my $key (keys %{$self->filters}) {
  9         18  
819 241         308 my $filter = $self->filters->{$key};
820              
821 241         504 my $matches = 0;
822              
823             # Check parsed_command first if present
824 241 100       611 if (my $pc = $filter->{parsed_command}) {
    100          
825 186 50       189 $matches = 1 if $self->_match_parsed_command($pc, $parsed);
826             }
827             # Fall back to regex matching for legacy filters
828             elsif ($command =~ /$key/) {
829 8         8 $matches = 1;
830             }
831              
832 241 100       313 next unless $matches;
833              
834             # Check output_detect if present - only apply filter if output matches
835 8 100       13 if (my $detect = $filter->{output_detect}) {
836 1         5 my @lines = split(/\n/, $stdout);
837 1         2 my $has_match = grep { /$detect/ } @lines;
  9         26  
838 1 50       2 unless ($has_match) {
839 0         0 next; # Skip this filter, try next one
840             }
841             }
842              
843 8         9 $matched_filter = $filter;
844 8         8 last; # Found matching filter
845             }
846              
847 9 100       27 if (!$matched_filter) {
848 1         7 return ($stdout, $stderr);
849             }
850              
851 8   50     14 my ($out, $err) = ($stdout, $stderr // '');
852              
853             # Filter stderr into stdout if configured
854 8 50       12 if ($matched_filter->{filter_stderr}) {
855 0 0       0 $out .= "\n$err" if length $err;
856 0         0 $err = '';
857             }
858              
859             # Stage 1: Strip ANSI
860 8 50       10 if ($matched_filter->{strip_ansi}) {
861 0         0 $out =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
862 0         0 $err =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
863             }
864              
865             # Stage 2: Match output short-circuit
866 8         7 for my $match (@{$matched_filter->{match_output}}) {
  8         16  
867 4 50       19 if ($out =~ /$match->{pattern}/) {
868 0         0 return ($match->{message}, $err);
869             }
870             }
871              
872             # Stage 3: Transform lines (if transform coderef provided)
873 8 100       12 if ($matched_filter->{transform}) {
874 1         4 $out = join("\n", map { $matched_filter->{transform}->($_) } split(/\n/, $out));
  9         10  
875             }
876              
877             # Stage 4: Strip lines matching
878 8 100       9 if (@{$matched_filter->{strip_lines_matching}}) {
  8         11  
879 7         24 my @out_lines = split(/\n/, $out);
880             @out_lines = grep {
881 7         11 my $keep = 1;
  100         96  
882 100         76 for my $pattern (@{$matched_filter->{strip_lines_matching}}) {
  100         83  
883 317 100       508 if (/$pattern/) {
884 15         11 $keep = 0;
885 15         31 last;
886             }
887             }
888 100         121 $keep;
889             } @out_lines;
890 7         32 $out = join("\n", @out_lines);
891             }
892              
893             # Stage 5: Keep lines matching (if any)
894 8 50       8 if (@{$matched_filter->{keep_lines_matching}}) {
  8         14  
895 0         0 my @out_lines = split(/\n/, $out);
896             @out_lines = grep {
897 0         0 my $keep = 0;
  0         0  
898 0         0 for my $pattern (@{$matched_filter->{keep_lines_matching}}) {
  0         0  
899 0 0       0 if (/$pattern/) {
900 0         0 $keep = 1;
901 0         0 last;
902             }
903             }
904 0         0 $keep;
905             } @out_lines;
906 0         0 $out = join("\n", @out_lines);
907             }
908              
909             # Stage 6: Truncate lines at N chars
910 8 100       13 if ($matched_filter->{truncate_lines_at} > 0) {
911 5         6 my $max = $matched_filter->{truncate_lines_at};
912 5 100       12 $out = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $out));
  28         40  
913 5 0       11 $err = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $err)) if length $err;
  0 50       0  
914             }
915              
916             # Stage 7: Head/Tail lines
917 8 50       15 if ($matched_filter->{head_lines} > 0) {
    50          
918 0         0 my @lines = split(/\n/, $out);
919 0         0 my $head = $matched_filter->{head_lines};
920 0   0     0 my $tail = $matched_filter->{tail_lines} // 0;
921 0         0 my $omit = @lines - $head - $tail;
922 0 0 0     0 if ($omit > 0 && $tail > 0) {
    0          
923 0         0 $out = (join("\n", @lines[0..$head-1])) . "\n... $omit lines omitted ...\n" . (join("\n", @lines[-$tail..-1]));
924             }
925             elsif ($omit > 0) {
926 0         0 $out = join("\n", @lines[0..min($head, @lines)-1]);
927             }
928             }
929             elsif ($matched_filter->{tail_lines} > 0) {
930 0         0 my @lines = split(/\n/, $out);
931 0         0 $out = join("\n", @lines[-min($matched_filter->{tail_lines}, @lines)..-1]);
932             }
933              
934             # Stage 8: Max lines
935 8 50       15 if ($matched_filter->{max_lines} > 0) {
936 8         15 my @out_lines = split(/\n/, $out);
937 8 100       17 if (@out_lines > $matched_filter->{max_lines}) {
938 1         12 $out = join("\n", @out_lines[0..$matched_filter->{max_lines}-1]) . "\n... " . (@out_lines - $matched_filter->{max_lines}) . " more lines ...";
939             }
940             }
941              
942             # Stage 9: On empty
943 8 50 66     19 if (!length trim($out) && $matched_filter->{on_empty}) {
944 1         18 $out = $matched_filter->{on_empty};
945             }
946              
947 8         179 return ($out, $err);
948             }
949              
950             1;
951              
952             __END__