Skip to main content

lowfat_core/
pipeline.rs

1use crate::level::Level;
2use crate::tokens::estimate_tokens;
3use regex::Regex;
4use std::sync::LazyLock;
5
6/// A resolved pipeline set: normal chain + optional conditional chains.
7#[derive(Debug, Clone)]
8pub struct Pipeline {
9    pub stages: Vec<PipelineStage>,
10}
11
12/// Conditional pipelines: different chains based on exit code or output patterns.
13#[derive(Debug, Clone, Default)]
14pub struct ConditionalPipelines {
15    /// Default pipeline (always present).
16    pub default: Option<Pipeline>,
17    /// Pipeline when command exits non-zero.
18    pub on_error: Option<Pipeline>,
19    /// Pipeline when output is empty.
20    pub on_empty: Option<Pipeline>,
21    /// Pipeline when output exceeds token budget.
22    pub on_large: Option<Pipeline>,
23}
24
25impl ConditionalPipelines {
26    /// Select the right pipeline based on command result.
27    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        // "large" = > 1000 tokens
39        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    /// Whether any pipelines are configured.
48    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/// A single stage in the pipeline.
57/// Supports optional parameter via `name:param` syntax (e.g., `truncate:100`, `grep:^error`).
58#[derive(Debug, Clone)]
59pub struct PipelineStage {
60    pub name: String,
61    pub stage_type: StageType,
62    /// Optional numeric parameter (e.g., line limit, token budget).
63    pub param: Option<usize>,
64    /// Optional string parameter (e.g., regex pattern for grep).
65    pub pattern: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum StageType {
70    /// Handled by `apply_builtin()`, runs in-process.
71    Builtin,
72    /// External plugin filter (discovered from ~/.lowfat/plugins/)
73    Plugin,
74}
75
76impl Pipeline {
77    /// Create a pipeline with just one filter (backwards-compatible default).
78    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    /// Build a pipeline from pre-processors, main filter, and post-processors.
90    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    /// Parse a pipeline from a pipe-separated string.
103    /// e.g., "strip-ansi | grep:^error | cut:1,3 | truncate:100"
104    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    /// Format as display string. Shows params when present (e.g., "truncate:100", "grep:^error").
123    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
140/// Parse conditional pipelines from .lowfat config lines.
141/// Supports:
142///   pipeline.git = strip-ansi | git-compact | truncate
143///   pipeline.git.error = strip-ansi | head
144///   pipeline.git.empty = passthrough
145///   pipeline.git.large = strip-ansi | git-compact | token-budget
146pub 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            _ => {} // unknown condition, ignore
157        }
158    }
159    cp
160}
161
162/// Parse a single stage spec string into a PipelineStage.
163fn 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
179/// Parse "name:param" into name + numeric or string param.
180/// e.g., "truncate:100" → numeric param, "grep:^error" → string pattern
181fn 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            // Try numeric first, fall back to string pattern
187            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
197/// Determine if a stage name is a built-in processor or an external plugin.
198fn resolve_stage_type(name: &str) -> StageType {
199    match name {
200        "strip-ansi" | "truncate" | "token-budget" | "dedup-blank" | "head" | "passthrough"
201        | "redact-secrets" | "grep" | "grep-v" | "cut" => {
202            StageType::Builtin
203        }
204        _ => StageType::Plugin,
205    }
206}
207
208// --- Built-in processors ---
209
210/// Strip ANSI escape codes.
211pub 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
232/// Truncate text to N lines.
233pub 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
246/// Enforce a token budget.
247pub 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
266/// Remove consecutive blank lines (keep max 1).
267pub 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
285/// Secret patterns for redaction. Compiled once via LazyLock.
286/// Patterns sourced from gitleaks and common secret formats.
287static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
288    vec![
289        // AWS access key ID
290        (Regex::new(r"(?i)(AKIA[0-9A-Z]{16})").unwrap(), "[REDACTED:aws-key]"),
291        // AWS secret access key (40-char base64 after common key names)
292        (Regex::new(r"(?i)(aws_secret_access_key|aws_secret_key)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:aws-secret]"),
293        // GitHub tokens (ghp_, gho_, ghs_, ghr_, github_pat_)
294        (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]"),
295        // GitLab tokens (glpat-)
296        (Regex::new(r"glpat-[A-Za-z0-9\-_]{20,}").unwrap(), "[REDACTED:gitlab-token]"),
297        // Slack tokens (xoxb-, xoxp-, xoxs-, xoxa-, xoxr-)
298        (Regex::new(r"xox[bpsar]-[A-Za-z0-9\-]{24,}").unwrap(), "[REDACTED:slack-token]"),
299        // Generic API key/token/secret in key=value or key: value
300        (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]"),
301        // Bearer tokens
302        (Regex::new(r"(?i)(Bearer\s+)[A-Za-z0-9\-_.~+/]+=*").unwrap(), "${1}[REDACTED:bearer]"),
303        // JWT (three base64url segments separated by dots)
304        (Regex::new(r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+").unwrap(), "[REDACTED:jwt]"),
305        // PEM private keys (multiline: match line-by-line since regex crate is single-line by default)
306        (Regex::new(r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----").unwrap(), "[REDACTED:private-key]"),
307        // Passwords in URLs (proto://user:pass@host)
308        (Regex::new(r"(://[^:]+:)[^@\s]+(@)").unwrap(), "${1}[REDACTED]${2}"),
309        // Heroku API key
310        (Regex::new(r"(?i)(HEROKU_API_KEY)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:heroku]"),
311        // Generic hex secrets (32+ hex chars after key-like names)
312        (Regex::new(r#"(?i)(secret|token|password|passwd|credential)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?"#).unwrap(), "$1=[REDACTED]"),
313    ]
314});
315
316/// Redact secrets from text. Replaces known secret patterns with [REDACTED].
317pub fn proc_redact_secrets(text: &str) -> String {
318    let mut result = text.to_string();
319    for (pattern, replacement) in SECRET_PATTERNS.iter() {
320        result = pattern.replace_all(&result, *replacement).to_string();
321    }
322    result
323}
324
325/// Keep or reject lines matching a regex pattern.
326/// `invert = false` → grep (keep matches), `invert = true` → grep -v (reject matches).
327pub fn proc_grep(text: &str, pattern: &str, invert: bool) -> String {
328    let re = match Regex::new(pattern) {
329        Ok(r) => r,
330        Err(_) => return text.to_string(),
331    };
332    text.lines()
333        .filter(|line| re.is_match(line) != invert)
334        .collect::<Vec<_>>()
335        .join("\n")
336}
337
338/// Extract fields from each line. Unix `cut -f` compatible syntax:
339///   1-indexed, supports ranges (N-M), open-ended (N-), and comma-separated lists.
340///
341/// Spec: `[delimiter;]fields`
342///   `1,3`       — whitespace split, fields 1 and 3
343///   `1-3`       — whitespace split, fields 1 through 3
344///   `2-`        — field 2 to end
345///   `:;1,3`     — colon delimiter, fields 1 and 3
346///   `/;2-4`     — slash delimiter, fields 2 through 4
347pub fn proc_cut(text: &str, spec: &str) -> String {
348    let (delim, field_spec) = match spec.split_once(';') {
349        Some((d, f)) => (Some(d), f),
350        None => (None, spec),
351    };
352
353    // Parse field spec into list of (start, end) inclusive ranges.
354    // end=usize::MAX means "to end of line".
355    let ranges: Vec<(usize, usize)> = field_spec
356        .split(',')
357        .filter_map(|s| {
358            let s = s.trim();
359            if let Some((a, b)) = s.split_once('-') {
360                let start = a.parse::<usize>().ok()?;
361                let end = if b.is_empty() { usize::MAX } else { b.parse::<usize>().ok()? };
362                Some((start, end))
363            } else {
364                let n = s.parse::<usize>().ok()?;
365                Some((n, n))
366            }
367        })
368        .collect();
369    if ranges.is_empty() {
370        return text.to_string();
371    }
372
373    text.lines()
374        .map(|line| {
375            let parts: Vec<&str> = match delim {
376                Some(d) => line.split(d).collect(),
377                None => line.split_whitespace().collect(),
378            };
379            let n = parts.len();
380            let mut selected = Vec::new();
381            for &(start, end) in &ranges {
382                let end = end.min(n);
383                for i in start..=end {
384                    if let Some(&field) = parts.get(i.checked_sub(1).unwrap_or(0)) {
385                        if i >= 1 { selected.push(field); }
386                    }
387                }
388            }
389            selected.join(" ")
390        })
391        .collect::<Vec<_>>()
392        .join("\n")
393}
394
395/// Apply a built-in processor by name. Returns None if not a built-in.
396/// When `param` is Some, it overrides the level-based default.
397/// When `pattern` is Some, it provides a string param (regex for grep, field spec for cut).
398pub fn apply_builtin(name: &str, text: &str, level: Level, param: Option<usize>, pattern: Option<&str>) -> Option<String> {
399    match name {
400        "strip-ansi" => Some(proc_strip_ansi(text)),
401        "truncate" => {
402            let limit = param.unwrap_or_else(|| level.head_limit(200));
403            Some(proc_truncate(text, limit))
404        }
405        "head" => {
406            let limit = param.unwrap_or_else(|| level.head_limit(40));
407            Some(proc_truncate(text, limit))
408        }
409        "token-budget" => {
410            let budget = param.unwrap_or_else(|| match level {
411                Level::Lite => 2000,
412                Level::Full => 1000,
413                Level::Ultra => 500,
414            });
415            Some(proc_token_budget(text, budget))
416        }
417        "dedup-blank" => Some(proc_dedup_blank(text)),
418        "redact-secrets" => Some(proc_redact_secrets(text)),
419        "grep" => {
420            let pat = pattern.unwrap_or(".");
421            Some(proc_grep(text, pat, false))
422        }
423        "grep-v" => {
424            // No pattern → no-op (keep all lines)
425            let pat = pattern.unwrap_or("(?!.*)");
426            Some(proc_grep(text, pat, true))
427        }
428        "cut" => {
429            let spec = pattern.unwrap_or("1-");
430            Some(proc_cut(text, spec))
431        }
432        "passthrough" => Some(text.to_string()),
433        _ => None,
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn pipeline_single() {
443        let p = Pipeline::single("git-compact");
444        assert_eq!(p.len(), 1);
445        assert_eq!(p.stages[0].name, "git-compact");
446        assert_eq!(p.display(), "git-compact");
447    }
448
449    #[test]
450    fn pipeline_from_parts() {
451        let p = Pipeline::from_parts(
452            &["strip-ansi".to_string()],
453            "git-compact",
454            &["truncate".to_string()],
455        );
456        assert_eq!(p.len(), 3);
457        assert_eq!(p.stages[0].stage_type, StageType::Builtin);
458        assert_eq!(p.stages[1].stage_type, StageType::Plugin);
459        assert_eq!(p.stages[2].stage_type, StageType::Builtin);
460        assert_eq!(p.display(), "strip-ansi → git-compact → truncate");
461    }
462
463    #[test]
464    fn pipeline_parse() {
465        let p = Pipeline::parse("strip-ansi | git-compact | truncate");
466        assert_eq!(p.len(), 3);
467        assert_eq!(p.stages[0].name, "strip-ansi");
468        assert_eq!(p.stages[1].name, "git-compact");
469        assert_eq!(p.stages[2].name, "truncate");
470    }
471
472    #[test]
473    fn conditional_select_default() {
474        let cp = ConditionalPipelines {
475            default: Some(Pipeline::single("git-compact")),
476            ..Default::default()
477        };
478        let p = cp.select(0, "some output").unwrap();
479        assert_eq!(p.stages[0].name, "git-compact");
480    }
481
482    #[test]
483    fn conditional_select_error() {
484        let cp = ConditionalPipelines {
485            default: Some(Pipeline::single("git-compact")),
486            on_error: Some(Pipeline::parse("strip-ansi | head")),
487            ..Default::default()
488        };
489        // exit_code != 0 → on_error
490        let p = cp.select(1, "error output").unwrap();
491        assert_eq!(p.display(), "strip-ansi → head");
492        // exit_code == 0 → default
493        let p = cp.select(0, "ok output").unwrap();
494        assert_eq!(p.display(), "git-compact");
495    }
496
497    #[test]
498    fn conditional_select_large() {
499        let cp = ConditionalPipelines {
500            default: Some(Pipeline::single("git-compact")),
501            on_large: Some(Pipeline::parse("git-compact | token-budget")),
502            ..Default::default()
503        };
504        let large_output = "x".repeat(5000); // > 1000 tokens
505        let p = cp.select(0, &large_output).unwrap();
506        assert_eq!(p.display(), "git-compact → token-budget");
507    }
508
509    #[test]
510    fn conditional_select_empty() {
511        let cp = ConditionalPipelines {
512            default: Some(Pipeline::single("git-compact")),
513            on_empty: Some(Pipeline::parse("passthrough")),
514            ..Default::default()
515        };
516        let p = cp.select(0, "").unwrap();
517        assert_eq!(p.display(), "passthrough");
518    }
519
520    #[test]
521    fn conditional_parse() {
522        let lines = vec![
523            ("".to_string(), "strip-ansi | git-compact".to_string()),
524            ("error".to_string(), "head".to_string()),
525            ("large".to_string(), "git-compact | token-budget".to_string()),
526        ];
527        let cp = parse_conditional_pipeline(&lines);
528        assert!(cp.default.is_some());
529        assert!(cp.on_error.is_some());
530        assert!(cp.on_large.is_some());
531        assert!(cp.on_empty.is_none());
532    }
533
534    #[test]
535    fn strip_ansi_basic() {
536        let input = "\x1b[31mERROR\x1b[0m: something failed";
537        assert_eq!(proc_strip_ansi(input), "ERROR: something failed");
538    }
539
540    #[test]
541    fn strip_ansi_clean() {
542        assert_eq!(proc_strip_ansi("no escape codes"), "no escape codes");
543    }
544
545    #[test]
546    fn truncate_within_limit() {
547        let input = "line1\nline2\nline3";
548        assert_eq!(proc_truncate(input, 5), input);
549    }
550
551    #[test]
552    fn truncate_over_limit() {
553        let input = "line1\nline2\nline3\nline4\nline5";
554        let result = proc_truncate(input, 3);
555        assert!(result.starts_with("line1\nline2\nline3"));
556        assert!(result.contains("2 lines truncated"));
557    }
558
559    #[test]
560    fn token_budget_within() {
561        assert_eq!(proc_token_budget("short", 100), "short");
562    }
563
564    #[test]
565    fn token_budget_over() {
566        let input = "a".repeat(400); // 100 tokens
567        let result = proc_token_budget(&input, 50);
568        assert!(result.len() < input.len());
569        assert!(result.contains("truncated to"));
570    }
571
572    #[test]
573    fn dedup_blank() {
574        let input = "line1\n\n\n\nline2\n\nline3";
575        assert_eq!(proc_dedup_blank(input), "line1\n\nline2\n\nline3\n");
576    }
577
578    #[test]
579    fn apply_builtin_known() {
580        assert!(apply_builtin("strip-ansi", "t", Level::Full, None, None).is_some());
581        assert!(apply_builtin("truncate", "t", Level::Full, None, None).is_some());
582        assert!(apply_builtin("token-budget", "t", Level::Full, None, None).is_some());
583        assert!(apply_builtin("dedup-blank", "t", Level::Full, None, None).is_some());
584        assert!(apply_builtin("head", "t", Level::Full, None, None).is_some());
585        assert!(apply_builtin("passthrough", "t", Level::Full, None, None).is_some());
586    }
587
588    #[test]
589    fn apply_builtin_unknown() {
590        assert!(apply_builtin("git-compact", "t", Level::Full, None, None).is_none());
591    }
592
593    #[test]
594    fn parse_parameterized_stages() {
595        let p = Pipeline::parse("strip-ansi | truncate:100 | token-budget:1500");
596        assert_eq!(p.len(), 3);
597        assert_eq!(p.stages[0].name, "strip-ansi");
598        assert_eq!(p.stages[0].param, None);
599        assert_eq!(p.stages[1].name, "truncate");
600        assert_eq!(p.stages[1].param, Some(100));
601        assert_eq!(p.stages[2].name, "token-budget");
602        assert_eq!(p.stages[2].param, Some(1500));
603    }
604
605    #[test]
606    fn display_with_params() {
607        let p = Pipeline::parse("strip-ansi | git-compact | truncate:100");
608        assert_eq!(p.display(), "strip-ansi → git-compact → truncate:100");
609    }
610
611    #[test]
612    fn param_overrides_level_default() {
613        let lines = (0..500).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
614        // Without param: Full level truncate = 200 lines
615        let default_result = apply_builtin("truncate", &lines, Level::Full, None, None).unwrap();
616        assert!(default_result.contains("truncated"));
617        // With param: override to 50 lines
618        let custom_result = apply_builtin("truncate", &lines, Level::Full, Some(50), None).unwrap();
619        assert!(custom_result.contains("truncated"));
620        assert!(custom_result.lines().count() < default_result.lines().count());
621    }
622
623    #[test]
624    fn redact_aws_key() {
625        let input = "key=AKIAIOSFODNN7EXAMPLE";
626        let out = proc_redact_secrets(input);
627        assert!(out.contains("[REDACTED:aws-key]"));
628        assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
629    }
630
631    #[test]
632    fn redact_github_token() {
633        let input = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
634        let out = proc_redact_secrets(input);
635        assert!(out.contains("[REDACTED:github-token]"));
636        assert!(!out.contains("ghp_"));
637    }
638
639    #[test]
640    fn redact_jwt() {
641        let input = "auth: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123signature";
642        let out = proc_redact_secrets(input);
643        assert!(out.contains("[REDACTED:jwt]"));
644        assert!(!out.contains("eyJhbGci"));
645    }
646
647    #[test]
648    fn redact_bearer_token() {
649        let input = "Authorization: Bearer eytoken123456.abcdef.xyz";
650        let out = proc_redact_secrets(input);
651        assert!(out.contains("[REDACTED:bearer]"));
652    }
653
654    #[test]
655    fn redact_password_in_url() {
656        let input = "postgres://admin:s3cretP4ss@db.example.com:5432/mydb";
657        let out = proc_redact_secrets(input);
658        assert!(out.contains("[REDACTED]@"));
659        assert!(!out.contains("s3cretP4ss"));
660    }
661
662    #[test]
663    fn redact_private_key() {
664        let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIBogIB...\n-----END RSA PRIVATE KEY-----";
665        let out = proc_redact_secrets(input);
666        assert!(out.contains("[REDACTED:private-key]"));
667        assert!(!out.contains("MIIBogIB"));
668    }
669
670    #[test]
671    fn redact_generic_api_key() {
672        let input = "API_KEY=abcdef1234567890abcdef1234567890";
673        let out = proc_redact_secrets(input);
674        assert!(out.contains("[REDACTED]"));
675        assert!(!out.contains("abcdef1234567890abcdef1234567890"));
676    }
677
678    #[test]
679    fn redact_slack_token() {
680        // Build the token at runtime to avoid GitHub push protection flagging it
681        let token = format!("xoxb-{}-{}-{}", "0".repeat(12), "0".repeat(13), "a".repeat(24));
682        let input = format!("SLACK_TOKEN={token}");
683        let out = proc_redact_secrets(&input);
684        assert!(out.contains("[REDACTED:slack-token]"));
685    }
686
687    #[test]
688    fn redact_preserves_normal_text() {
689        let input = "commit abc123\nAuthor: zdk\n\n    fix login bug\n";
690        let out = proc_redact_secrets(input);
691        assert_eq!(out, input);
692    }
693
694    #[test]
695    fn redact_secrets_is_builtin() {
696        assert!(apply_builtin("redact-secrets", "test", Level::Full, None, None).is_some());
697    }
698
699    #[test]
700    fn grep_keeps_matching_lines() {
701        let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
702        let result = proc_grep(input, "^error", false);
703        assert_eq!(result, "error: bad\nerror: worse");
704    }
705
706    #[test]
707    fn grep_v_removes_matching_lines() {
708        let input = "error: bad\ninfo: ok\nerror: worse\nwarn: meh";
709        let result = proc_grep(input, "^error", true);
710        assert_eq!(result, "info: ok\nwarn: meh");
711    }
712
713    #[test]
714    fn grep_invalid_regex_passthrough() {
715        let input = "hello\nworld";
716        let result = proc_grep(input, "[invalid", false);
717        assert_eq!(result, input);
718    }
719
720    #[test]
721    fn grep_via_apply_builtin() {
722        let input = "  M src/main.rs\n?? temp.txt\n  D old.rs";
723        let result = apply_builtin("grep", input, Level::Full, None, Some("^\\s*[MADRCU?!]")).unwrap();
724        assert_eq!(result, "  M src/main.rs\n?? temp.txt\n  D old.rs");
725    }
726
727    #[test]
728    fn grep_v_via_apply_builtin() {
729        let input = "index abc123..def456\nmode 100644\n+++ b/file.rs\n--- a/file.rs";
730        let result = apply_builtin("grep-v", input, Level::Full, None, Some("^(index |mode )")).unwrap();
731        assert_eq!(result, "+++ b/file.rs\n--- a/file.rs");
732    }
733
734    #[test]
735    fn grep_pipeline_parse() {
736        let p = Pipeline::parse("grep:^error | head:10");
737        assert_eq!(p.stages[0].name, "grep");
738        assert_eq!(p.stages[0].pattern.as_deref(), Some("^error"));
739        assert_eq!(p.stages[0].stage_type, StageType::Builtin);
740        assert_eq!(p.stages[1].name, "head");
741        assert_eq!(p.stages[1].param, Some(10));
742    }
743
744    #[test]
745    fn cut_single_field() {
746        let input = "alice 100 x\nbob 200 y\ncharlie 300 z";
747        assert_eq!(proc_cut(input, "2"), "100\n200\n300");
748    }
749
750    #[test]
751    fn cut_multiple_fields() {
752        let input = "alice 100 x\nbob 200 y";
753        assert_eq!(proc_cut(input, "1,3"), "alice x\nbob y");
754    }
755
756    #[test]
757    fn cut_range() {
758        let input = "a b c d e";
759        assert_eq!(proc_cut(input, "2-4"), "b c d");
760    }
761
762    #[test]
763    fn cut_open_ended_range() {
764        let input = "a b c d e";
765        assert_eq!(proc_cut(input, "3-"), "c d e");
766    }
767
768    #[test]
769    fn cut_custom_delimiter() {
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_via_apply_builtin() {
776        let input = "alice 100 x\nbob 200 y";
777        let result = apply_builtin("cut", input, Level::Full, None, Some("1,2")).unwrap();
778        assert_eq!(result, "alice 100\nbob 200");
779    }
780
781    #[test]
782    fn cut_pipeline_parse() {
783        let p = Pipeline::parse("cut:1,3 | head:10");
784        assert_eq!(p.stages.len(), 2);
785        assert_eq!(p.stages[0].name, "cut");
786        assert_eq!(p.stages[0].pattern.as_deref(), Some("1,3"));
787        assert_eq!(p.stages[1].name, "head");
788        assert_eq!(p.stages[1].param, Some(10));
789    }
790}