1use crate::level::Level;
2use crate::tokens::estimate_tokens;
3use regex::Regex;
4
5#[derive(Debug, Clone)]
7pub struct Pipeline {
8 pub stages: Vec<PipelineStage>,
9}
10
11#[derive(Debug, Clone, Default)]
13pub struct ConditionalPipelines {
14 pub default: Option<Pipeline>,
16 pub on_error: Option<Pipeline>,
18 pub on_empty: Option<Pipeline>,
20 pub on_large: Option<Pipeline>,
22}
23
24impl ConditionalPipelines {
25 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 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 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#[derive(Debug, Clone)]
58pub struct PipelineStage {
59 pub name: String,
60 pub stage_type: StageType,
61 pub param: Option<usize>,
63 pub pattern: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum StageType {
69 Builtin,
71 Plugin,
73}
74
75impl Pipeline {
76 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 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 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 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
139pub 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 _ => {} }
157 }
158 cp
159}
160
161fn 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
178fn 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 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
196fn 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
207pub 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
231pub 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
245pub 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
265pub 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
284pub 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 while result.ends_with("\n\n") {
306 result.pop();
307 }
308 result
309}
310
311pub fn proc_redact_secrets(text: &str) -> String {
314 crate::redact::redact(text)
315}
316
317pub 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
330pub 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 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
387pub 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 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 let p = cp.select(1, "error output").unwrap();
484 assert_eq!(p.display(), "strip-ansi → head");
485 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); 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); 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 let default_result = apply_builtin("truncate", &lines, Level::Full, None, None).unwrap();
634 assert!(default_result.contains("truncated"));
635 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 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}