1use crate::level::Level;
2use crate::tokens::estimate_tokens;
3use regex::Regex;
4use std::sync::LazyLock;
5
6#[derive(Debug, Clone)]
8pub struct Pipeline {
9 pub stages: Vec<PipelineStage>,
10}
11
12#[derive(Debug, Clone, Default)]
14pub struct ConditionalPipelines {
15 pub default: Option<Pipeline>,
17 pub on_error: Option<Pipeline>,
19 pub on_empty: Option<Pipeline>,
21 pub on_large: Option<Pipeline>,
23}
24
25impl ConditionalPipelines {
26 pub fn select(&self, exit_code: i32, output: &str) -> Option<&Pipeline> {
28 if exit_code != 0 {
29 if let Some(ref p) = self.on_error {
30 return Some(p);
31 }
32 }
33 if output.is_empty() {
34 if let Some(ref p) = self.on_empty {
35 return Some(p);
36 }
37 }
38 if estimate_tokens(output) > 1000 {
40 if let Some(ref p) = self.on_large {
41 return Some(p);
42 }
43 }
44 self.default.as_ref()
45 }
46
47 pub fn is_empty(&self) -> bool {
49 self.default.is_none()
50 && self.on_error.is_none()
51 && self.on_empty.is_none()
52 && self.on_large.is_none()
53 }
54}
55
56#[derive(Debug, Clone)]
59pub struct PipelineStage {
60 pub name: String,
61 pub stage_type: StageType,
62 pub param: Option<usize>,
64 pub pattern: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum StageType {
70 Builtin,
72 Plugin,
74}
75
76impl Pipeline {
77 pub fn single(filter_name: &str) -> Self {
79 Pipeline {
80 stages: vec![PipelineStage {
81 name: filter_name.to_string(),
82 stage_type: StageType::Plugin,
83 param: None,
84 pattern: None,
85 }],
86 }
87 }
88
89 pub fn from_parts(pre: &[String], filter_name: &str, post: &[String]) -> Self {
91 let mut stages: Vec<PipelineStage> = pre.iter().map(|s| parse_pipeline_stage(s)).collect();
92 stages.push(PipelineStage {
93 name: filter_name.to_string(),
94 stage_type: StageType::Plugin,
95 param: None,
96 pattern: None,
97 });
98 stages.extend(post.iter().map(|s| parse_pipeline_stage(s)));
99 Pipeline { stages }
100 }
101
102 pub fn parse(spec: &str) -> Self {
105 let stages = spec
106 .split('|')
107 .map(|s| s.trim())
108 .filter(|s| !s.is_empty())
109 .map(|raw| parse_pipeline_stage(raw))
110 .collect();
111 Pipeline { stages }
112 }
113
114 pub fn len(&self) -> usize {
115 self.stages.len()
116 }
117
118 pub fn is_empty(&self) -> bool {
119 self.stages.is_empty()
120 }
121
122 pub fn display(&self) -> String {
124 self.stages
125 .iter()
126 .map(|s| {
127 if let Some(ref pat) = s.pattern {
128 format!("{}:{}", s.name, pat)
129 } else if let Some(p) = s.param {
130 format!("{}:{}", s.name, p)
131 } else {
132 s.name.clone()
133 }
134 })
135 .collect::<Vec<_>>()
136 .join(" → ")
137 }
138}
139
140pub fn parse_conditional_pipeline(
147 lines: &[(String, String)],
148) -> ConditionalPipelines {
149 let mut cp = ConditionalPipelines::default();
150 for (key, spec) in lines {
151 match key.as_str() {
152 "" => cp.default = Some(Pipeline::parse(spec)),
153 "error" => cp.on_error = Some(Pipeline::parse(spec)),
154 "empty" => cp.on_empty = Some(Pipeline::parse(spec)),
155 "large" => cp.on_large = Some(Pipeline::parse(spec)),
156 _ => {} }
158 }
159 cp
160}
161
162fn parse_pipeline_stage(raw: &str) -> PipelineStage {
164 let spec = parse_stage_spec(raw);
165 PipelineStage {
166 stage_type: resolve_stage_type(&spec.name),
167 name: spec.name,
168 param: spec.param,
169 pattern: spec.pattern,
170 }
171}
172
173struct ParsedStage {
174 name: String,
175 param: Option<usize>,
176 pattern: Option<String>,
177}
178
179fn parse_stage_spec(spec: &str) -> ParsedStage {
182 match spec.split_once(':') {
183 Some((name, rest)) => {
184 let name = name.trim().to_string();
185 let rest = rest.trim();
186 if let Ok(n) = rest.parse::<usize>() {
188 ParsedStage { name, param: Some(n), pattern: None }
189 } else {
190 ParsedStage { name, param: None, pattern: Some(rest.to_string()) }
191 }
192 }
193 None => ParsedStage { name: spec.trim().to_string(), param: None, pattern: None },
194 }
195}
196
197fn resolve_stage_type(name: &str) -> StageType {
199 match name {
200 "strip-ansi" | "truncate" | "token-budget" | "dedup-blank" | "normalize" | "head"
201 | "passthrough" | "redact-secrets" | "grep" | "grep-v" | "cut" => {
202 StageType::Builtin
203 }
204 _ => StageType::Plugin,
205 }
206}
207
208pub fn proc_strip_ansi(text: &str) -> String {
212 let mut result = String::with_capacity(text.len());
213 let mut chars = text.chars().peekable();
214 while let Some(ch) = chars.next() {
215 if ch == '\x1b' {
216 if chars.peek() == Some(&'[') {
217 chars.next();
218 while let Some(&c) = chars.peek() {
219 chars.next();
220 if c.is_ascii_alphabetic() {
221 break;
222 }
223 }
224 continue;
225 }
226 }
227 result.push(ch);
228 }
229 result
230}
231
232pub fn proc_truncate(text: &str, max_lines: usize) -> String {
234 let lines: Vec<&str> = text.lines().collect();
235 if lines.len() <= max_lines {
236 return text.to_string();
237 }
238 let mut result: String = lines[..max_lines].join("\n");
239 result.push_str(&format!(
240 "\n... ({} lines truncated)",
241 lines.len() - max_lines
242 ));
243 result
244}
245
246pub fn proc_token_budget(text: &str, max_tokens: usize) -> String {
248 let current = estimate_tokens(text);
249 if current <= max_tokens {
250 return text.to_string();
251 }
252 let ratio = max_tokens as f64 / current as f64;
253 let target_chars = (text.len() as f64 * ratio) as usize;
254 let mut result = text[..target_chars.min(text.len())].to_string();
255 if let Some(pos) = result.rfind('\n') {
256 result.truncate(pos);
257 }
258 let truncated_tokens = estimate_tokens(&result);
259 result.push_str(&format!(
260 "\n... (truncated to ~{} tokens from {})",
261 truncated_tokens, current
262 ));
263 result
264}
265
266pub fn proc_dedup_blank(text: &str) -> String {
268 let mut result = String::with_capacity(text.len());
269 let mut prev_blank = false;
270 for line in text.lines() {
271 if line.trim().is_empty() {
272 if !prev_blank {
273 result.push('\n');
274 prev_blank = true;
275 }
276 } else {
277 result.push_str(line);
278 result.push('\n');
279 prev_blank = false;
280 }
281 }
282 result
283}
284
285pub fn proc_normalize(text: &str) -> String {
288 let mut result = String::with_capacity(text.len());
289 let mut prev_blank = false;
290
291 for line in text.lines() {
292 let trimmed = line.trim_end();
293 if trimmed.is_empty() {
294 if !prev_blank && !result.is_empty() {
295 result.push('\n');
296 prev_blank = true;
297 }
298 } else {
299 result.push_str(trimmed);
300 result.push('\n');
301 prev_blank = false;
302 }
303 }
304
305 while result.ends_with("\n\n") {
307 result.pop();
308 }
309 result
310}
311
312static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
315 vec![
316 (Regex::new(r"(?i)(AKIA[0-9A-Z]{16})").unwrap(), "[REDACTED:aws-key]"),
318 (Regex::new(r"(?i)(aws_secret_access_key|aws_secret_key)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:aws-secret]"),
320 (Regex::new(r"ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9]{36,}|ghr_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}").unwrap(), "[REDACTED:github-token]"),
322 (Regex::new(r"glpat-[A-Za-z0-9\-_]{20,}").unwrap(), "[REDACTED:gitlab-token]"),
324 (Regex::new(r"xox[bpsar]-[A-Za-z0-9\-]{24,}").unwrap(), "[REDACTED:slack-token]"),
326 (Regex::new(r#"(?i)(api[_-]?key|api[_-]?secret|api[_-]?token|access[_-]?token|secret[_-]?key|auth[_-]?token|private[_-]?key)\s*[=:]\s*['"]?([A-Za-z0-9/+=\-_.]{16,})['"]?"#).unwrap(), "$1=[REDACTED]"),
328 (Regex::new(r"(?i)(Bearer\s+)[A-Za-z0-9\-_.~+/]+=*").unwrap(), "${1}[REDACTED:bearer]"),
330 (Regex::new(r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+").unwrap(), "[REDACTED:jwt]"),
332 (Regex::new(r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----").unwrap(), "[REDACTED:private-key]"),
334 (Regex::new(r"(://[^:]+:)[^@\s]+(@)").unwrap(), "${1}[REDACTED]${2}"),
336 (Regex::new(r"(?i)(HEROKU_API_KEY)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:heroku]"),
338 (Regex::new(r#"(?i)(secret|token|password|passwd|credential)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?"#).unwrap(), "$1=[REDACTED]"),
340 ]
341});
342
343pub fn proc_redact_secrets(text: &str) -> String {
345 let mut result = text.to_string();
346 for (pattern, replacement) in SECRET_PATTERNS.iter() {
347 result = pattern.replace_all(&result, *replacement).to_string();
348 }
349 result
350}
351
352pub fn proc_grep(text: &str, pattern: &str, invert: bool) -> String {
355 let re = match Regex::new(pattern) {
356 Ok(r) => r,
357 Err(_) => return text.to_string(),
358 };
359 text.lines()
360 .filter(|line| re.is_match(line) != invert)
361 .collect::<Vec<_>>()
362 .join("\n")
363}
364
365pub fn proc_cut(text: &str, spec: &str) -> String {
375 let (delim, field_spec) = match spec.split_once(';') {
376 Some((d, f)) => (Some(d), f),
377 None => (None, spec),
378 };
379
380 let ranges: Vec<(usize, usize)> = field_spec
383 .split(',')
384 .filter_map(|s| {
385 let s = s.trim();
386 if let Some((a, b)) = s.split_once('-') {
387 let start = a.parse::<usize>().ok()?;
388 let end = if b.is_empty() { usize::MAX } else { b.parse::<usize>().ok()? };
389 Some((start, end))
390 } else {
391 let n = s.parse::<usize>().ok()?;
392 Some((n, n))
393 }
394 })
395 .collect();
396 if ranges.is_empty() {
397 return text.to_string();
398 }
399
400 text.lines()
401 .map(|line| {
402 let parts: Vec<&str> = match delim {
403 Some(d) => line.split(d).collect(),
404 None => line.split_whitespace().collect(),
405 };
406 let n = parts.len();
407 let mut selected = Vec::new();
408 for &(start, end) in &ranges {
409 let end = end.min(n);
410 for i in start..=end {
411 if let Some(&field) = parts.get(i.checked_sub(1).unwrap_or(0)) {
412 if i >= 1 { selected.push(field); }
413 }
414 }
415 }
416 selected.join(" ")
417 })
418 .collect::<Vec<_>>()
419 .join("\n")
420}
421
422pub fn apply_builtin(name: &str, text: &str, level: Level, param: Option<usize>, pattern: Option<&str>) -> Option<String> {
426 match name {
427 "strip-ansi" => Some(proc_strip_ansi(text)),
428 "truncate" => {
429 let limit = param.unwrap_or_else(|| level.head_limit(200));
430 Some(proc_truncate(text, limit))
431 }
432 "head" => {
433 let limit = param.unwrap_or_else(|| level.head_limit(40));
434 Some(proc_truncate(text, limit))
435 }
436 "token-budget" => {
437 let budget = param.unwrap_or_else(|| match level {
438 Level::Lite => 2000,
439 Level::Full => 1000,
440 Level::Ultra => 500,
441 });
442 Some(proc_token_budget(text, budget))
443 }
444 "dedup-blank" => Some(proc_dedup_blank(text)),
445 "normalize" => Some(proc_normalize(text)),
446 "redact-secrets" => Some(proc_redact_secrets(text)),
447 "grep" => {
448 let pat = pattern.unwrap_or(".");
449 Some(proc_grep(text, pat, false))
450 }
451 "grep-v" => {
452 let pat = pattern.unwrap_or("(?!.*)");
454 Some(proc_grep(text, pat, true))
455 }
456 "cut" => {
457 let spec = pattern.unwrap_or("1-");
458 Some(proc_cut(text, spec))
459 }
460 "passthrough" => Some(text.to_string()),
461 _ => None,
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn pipeline_single() {
471 let p = Pipeline::single("git-compact");
472 assert_eq!(p.len(), 1);
473 assert_eq!(p.stages[0].name, "git-compact");
474 assert_eq!(p.display(), "git-compact");
475 }
476
477 #[test]
478 fn pipeline_from_parts() {
479 let p = Pipeline::from_parts(
480 &["strip-ansi".to_string()],
481 "git-compact",
482 &["truncate".to_string()],
483 );
484 assert_eq!(p.len(), 3);
485 assert_eq!(p.stages[0].stage_type, StageType::Builtin);
486 assert_eq!(p.stages[1].stage_type, StageType::Plugin);
487 assert_eq!(p.stages[2].stage_type, StageType::Builtin);
488 assert_eq!(p.display(), "strip-ansi → git-compact → truncate");
489 }
490
491 #[test]
492 fn pipeline_parse() {
493 let p = Pipeline::parse("strip-ansi | git-compact | truncate");
494 assert_eq!(p.len(), 3);
495 assert_eq!(p.stages[0].name, "strip-ansi");
496 assert_eq!(p.stages[1].name, "git-compact");
497 assert_eq!(p.stages[2].name, "truncate");
498 }
499
500 #[test]
501 fn conditional_select_default() {
502 let cp = ConditionalPipelines {
503 default: Some(Pipeline::single("git-compact")),
504 ..Default::default()
505 };
506 let p = cp.select(0, "some output").unwrap();
507 assert_eq!(p.stages[0].name, "git-compact");
508 }
509
510 #[test]
511 fn conditional_select_error() {
512 let cp = ConditionalPipelines {
513 default: Some(Pipeline::single("git-compact")),
514 on_error: Some(Pipeline::parse("strip-ansi | head")),
515 ..Default::default()
516 };
517 let p = cp.select(1, "error output").unwrap();
519 assert_eq!(p.display(), "strip-ansi → head");
520 let p = cp.select(0, "ok output").unwrap();
522 assert_eq!(p.display(), "git-compact");
523 }
524
525 #[test]
526 fn conditional_select_large() {
527 let cp = ConditionalPipelines {
528 default: Some(Pipeline::single("git-compact")),
529 on_large: Some(Pipeline::parse("git-compact | token-budget")),
530 ..Default::default()
531 };
532 let large_output = "x".repeat(5000); let p = cp.select(0, &large_output).unwrap();
534 assert_eq!(p.display(), "git-compact → token-budget");
535 }
536
537 #[test]
538 fn conditional_select_empty() {
539 let cp = ConditionalPipelines {
540 default: Some(Pipeline::single("git-compact")),
541 on_empty: Some(Pipeline::parse("passthrough")),
542 ..Default::default()
543 };
544 let p = cp.select(0, "").unwrap();
545 assert_eq!(p.display(), "passthrough");
546 }
547
548 #[test]
549 fn conditional_parse() {
550 let lines = vec![
551 ("".to_string(), "strip-ansi | git-compact".to_string()),
552 ("error".to_string(), "head".to_string()),
553 ("large".to_string(), "git-compact | token-budget".to_string()),
554 ];
555 let cp = parse_conditional_pipeline(&lines);
556 assert!(cp.default.is_some());
557 assert!(cp.on_error.is_some());
558 assert!(cp.on_large.is_some());
559 assert!(cp.on_empty.is_none());
560 }
561
562 #[test]
563 fn strip_ansi_basic() {
564 let input = "\x1b[31mERROR\x1b[0m: something failed";
565 assert_eq!(proc_strip_ansi(input), "ERROR: something failed");
566 }
567
568 #[test]
569 fn strip_ansi_clean() {
570 assert_eq!(proc_strip_ansi("no escape codes"), "no escape codes");
571 }
572
573 #[test]
574 fn truncate_within_limit() {
575 let input = "line1\nline2\nline3";
576 assert_eq!(proc_truncate(input, 5), input);
577 }
578
579 #[test]
580 fn truncate_over_limit() {
581 let input = "line1\nline2\nline3\nline4\nline5";
582 let result = proc_truncate(input, 3);
583 assert!(result.starts_with("line1\nline2\nline3"));
584 assert!(result.contains("2 lines truncated"));
585 }
586
587 #[test]
588 fn token_budget_within() {
589 assert_eq!(proc_token_budget("short", 100), "short");
590 }
591
592 #[test]
593 fn token_budget_over() {
594 let input = "a".repeat(400); let result = proc_token_budget(&input, 50);
596 assert!(result.len() < input.len());
597 assert!(result.contains("truncated to"));
598 }
599
600 #[test]
601 fn dedup_blank() {
602 let input = "line1\n\n\n\nline2\n\nline3";
603 assert_eq!(proc_dedup_blank(input), "line1\n\nline2\n\nline3\n");
604 }
605
606 #[test]
607 fn normalize_trailing_spaces() {
608 let input = "line1 \nline2\t\t\n line3 ";
609 assert_eq!(proc_normalize(input), "line1\nline2\n line3\n");
610 }
611
612 #[test]
613 fn normalize_blank_lines() {
614 let input = "line1\n\n\n\nline2\n\nline3\n\n\n";
615 assert_eq!(proc_normalize(input), "line1\n\nline2\n\nline3\n");
616 }
617
618 #[test]
619 fn normalize_leading_blanks() {
620 let input = "\n\n\nline1\nline2";
621 assert_eq!(proc_normalize(input), "line1\nline2\n");
622 }
623
624 #[test]
625 fn normalize_empty() {
626 assert_eq!(proc_normalize(""), "");
627 assert_eq!(proc_normalize("\n\n\n"), "");
628 }
629
630 #[test]
631 fn apply_builtin_known() {
632 assert!(apply_builtin("strip-ansi", "t", Level::Full, None, None).is_some());
633 assert!(apply_builtin("truncate", "t", Level::Full, None, None).is_some());
634 assert!(apply_builtin("token-budget", "t", Level::Full, None, None).is_some());
635 assert!(apply_builtin("dedup-blank", "t", Level::Full, None, None).is_some());
636 assert!(apply_builtin("normalize", "t", Level::Full, None, None).is_some());
637 assert!(apply_builtin("head", "t", Level::Full, None, None).is_some());
638 assert!(apply_builtin("passthrough", "t", Level::Full, None, None).is_some());
639 }
640
641 #[test]
642 fn apply_builtin_unknown() {
643 assert!(apply_builtin("git-compact", "t", Level::Full, None, None).is_none());
644 }
645
646 #[test]
647 fn parse_parameterized_stages() {
648 let p = Pipeline::parse("strip-ansi | truncate:100 | token-budget:1500");
649 assert_eq!(p.len(), 3);
650 assert_eq!(p.stages[0].name, "strip-ansi");
651 assert_eq!(p.stages[0].param, None);
652 assert_eq!(p.stages[1].name, "truncate");
653 assert_eq!(p.stages[1].param, Some(100));
654 assert_eq!(p.stages[2].name, "token-budget");
655 assert_eq!(p.stages[2].param, Some(1500));
656 }
657
658 #[test]
659 fn display_with_params() {
660 let p = Pipeline::parse("strip-ansi | git-compact | truncate:100");
661 assert_eq!(p.display(), "strip-ansi → git-compact → truncate:100");
662 }
663
664 #[test]
665 fn param_overrides_level_default() {
666 let lines = (0..500).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
667 let default_result = apply_builtin("truncate", &lines, Level::Full, None, None).unwrap();
669 assert!(default_result.contains("truncated"));
670 let custom_result = apply_builtin("truncate", &lines, Level::Full, Some(50), None).unwrap();
672 assert!(custom_result.contains("truncated"));
673 assert!(custom_result.lines().count() < default_result.lines().count());
674 }
675
676 #[test]
677 fn redact_aws_key() {
678 let input = "key=AKIAIOSFODNN7EXAMPLE";
679 let out = proc_redact_secrets(input);
680 assert!(out.contains("[REDACTED:aws-key]"));
681 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
682 }
683
684 #[test]
685 fn redact_github_token() {
686 let input = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
687 let out = proc_redact_secrets(input);
688 assert!(out.contains("[REDACTED:github-token]"));
689 assert!(!out.contains("ghp_"));
690 }
691
692 #[test]
693 fn redact_jwt() {
694 let input = "auth: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123signature";
695 let out = proc_redact_secrets(input);
696 assert!(out.contains("[REDACTED:jwt]"));
697 assert!(!out.contains("eyJhbGci"));
698 }
699
700 #[test]
701 fn redact_bearer_token() {
702 let input = "Authorization: Bearer eytoken123456.abcdef.xyz";
703 let out = proc_redact_secrets(input);
704 assert!(out.contains("[REDACTED:bearer]"));
705 }
706
707 #[test]
708 fn redact_password_in_url() {
709 let input = "postgres://admin:s3cretP4ss@db.example.com:5432/mydb";
710 let out = proc_redact_secrets(input);
711 assert!(out.contains("[REDACTED]@"));
712 assert!(!out.contains("s3cretP4ss"));
713 }
714
715 #[test]
716 fn redact_private_key() {
717 let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIBogIB...\n-----END RSA PRIVATE KEY-----";
718 let out = proc_redact_secrets(input);
719 assert!(out.contains("[REDACTED:private-key]"));
720 assert!(!out.contains("MIIBogIB"));
721 }
722
723 #[test]
724 fn redact_generic_api_key() {
725 let input = "API_KEY=abcdef1234567890abcdef1234567890";
726 let out = proc_redact_secrets(input);
727 assert!(out.contains("[REDACTED]"));
728 assert!(!out.contains("abcdef1234567890abcdef1234567890"));
729 }
730
731 #[test]
732 fn redact_slack_token() {
733 let token = format!("xoxb-{}-{}-{}", "0".repeat(12), "0".repeat(13), "a".repeat(24));
735 let input = format!("SLACK_TOKEN={token}");
736 let out = proc_redact_secrets(&input);
737 assert!(out.contains("[REDACTED:slack-token]"));
738 }
739
740 #[test]
741 fn redact_preserves_normal_text() {
742 let input = "commit abc123\nAuthor: zdk\n\n fix login bug\n";
743 let out = proc_redact_secrets(input);
744 assert_eq!(out, input);
745 }
746
747 #[test]
748 fn redact_secrets_is_builtin() {
749 assert!(apply_builtin("redact-secrets", "test", Level::Full, None, None).is_some());
750 }
751
752 #[test]
753 fn grep_keeps_matching_lines() {
754 let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
755 let result = proc_grep(input, "^error", false);
756 assert_eq!(result, "error: bad\nerror: worse");
757 }
758
759 #[test]
760 fn grep_v_removes_matching_lines() {
761 let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
762 let result = proc_grep(input, "^error", true);
763 assert_eq!(result, "info: ok\nwarn: meh");
764 }
765
766 #[test]
767 fn grep_invalid_regex_passthrough() {
768 let input = "hello\nworld";
769 let result = proc_grep(input, "[invalid", false);
770 assert_eq!(result, input);
771 }
772
773 #[test]
774 fn grep_via_apply_builtin() {
775 let input = " M src/main.rs\n?? temp.txt\n D old.rs";
776 let result = apply_builtin("grep", input, Level::Full, None, Some("^\\s*[MADRCU?!]")).unwrap();
777 assert_eq!(result, " M src/main.rs\n?? temp.txt\n D old.rs");
778 }
779
780 #[test]
781 fn grep_v_via_apply_builtin() {
782 let input = "index abc123..def456\nmode 100644\n+++ b/file.rs\n--- a/file.rs";
783 let result = apply_builtin("grep-v", input, Level::Full, None, Some("^(index |mode )")).unwrap();
784 assert_eq!(result, "+++ b/file.rs\n--- a/file.rs");
785 }
786
787 #[test]
788 fn grep_pipeline_parse() {
789 let p = Pipeline::parse("grep:^error | head:10");
790 assert_eq!(p.stages[0].name, "grep");
791 assert_eq!(p.stages[0].pattern.as_deref(), Some("^error"));
792 assert_eq!(p.stages[0].stage_type, StageType::Builtin);
793 assert_eq!(p.stages[1].name, "head");
794 assert_eq!(p.stages[1].param, Some(10));
795 }
796
797 #[test]
798 fn cut_single_field() {
799 let input = "alice 100 x\nbob 200 y\ncharlie 300 z";
800 assert_eq!(proc_cut(input, "2"), "100\n200\n300");
801 }
802
803 #[test]
804 fn cut_multiple_fields() {
805 let input = "alice 100 x\nbob 200 y";
806 assert_eq!(proc_cut(input, "1,3"), "alice x\nbob y");
807 }
808
809 #[test]
810 fn cut_range() {
811 let input = "a b c d e";
812 assert_eq!(proc_cut(input, "2-4"), "b c d");
813 }
814
815 #[test]
816 fn cut_open_ended_range() {
817 let input = "a b c d e";
818 assert_eq!(proc_cut(input, "3-"), "c d e");
819 }
820
821 #[test]
822 fn cut_custom_delimiter() {
823 let input = "alice:100:x\nbob:200:y";
824 assert_eq!(proc_cut(input, ":;1,3"), "alice x\nbob y");
825 }
826
827 #[test]
828 fn cut_via_apply_builtin() {
829 let input = "alice 100 x\nbob 200 y";
830 let result = apply_builtin("cut", input, Level::Full, None, Some("1,2")).unwrap();
831 assert_eq!(result, "alice 100\nbob 200");
832 }
833
834 #[test]
835 fn cut_pipeline_parse() {
836 let p = Pipeline::parse("cut:1,3 | head:10");
837 assert_eq!(p.stages.len(), 2);
838 assert_eq!(p.stages[0].name, "cut");
839 assert_eq!(p.stages[0].pattern.as_deref(), Some("1,3"));
840 assert_eq!(p.stages[1].name, "head");
841 assert_eq!(p.stages[1].param, Some(10));
842 }
843}