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" | "normalize" | "head"
201        | "passthrough" | "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/// Normalize whitespace: trim trailing spaces per line, collapse consecutive
286/// blank lines, and strip leading/trailing blank lines from the whole output.
287pub 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    // Strip trailing blank lines
306    while result.ends_with("\n\n") {
307        result.pop();
308    }
309    result
310}
311
312/// Secret patterns for redaction. Compiled once via LazyLock.
313/// Patterns sourced from gitleaks and common secret formats.
314static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
315    vec![
316        // AWS access key ID
317        (Regex::new(r"(?i)(AKIA[0-9A-Z]{16})").unwrap(), "[REDACTED:aws-key]"),
318        // AWS secret access key (40-char base64 after common key names)
319        (Regex::new(r"(?i)(aws_secret_access_key|aws_secret_key)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:aws-secret]"),
320        // GitHub tokens (ghp_, gho_, ghs_, ghr_, github_pat_)
321        (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        // GitLab tokens (glpat-)
323        (Regex::new(r"glpat-[A-Za-z0-9\-_]{20,}").unwrap(), "[REDACTED:gitlab-token]"),
324        // Slack tokens (xoxb-, xoxp-, xoxs-, xoxa-, xoxr-)
325        (Regex::new(r"xox[bpsar]-[A-Za-z0-9\-]{24,}").unwrap(), "[REDACTED:slack-token]"),
326        // Generic API key/token/secret in key=value or key: value
327        (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        // Bearer tokens
329        (Regex::new(r"(?i)(Bearer\s+)[A-Za-z0-9\-_.~+/]+=*").unwrap(), "${1}[REDACTED:bearer]"),
330        // JWT (three base64url segments separated by dots)
331        (Regex::new(r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+").unwrap(), "[REDACTED:jwt]"),
332        // PEM private keys (multiline: match line-by-line since regex crate is single-line by default)
333        (Regex::new(r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----").unwrap(), "[REDACTED:private-key]"),
334        // Passwords in URLs (proto://user:pass@host)
335        (Regex::new(r"(://[^:]+:)[^@\s]+(@)").unwrap(), "${1}[REDACTED]${2}"),
336        // Heroku API key
337        (Regex::new(r"(?i)(HEROKU_API_KEY)\s*[=:]\s*\S+").unwrap(), "$1=[REDACTED:heroku]"),
338        // Generic hex secrets (32+ hex chars after key-like names)
339        (Regex::new(r#"(?i)(secret|token|password|passwd|credential)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?"#).unwrap(), "$1=[REDACTED]"),
340    ]
341});
342
343/// Redact secrets from text. Replaces known secret patterns with [REDACTED].
344pub 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
352/// Keep or reject lines matching a regex pattern.
353/// `invert = false` → grep (keep matches), `invert = true` → grep -v (reject matches).
354pub 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
365/// Extract fields from each line. Unix `cut -f` compatible syntax:
366///   1-indexed, supports ranges (N-M), open-ended (N-), and comma-separated lists.
367///
368/// Spec: `[delimiter;]fields`
369///   `1,3`       — whitespace split, fields 1 and 3
370///   `1-3`       — whitespace split, fields 1 through 3
371///   `2-`        — field 2 to end
372///   `:;1,3`     — colon delimiter, fields 1 and 3
373///   `/;2-4`     — slash delimiter, fields 2 through 4
374pub 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    // Parse field spec into list of (start, end) inclusive ranges.
381    // end=usize::MAX means "to end of line".
382    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
422/// Apply a built-in processor by name. Returns None if not a built-in.
423/// When `param` is Some, it overrides the level-based default.
424/// When `pattern` is Some, it provides a string param (regex for grep, field spec for cut).
425pub 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            // No pattern → no-op (keep all lines)
453            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        // exit_code != 0 → on_error
518        let p = cp.select(1, "error output").unwrap();
519        assert_eq!(p.display(), "strip-ansi → head");
520        // exit_code == 0 → default
521        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); // > 1000 tokens
533        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); // 100 tokens
595        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        // Without param: Full level truncate = 200 lines
668        let default_result = apply_builtin("truncate", &lines, Level::Full, None, None).unwrap();
669        assert!(default_result.contains("truncated"));
670        // With param: override to 50 lines
671        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        // Build the token at runtime to avoid GitHub push protection flagging it
734        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}