Skip to main content

lowfat_core/
pipeline.rs

1use crate::level::Level;
2use crate::tokens::estimate_tokens;
3use regex::Regex;
4
5/// A resolved pipeline set: normal chain + optional conditional chains.
6#[derive(Debug, Clone)]
7pub struct Pipeline {
8    pub stages: Vec<PipelineStage>,
9}
10
11/// Conditional pipelines: different chains based on exit code or output patterns.
12#[derive(Debug, Clone, Default)]
13pub struct ConditionalPipelines {
14    /// Default pipeline (always present).
15    pub default: Option<Pipeline>,
16    /// Pipeline when command exits non-zero.
17    pub on_error: Option<Pipeline>,
18    /// Pipeline when output is empty.
19    pub on_empty: Option<Pipeline>,
20    /// Pipeline when output exceeds token budget.
21    pub on_large: Option<Pipeline>,
22}
23
24impl ConditionalPipelines {
25    /// Select the right pipeline based on command result.
26    pub fn select(&self, exit_code: i32, output: &str) -> Option<&Pipeline> {
27        if exit_code != 0 {
28            if let Some(ref p) = self.on_error {
29                return Some(p);
30            }
31        }
32        if output.is_empty() {
33            if let Some(ref p) = self.on_empty {
34                return Some(p);
35            }
36        }
37        // "large" = > 1000 tokens
38        if estimate_tokens(output) > 1000 {
39            if let Some(ref p) = self.on_large {
40                return Some(p);
41            }
42        }
43        self.default.as_ref()
44    }
45
46    /// Whether any pipelines are configured.
47    pub fn is_empty(&self) -> bool {
48        self.default.is_none()
49            && self.on_error.is_none()
50            && self.on_empty.is_none()
51            && self.on_large.is_none()
52    }
53}
54
55/// A single stage in the pipeline.
56/// Supports optional parameter via `name:param` syntax (e.g., `truncate:100`, `grep:^error`).
57#[derive(Debug, Clone)]
58pub struct PipelineStage {
59    pub name: String,
60    pub stage_type: StageType,
61    /// Optional numeric parameter (e.g., line limit, token budget).
62    pub param: Option<usize>,
63    /// Optional string parameter (e.g., regex pattern for grep).
64    pub pattern: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum StageType {
69    /// Handled by `apply_builtin()`, runs in-process.
70    Builtin,
71    /// External plugin filter (discovered from ~/.lowfat/plugins/)
72    Plugin,
73}
74
75impl Pipeline {
76    /// Create a pipeline with just one filter (backwards-compatible default).
77    pub fn single(filter_name: &str) -> Self {
78        Pipeline {
79            stages: vec![PipelineStage {
80                name: filter_name.to_string(),
81                stage_type: StageType::Plugin,
82                param: None,
83                pattern: None,
84            }],
85        }
86    }
87
88    /// Build a pipeline from pre-processors, main filter, and post-processors.
89    pub fn from_parts(pre: &[String], filter_name: &str, post: &[String]) -> Self {
90        let mut stages: Vec<PipelineStage> = pre.iter().map(|s| parse_pipeline_stage(s)).collect();
91        stages.push(PipelineStage {
92            name: filter_name.to_string(),
93            stage_type: StageType::Plugin,
94            param: None,
95            pattern: None,
96        });
97        stages.extend(post.iter().map(|s| parse_pipeline_stage(s)));
98        Pipeline { stages }
99    }
100
101    /// Parse a pipeline from a pipe-separated string.
102    /// e.g., "strip-ansi | grep:^error | cut:1,3 | truncate:100"
103    pub fn parse(spec: &str) -> Self {
104        let stages = spec
105            .split('|')
106            .map(|s| s.trim())
107            .filter(|s| !s.is_empty())
108            .map(|raw| parse_pipeline_stage(raw))
109            .collect();
110        Pipeline { stages }
111    }
112
113    pub fn len(&self) -> usize {
114        self.stages.len()
115    }
116
117    pub fn is_empty(&self) -> bool {
118        self.stages.is_empty()
119    }
120
121    /// Format as display string. Shows params when present (e.g., "truncate:100", "grep:^error").
122    pub fn display(&self) -> String {
123        self.stages
124            .iter()
125            .map(|s| {
126                if let Some(ref pat) = s.pattern {
127                    format!("{}:{}", s.name, pat)
128                } else if let Some(p) = s.param {
129                    format!("{}:{}", s.name, p)
130                } else {
131                    s.name.clone()
132                }
133            })
134            .collect::<Vec<_>>()
135            .join(" → ")
136    }
137}
138
139/// Parse conditional pipelines from .lowfat config lines.
140/// Supports:
141///   pipeline.git = strip-ansi | git-compact | truncate
142///   pipeline.git.error = strip-ansi | head
143///   pipeline.git.empty = passthrough
144///   pipeline.git.large = strip-ansi | git-compact | token-budget
145pub fn parse_conditional_pipeline(
146    lines: &[(String, String)],
147) -> ConditionalPipelines {
148    let mut cp = ConditionalPipelines::default();
149    for (key, spec) in lines {
150        match key.as_str() {
151            "" => cp.default = Some(Pipeline::parse(spec)),
152            "error" => cp.on_error = Some(Pipeline::parse(spec)),
153            "empty" => cp.on_empty = Some(Pipeline::parse(spec)),
154            "large" => cp.on_large = Some(Pipeline::parse(spec)),
155            _ => {} // unknown condition, ignore
156        }
157    }
158    cp
159}
160
161/// Parse a single stage spec string into a PipelineStage.
162fn parse_pipeline_stage(raw: &str) -> PipelineStage {
163    let spec = parse_stage_spec(raw);
164    PipelineStage {
165        stage_type: resolve_stage_type(&spec.name),
166        name: spec.name,
167        param: spec.param,
168        pattern: spec.pattern,
169    }
170}
171
172struct ParsedStage {
173    name: String,
174    param: Option<usize>,
175    pattern: Option<String>,
176}
177
178/// Parse "name:param" into name + numeric or string param.
179/// e.g., "truncate:100" → numeric param, "grep:^error" → string pattern
180fn parse_stage_spec(spec: &str) -> ParsedStage {
181    match spec.split_once(':') {
182        Some((name, rest)) => {
183            let name = name.trim().to_string();
184            let rest = rest.trim();
185            // Try numeric first, fall back to string pattern
186            if let Ok(n) = rest.parse::<usize>() {
187                ParsedStage { name, param: Some(n), pattern: None }
188            } else {
189                ParsedStage { name, param: None, pattern: Some(rest.to_string()) }
190            }
191        }
192        None => ParsedStage { name: spec.trim().to_string(), param: None, pattern: None },
193    }
194}
195
196/// Determine if a stage name is a built-in processor or an external plugin.
197fn resolve_stage_type(name: &str) -> StageType {
198    match name {
199        "strip-ansi" | "truncate" | "token-budget" | "dedup-blank" | "normalize" | "head"
200        | "passthrough" | "redact-secrets" | "grep" | "grep-v" | "cut" => {
201            StageType::Builtin
202        }
203        _ => StageType::Plugin,
204    }
205}
206
207// --- Built-in processors ---
208
209/// Strip ANSI escape codes.
210pub fn proc_strip_ansi(text: &str) -> String {
211    let mut result = String::with_capacity(text.len());
212    let mut chars = text.chars().peekable();
213    while let Some(ch) = chars.next() {
214        if ch == '\x1b' {
215            if chars.peek() == Some(&'[') {
216                chars.next();
217                while let Some(&c) = chars.peek() {
218                    chars.next();
219                    if c.is_ascii_alphabetic() {
220                        break;
221                    }
222                }
223                continue;
224            }
225        }
226        result.push(ch);
227    }
228    result
229}
230
231/// Truncate text to N lines.
232pub fn proc_truncate(text: &str, max_lines: usize) -> String {
233    let lines: Vec<&str> = text.lines().collect();
234    if lines.len() <= max_lines {
235        return text.to_string();
236    }
237    let mut result: String = lines[..max_lines].join("\n");
238    result.push_str(&format!(
239        "\n... ({} lines truncated)",
240        lines.len() - max_lines
241    ));
242    result
243}
244
245/// Enforce a token budget.
246pub fn proc_token_budget(text: &str, max_tokens: usize) -> String {
247    let current = estimate_tokens(text);
248    if current <= max_tokens {
249        return text.to_string();
250    }
251    let ratio = max_tokens as f64 / current as f64;
252    let target_chars = (text.len() as f64 * ratio) as usize;
253    let mut result = text[..target_chars.min(text.len())].to_string();
254    if let Some(pos) = result.rfind('\n') {
255        result.truncate(pos);
256    }
257    let truncated_tokens = estimate_tokens(&result);
258    result.push_str(&format!(
259        "\n... (truncated to ~{} tokens from {})",
260        truncated_tokens, current
261    ));
262    result
263}
264
265/// Remove consecutive blank lines (keep max 1).
266pub fn proc_dedup_blank(text: &str) -> String {
267    let mut result = String::with_capacity(text.len());
268    let mut prev_blank = false;
269    for line in text.lines() {
270        if line.trim().is_empty() {
271            if !prev_blank {
272                result.push('\n');
273                prev_blank = true;
274            }
275        } else {
276            result.push_str(line);
277            result.push('\n');
278            prev_blank = false;
279        }
280    }
281    result
282}
283
284/// Normalize whitespace: trim trailing spaces per line, collapse consecutive
285/// blank lines, and strip leading/trailing blank lines from the whole output.
286pub fn proc_normalize(text: &str) -> String {
287    let mut result = String::with_capacity(text.len());
288    let mut prev_blank = false;
289
290    for line in text.lines() {
291        let trimmed = line.trim_end();
292        if trimmed.is_empty() {
293            if !prev_blank && !result.is_empty() {
294                result.push('\n');
295                prev_blank = true;
296            }
297        } else {
298            result.push_str(trimmed);
299            result.push('\n');
300            prev_blank = false;
301        }
302    }
303
304    // Strip trailing blank lines
305    while result.ends_with("\n\n") {
306        result.pop();
307    }
308    result
309}
310
311/// Redact secrets via the configured ruleset — built-in defaults layered
312/// with `redact.conf` (global + project). See [`crate::redact`].
313pub fn proc_redact_secrets(text: &str) -> String {
314    crate::redact::redact(text)
315}
316
317/// Keep or reject lines matching a regex pattern.
318/// `invert = false` → grep (keep matches), `invert = true` → grep -v (reject matches).
319pub fn proc_grep(text: &str, pattern: &str, invert: bool) -> String {
320    let re = match Regex::new(pattern) {
321        Ok(r) => r,
322        Err(_) => return text.to_string(),
323    };
324    text.lines()
325        .filter(|line| re.is_match(line) != invert)
326        .collect::<Vec<_>>()
327        .join("\n")
328}
329
330/// Extract fields from each line. Unix `cut -f` compatible syntax:
331///   1-indexed, supports ranges (N-M), open-ended (N-), and comma-separated lists.
332///
333/// Spec: `[delimiter;]fields`
334///   `1,3`       — whitespace split, fields 1 and 3
335///   `1-3`       — whitespace split, fields 1 through 3
336///   `2-`        — field 2 to end
337///   `:;1,3`     — colon delimiter, fields 1 and 3
338///   `/;2-4`     — slash delimiter, fields 2 through 4
339pub fn proc_cut(text: &str, spec: &str) -> String {
340    let (delim, field_spec) = match spec.split_once(';') {
341        Some((d, f)) => (Some(d), f),
342        None => (None, spec),
343    };
344
345    // Parse field spec into list of (start, end) inclusive ranges.
346    // end=usize::MAX means "to end of line".
347    let ranges: Vec<(usize, usize)> = field_spec
348        .split(',')
349        .filter_map(|s| {
350            let s = s.trim();
351            if let Some((a, b)) = s.split_once('-') {
352                let start = a.parse::<usize>().ok()?;
353                let end = if b.is_empty() { usize::MAX } else { b.parse::<usize>().ok()? };
354                Some((start, end))
355            } else {
356                let n = s.parse::<usize>().ok()?;
357                Some((n, n))
358            }
359        })
360        .collect();
361    if ranges.is_empty() {
362        return text.to_string();
363    }
364
365    text.lines()
366        .map(|line| {
367            let parts: Vec<&str> = match delim {
368                Some(d) => line.split(d).collect(),
369                None => line.split_whitespace().collect(),
370            };
371            let n = parts.len();
372            let mut selected = Vec::new();
373            for &(start, end) in &ranges {
374                let end = end.min(n);
375                for i in start..=end {
376                    if let Some(&field) = parts.get(i.checked_sub(1).unwrap_or(0)) {
377                        if i >= 1 { selected.push(field); }
378                    }
379                }
380            }
381            selected.join(" ")
382        })
383        .collect::<Vec<_>>()
384        .join("\n")
385}
386
387/// Apply a built-in processor by name. Returns None if not a built-in.
388/// When `param` is Some, it overrides the level-based default.
389/// When `pattern` is Some, it provides a string param (regex for grep, field spec for cut).
390pub fn apply_builtin(name: &str, text: &str, level: Level, param: Option<usize>, pattern: Option<&str>) -> Option<String> {
391    match name {
392        "strip-ansi" => Some(proc_strip_ansi(text)),
393        "truncate" => {
394            let limit = param.unwrap_or_else(|| level.head_limit(200));
395            Some(proc_truncate(text, limit))
396        }
397        "head" => {
398            let limit = param.unwrap_or_else(|| level.head_limit(40));
399            Some(proc_truncate(text, limit))
400        }
401        "token-budget" => {
402            let budget = param.unwrap_or_else(|| match level {
403                Level::Lite => 2000,
404                Level::Full => 1000,
405                Level::Ultra => 500,
406            });
407            Some(proc_token_budget(text, budget))
408        }
409        "dedup-blank" => Some(proc_dedup_blank(text)),
410        "normalize" => Some(proc_normalize(text)),
411        "redact-secrets" => Some(proc_redact_secrets(text)),
412        "grep" => {
413            let pat = pattern.unwrap_or(".");
414            Some(proc_grep(text, pat, false))
415        }
416        "grep-v" => {
417            // No pattern → no-op (keep all lines)
418            let pat = pattern.unwrap_or("(?!.*)");
419            Some(proc_grep(text, pat, true))
420        }
421        "cut" => {
422            let spec = pattern.unwrap_or("1-");
423            Some(proc_cut(text, spec))
424        }
425        "passthrough" => Some(text.to_string()),
426        _ => None,
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn pipeline_single() {
436        let p = Pipeline::single("git-compact");
437        assert_eq!(p.len(), 1);
438        assert_eq!(p.stages[0].name, "git-compact");
439        assert_eq!(p.display(), "git-compact");
440    }
441
442    #[test]
443    fn pipeline_from_parts() {
444        let p = Pipeline::from_parts(
445            &["strip-ansi".to_string()],
446            "git-compact",
447            &["truncate".to_string()],
448        );
449        assert_eq!(p.len(), 3);
450        assert_eq!(p.stages[0].stage_type, StageType::Builtin);
451        assert_eq!(p.stages[1].stage_type, StageType::Plugin);
452        assert_eq!(p.stages[2].stage_type, StageType::Builtin);
453        assert_eq!(p.display(), "strip-ansi → git-compact → truncate");
454    }
455
456    #[test]
457    fn pipeline_parse() {
458        let p = Pipeline::parse("strip-ansi | git-compact | truncate");
459        assert_eq!(p.len(), 3);
460        assert_eq!(p.stages[0].name, "strip-ansi");
461        assert_eq!(p.stages[1].name, "git-compact");
462        assert_eq!(p.stages[2].name, "truncate");
463    }
464
465    #[test]
466    fn conditional_select_default() {
467        let cp = ConditionalPipelines {
468            default: Some(Pipeline::single("git-compact")),
469            ..Default::default()
470        };
471        let p = cp.select(0, "some output").unwrap();
472        assert_eq!(p.stages[0].name, "git-compact");
473    }
474
475    #[test]
476    fn conditional_select_error() {
477        let cp = ConditionalPipelines {
478            default: Some(Pipeline::single("git-compact")),
479            on_error: Some(Pipeline::parse("strip-ansi | head")),
480            ..Default::default()
481        };
482        // exit_code != 0 → on_error
483        let p = cp.select(1, "error output").unwrap();
484        assert_eq!(p.display(), "strip-ansi → head");
485        // exit_code == 0 → default
486        let p = cp.select(0, "ok output").unwrap();
487        assert_eq!(p.display(), "git-compact");
488    }
489
490    #[test]
491    fn conditional_select_large() {
492        let cp = ConditionalPipelines {
493            default: Some(Pipeline::single("git-compact")),
494            on_large: Some(Pipeline::parse("git-compact | token-budget")),
495            ..Default::default()
496        };
497        let large_output = "x".repeat(5000); // > 1000 tokens
498        let p = cp.select(0, &large_output).unwrap();
499        assert_eq!(p.display(), "git-compact → token-budget");
500    }
501
502    #[test]
503    fn conditional_select_empty() {
504        let cp = ConditionalPipelines {
505            default: Some(Pipeline::single("git-compact")),
506            on_empty: Some(Pipeline::parse("passthrough")),
507            ..Default::default()
508        };
509        let p = cp.select(0, "").unwrap();
510        assert_eq!(p.display(), "passthrough");
511    }
512
513    #[test]
514    fn conditional_parse() {
515        let lines = vec![
516            ("".to_string(), "strip-ansi | git-compact".to_string()),
517            ("error".to_string(), "head".to_string()),
518            ("large".to_string(), "git-compact | token-budget".to_string()),
519        ];
520        let cp = parse_conditional_pipeline(&lines);
521        assert!(cp.default.is_some());
522        assert!(cp.on_error.is_some());
523        assert!(cp.on_large.is_some());
524        assert!(cp.on_empty.is_none());
525    }
526
527    #[test]
528    fn strip_ansi_basic() {
529        let input = "\x1b[31mERROR\x1b[0m: something failed";
530        assert_eq!(proc_strip_ansi(input), "ERROR: something failed");
531    }
532
533    #[test]
534    fn strip_ansi_clean() {
535        assert_eq!(proc_strip_ansi("no escape codes"), "no escape codes");
536    }
537
538    #[test]
539    fn truncate_within_limit() {
540        let input = "line1\nline2\nline3";
541        assert_eq!(proc_truncate(input, 5), input);
542    }
543
544    #[test]
545    fn truncate_over_limit() {
546        let input = "line1\nline2\nline3\nline4\nline5";
547        let result = proc_truncate(input, 3);
548        assert!(result.starts_with("line1\nline2\nline3"));
549        assert!(result.contains("2 lines truncated"));
550    }
551
552    #[test]
553    fn token_budget_within() {
554        assert_eq!(proc_token_budget("short", 100), "short");
555    }
556
557    #[test]
558    fn token_budget_over() {
559        let input = "a".repeat(400); // 100 tokens
560        let result = proc_token_budget(&input, 50);
561        assert!(result.len() < input.len());
562        assert!(result.contains("truncated to"));
563    }
564
565    #[test]
566    fn dedup_blank() {
567        let input = "line1\n\n\n\nline2\n\nline3";
568        assert_eq!(proc_dedup_blank(input), "line1\n\nline2\n\nline3\n");
569    }
570
571    #[test]
572    fn normalize_trailing_spaces() {
573        let input = "line1   \nline2\t\t\n  line3  ";
574        assert_eq!(proc_normalize(input), "line1\nline2\n  line3\n");
575    }
576
577    #[test]
578    fn normalize_blank_lines() {
579        let input = "line1\n\n\n\nline2\n\nline3\n\n\n";
580        assert_eq!(proc_normalize(input), "line1\n\nline2\n\nline3\n");
581    }
582
583    #[test]
584    fn normalize_leading_blanks() {
585        let input = "\n\n\nline1\nline2";
586        assert_eq!(proc_normalize(input), "line1\nline2\n");
587    }
588
589    #[test]
590    fn normalize_empty() {
591        assert_eq!(proc_normalize(""), "");
592        assert_eq!(proc_normalize("\n\n\n"), "");
593    }
594
595    #[test]
596    fn apply_builtin_known() {
597        assert!(apply_builtin("strip-ansi", "t", Level::Full, None, None).is_some());
598        assert!(apply_builtin("truncate", "t", Level::Full, None, None).is_some());
599        assert!(apply_builtin("token-budget", "t", Level::Full, None, None).is_some());
600        assert!(apply_builtin("dedup-blank", "t", Level::Full, None, None).is_some());
601        assert!(apply_builtin("normalize", "t", Level::Full, None, None).is_some());
602        assert!(apply_builtin("head", "t", Level::Full, None, None).is_some());
603        assert!(apply_builtin("passthrough", "t", Level::Full, None, None).is_some());
604    }
605
606    #[test]
607    fn apply_builtin_unknown() {
608        assert!(apply_builtin("git-compact", "t", Level::Full, None, None).is_none());
609    }
610
611    #[test]
612    fn parse_parameterized_stages() {
613        let p = Pipeline::parse("strip-ansi | truncate:100 | token-budget:1500");
614        assert_eq!(p.len(), 3);
615        assert_eq!(p.stages[0].name, "strip-ansi");
616        assert_eq!(p.stages[0].param, None);
617        assert_eq!(p.stages[1].name, "truncate");
618        assert_eq!(p.stages[1].param, Some(100));
619        assert_eq!(p.stages[2].name, "token-budget");
620        assert_eq!(p.stages[2].param, Some(1500));
621    }
622
623    #[test]
624    fn display_with_params() {
625        let p = Pipeline::parse("strip-ansi | git-compact | truncate:100");
626        assert_eq!(p.display(), "strip-ansi → git-compact → truncate:100");
627    }
628
629    #[test]
630    fn param_overrides_level_default() {
631        let lines = (0..500).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
632        // Without param: Full level truncate = 200 lines
633        let default_result = apply_builtin("truncate", &lines, Level::Full, None, None).unwrap();
634        assert!(default_result.contains("truncated"));
635        // With param: override to 50 lines
636        let custom_result = apply_builtin("truncate", &lines, Level::Full, Some(50), None).unwrap();
637        assert!(custom_result.contains("truncated"));
638        assert!(custom_result.lines().count() < default_result.lines().count());
639    }
640
641    #[test]
642    fn redact_aws_key() {
643        let input = "key=AKIAIOSFODNN7EXAMPLE";
644        let out = proc_redact_secrets(input);
645        assert!(out.contains("[REDACTED:aws-key]"));
646        assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
647    }
648
649    #[test]
650    fn redact_github_token() {
651        let input = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
652        let out = proc_redact_secrets(input);
653        assert!(out.contains("[REDACTED:github-token]"));
654        assert!(!out.contains("ghp_"));
655    }
656
657    #[test]
658    fn redact_jwt() {
659        let input = "auth: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123signature";
660        let out = proc_redact_secrets(input);
661        assert!(out.contains("[REDACTED:jwt]"));
662        assert!(!out.contains("eyJhbGci"));
663    }
664
665    #[test]
666    fn redact_bearer_token() {
667        let input = "Authorization: Bearer eytoken123456.abcdef.xyz";
668        let out = proc_redact_secrets(input);
669        assert!(out.contains("[REDACTED:bearer]"));
670    }
671
672    #[test]
673    fn redact_password_in_url() {
674        let input = "postgres://admin:s3cretP4ss@db.example.com:5432/mydb";
675        let out = proc_redact_secrets(input);
676        assert!(out.contains("[REDACTED]@"));
677        assert!(!out.contains("s3cretP4ss"));
678    }
679
680    #[test]
681    fn redact_private_key() {
682        let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIBogIB...\n-----END RSA PRIVATE KEY-----";
683        let out = proc_redact_secrets(input);
684        assert!(out.contains("[REDACTED:private-key]"));
685        assert!(!out.contains("MIIBogIB"));
686    }
687
688    #[test]
689    fn redact_generic_api_key() {
690        let input = "API_KEY=abcdef1234567890abcdef1234567890";
691        let out = proc_redact_secrets(input);
692        assert!(out.contains("[REDACTED]"));
693        assert!(!out.contains("abcdef1234567890abcdef1234567890"));
694    }
695
696    #[test]
697    fn redact_slack_token() {
698        // Build the token at runtime to avoid GitHub push protection flagging it
699        let token = format!("xoxb-{}-{}-{}", "0".repeat(12), "0".repeat(13), "a".repeat(24));
700        let input = format!("SLACK_TOKEN={token}");
701        let out = proc_redact_secrets(&input);
702        assert!(out.contains("[REDACTED:slack-token]"));
703    }
704
705    #[test]
706    fn redact_preserves_normal_text() {
707        let input = "commit abc123\nAuthor: zdk\n\n    fix login bug\n";
708        let out = proc_redact_secrets(input);
709        assert_eq!(out, input);
710    }
711
712    #[test]
713    fn redact_secrets_is_builtin() {
714        assert!(apply_builtin("redact-secrets", "test", Level::Full, None, None).is_some());
715    }
716
717    #[test]
718    fn grep_keeps_matching_lines() {
719        let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
720        let result = proc_grep(input, "^error", false);
721        assert_eq!(result, "error: bad\nerror: worse");
722    }
723
724    #[test]
725    fn grep_v_removes_matching_lines() {
726        let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
727        let result = proc_grep(input, "^error", true);
728        assert_eq!(result, "info: ok\nwarn: meh");
729    }
730
731    #[test]
732    fn grep_invalid_regex_passthrough() {
733        let input = "hello\nworld";
734        let result = proc_grep(input, "[invalid", false);
735        assert_eq!(result, input);
736    }
737
738    #[test]
739    fn grep_via_apply_builtin() {
740        let input = "  M src/main.rs\n?? temp.txt\n  D old.rs";
741        let result = apply_builtin("grep", input, Level::Full, None, Some("^\\s*[MADRCU?!]")).unwrap();
742        assert_eq!(result, "  M src/main.rs\n?? temp.txt\n  D old.rs");
743    }
744
745    #[test]
746    fn grep_v_via_apply_builtin() {
747        let input = "index abc123..def456\nmode 100644\n+++ b/file.rs\n--- a/file.rs";
748        let result = apply_builtin("grep-v", input, Level::Full, None, Some("^(index |mode )")).unwrap();
749        assert_eq!(result, "+++ b/file.rs\n--- a/file.rs");
750    }
751
752    #[test]
753    fn grep_pipeline_parse() {
754        let p = Pipeline::parse("grep:^error | head:10");
755        assert_eq!(p.stages[0].name, "grep");
756        assert_eq!(p.stages[0].pattern.as_deref(), Some("^error"));
757        assert_eq!(p.stages[0].stage_type, StageType::Builtin);
758        assert_eq!(p.stages[1].name, "head");
759        assert_eq!(p.stages[1].param, Some(10));
760    }
761
762    #[test]
763    fn cut_single_field() {
764        let input = "alice 100 x\nbob 200 y\ncharlie 300 z";
765        assert_eq!(proc_cut(input, "2"), "100\n200\n300");
766    }
767
768    #[test]
769    fn cut_multiple_fields() {
770        let input = "alice 100 x\nbob 200 y";
771        assert_eq!(proc_cut(input, "1,3"), "alice x\nbob y");
772    }
773
774    #[test]
775    fn cut_range() {
776        let input = "a b c d e";
777        assert_eq!(proc_cut(input, "2-4"), "b c d");
778    }
779
780    #[test]
781    fn cut_open_ended_range() {
782        let input = "a b c d e";
783        assert_eq!(proc_cut(input, "3-"), "c d e");
784    }
785
786    #[test]
787    fn cut_custom_delimiter() {
788        let input = "alice:100:x\nbob:200:y";
789        assert_eq!(proc_cut(input, ":;1,3"), "alice x\nbob y");
790    }
791
792    #[test]
793    fn cut_via_apply_builtin() {
794        let input = "alice 100 x\nbob 200 y";
795        let result = apply_builtin("cut", input, Level::Full, None, Some("1,2")).unwrap();
796        assert_eq!(result, "alice 100\nbob 200");
797    }
798
799    #[test]
800    fn cut_pipeline_parse() {
801        let p = Pipeline::parse("cut:1,3 | head:10");
802        assert_eq!(p.stages.len(), 2);
803        assert_eq!(p.stages[0].name, "cut");
804        assert_eq!(p.stages[0].pattern.as_deref(), Some("1,3"));
805        assert_eq!(p.stages[1].name, "head");
806        assert_eq!(p.stages[1].param, Some(10));
807    }
808}