use crate::level::Level;
use crate::tokens::estimate_tokens;
#[derive(Debug, Clone)]
pub struct Pipeline {
pub stages: Vec<PipelineStage>,
}
#[derive(Debug, Clone, Default)]
pub struct ConditionalPipelines {
pub default: Option<Pipeline>,
pub on_error: Option<Pipeline>,
pub on_empty: Option<Pipeline>,
pub on_large: Option<Pipeline>,
}
impl ConditionalPipelines {
pub fn select(&self, exit_code: i32, output: &str) -> Option<&Pipeline> {
if exit_code != 0 {
if let Some(ref p) = self.on_error {
return Some(p);
}
}
if output.is_empty() {
if let Some(ref p) = self.on_empty {
return Some(p);
}
}
if estimate_tokens(output) > 1000 {
if let Some(ref p) = self.on_large {
return Some(p);
}
}
self.default.as_ref()
}
pub fn is_empty(&self) -> bool {
self.default.is_none()
&& self.on_error.is_none()
&& self.on_empty.is_none()
&& self.on_large.is_none()
}
}
#[derive(Debug, Clone)]
pub struct PipelineStage {
pub name: String,
pub stage_type: StageType,
pub param: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StageType {
Builtin,
Plugin,
}
impl Pipeline {
pub fn single(filter_name: &str) -> Self {
Pipeline {
stages: vec![PipelineStage {
name: filter_name.to_string(),
stage_type: StageType::Plugin,
param: None,
}],
}
}
pub fn from_parts(pre: &[String], filter_name: &str, post: &[String]) -> Self {
let mut stages = Vec::new();
for name in pre {
let (base, param) = parse_stage_spec(name);
stages.push(PipelineStage {
name: base,
stage_type: resolve_stage_type(&name.split(':').next().unwrap_or(name)),
param,
});
}
stages.push(PipelineStage {
name: filter_name.to_string(),
stage_type: StageType::Plugin,
param: None,
});
for name in post {
let (base, param) = parse_stage_spec(name);
stages.push(PipelineStage {
name: base,
stage_type: resolve_stage_type(&name.split(':').next().unwrap_or(name)),
param,
});
}
Pipeline { stages }
}
pub fn parse(spec: &str) -> Self {
let stages = spec
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|raw| {
let (name, param) = parse_stage_spec(raw);
let base_name = raw.split(':').next().unwrap_or(raw);
PipelineStage {
name,
stage_type: resolve_stage_type(base_name),
param,
}
})
.collect();
Pipeline { stages }
}
pub fn len(&self) -> usize {
self.stages.len()
}
pub fn is_empty(&self) -> bool {
self.stages.is_empty()
}
pub fn display(&self) -> String {
self.stages
.iter()
.map(|s| match s.param {
Some(p) => format!("{}:{}", s.name, p),
None => s.name.clone(),
})
.collect::<Vec<_>>()
.join(" → ")
}
}
pub fn parse_conditional_pipeline(
lines: &[(String, String)],
) -> ConditionalPipelines {
let mut cp = ConditionalPipelines::default();
for (key, spec) in lines {
match key.as_str() {
"" => cp.default = Some(Pipeline::parse(spec)),
"error" => cp.on_error = Some(Pipeline::parse(spec)),
"empty" => cp.on_empty = Some(Pipeline::parse(spec)),
"large" => cp.on_large = Some(Pipeline::parse(spec)),
_ => {} }
}
cp
}
fn parse_stage_spec(spec: &str) -> (String, Option<usize>) {
match spec.split_once(':') {
Some((name, param)) => {
let param = param.trim().parse::<usize>().ok();
(name.trim().to_string(), param)
}
None => (spec.to_string(), None),
}
}
fn resolve_stage_type(name: &str) -> StageType {
match name {
"strip-ansi" | "truncate" | "token-budget" | "dedup-blank" | "head" | "passthrough" => {
StageType::Builtin
}
_ => StageType::Plugin,
}
}
pub fn proc_strip_ansi(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() {
break;
}
}
continue;
}
}
result.push(ch);
}
result
}
pub fn proc_truncate(text: &str, max_lines: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= max_lines {
return text.to_string();
}
let mut result: String = lines[..max_lines].join("\n");
result.push_str(&format!(
"\n... ({} lines truncated)",
lines.len() - max_lines
));
result
}
pub fn proc_token_budget(text: &str, max_tokens: usize) -> String {
let current = estimate_tokens(text);
if current <= max_tokens {
return text.to_string();
}
let ratio = max_tokens as f64 / current as f64;
let target_chars = (text.len() as f64 * ratio) as usize;
let mut result = text[..target_chars.min(text.len())].to_string();
if let Some(pos) = result.rfind('\n') {
result.truncate(pos);
}
let truncated_tokens = estimate_tokens(&result);
result.push_str(&format!(
"\n... (truncated to ~{} tokens from {})",
truncated_tokens, current
));
result
}
pub fn proc_dedup_blank(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut prev_blank = false;
for line in text.lines() {
if line.trim().is_empty() {
if !prev_blank {
result.push('\n');
prev_blank = true;
}
} else {
result.push_str(line);
result.push('\n');
prev_blank = false;
}
}
result
}
pub fn apply_builtin(name: &str, text: &str, level: Level, param: Option<usize>) -> Option<String> {
match name {
"strip-ansi" => Some(proc_strip_ansi(text)),
"truncate" => {
let limit = param.unwrap_or_else(|| level.head_limit(200));
Some(proc_truncate(text, limit))
}
"head" => {
let limit = param.unwrap_or_else(|| level.head_limit(40));
Some(proc_truncate(text, limit))
}
"token-budget" => {
let budget = param.unwrap_or_else(|| match level {
Level::Lite => 2000,
Level::Full => 1000,
Level::Ultra => 500,
});
Some(proc_token_budget(text, budget))
}
"dedup-blank" => Some(proc_dedup_blank(text)),
"passthrough" => Some(text.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pipeline_single() {
let p = Pipeline::single("git-compact");
assert_eq!(p.len(), 1);
assert_eq!(p.stages[0].name, "git-compact");
assert_eq!(p.display(), "git-compact");
}
#[test]
fn pipeline_from_parts() {
let p = Pipeline::from_parts(
&["strip-ansi".to_string()],
"git-compact",
&["truncate".to_string()],
);
assert_eq!(p.len(), 3);
assert_eq!(p.stages[0].stage_type, StageType::Builtin);
assert_eq!(p.stages[1].stage_type, StageType::Plugin);
assert_eq!(p.stages[2].stage_type, StageType::Builtin);
assert_eq!(p.display(), "strip-ansi → git-compact → truncate");
}
#[test]
fn pipeline_parse() {
let p = Pipeline::parse("strip-ansi, git-compact, truncate");
assert_eq!(p.len(), 3);
assert_eq!(p.stages[0].name, "strip-ansi");
assert_eq!(p.stages[1].name, "git-compact");
assert_eq!(p.stages[2].name, "truncate");
}
#[test]
fn conditional_select_default() {
let cp = ConditionalPipelines {
default: Some(Pipeline::single("git-compact")),
..Default::default()
};
let p = cp.select(0, "some output").unwrap();
assert_eq!(p.stages[0].name, "git-compact");
}
#[test]
fn conditional_select_error() {
let cp = ConditionalPipelines {
default: Some(Pipeline::single("git-compact")),
on_error: Some(Pipeline::parse("strip-ansi,head")),
..Default::default()
};
let p = cp.select(1, "error output").unwrap();
assert_eq!(p.display(), "strip-ansi → head");
let p = cp.select(0, "ok output").unwrap();
assert_eq!(p.display(), "git-compact");
}
#[test]
fn conditional_select_large() {
let cp = ConditionalPipelines {
default: Some(Pipeline::single("git-compact")),
on_large: Some(Pipeline::parse("git-compact,token-budget")),
..Default::default()
};
let large_output = "x".repeat(5000); let p = cp.select(0, &large_output).unwrap();
assert_eq!(p.display(), "git-compact → token-budget");
}
#[test]
fn conditional_select_empty() {
let cp = ConditionalPipelines {
default: Some(Pipeline::single("git-compact")),
on_empty: Some(Pipeline::parse("passthrough")),
..Default::default()
};
let p = cp.select(0, "").unwrap();
assert_eq!(p.display(), "passthrough");
}
#[test]
fn conditional_parse() {
let lines = vec![
("".to_string(), "strip-ansi,git-compact".to_string()),
("error".to_string(), "head".to_string()),
("large".to_string(), "git-compact,token-budget".to_string()),
];
let cp = parse_conditional_pipeline(&lines);
assert!(cp.default.is_some());
assert!(cp.on_error.is_some());
assert!(cp.on_large.is_some());
assert!(cp.on_empty.is_none());
}
#[test]
fn strip_ansi_basic() {
let input = "\x1b[31mERROR\x1b[0m: something failed";
assert_eq!(proc_strip_ansi(input), "ERROR: something failed");
}
#[test]
fn strip_ansi_clean() {
assert_eq!(proc_strip_ansi("no escape codes"), "no escape codes");
}
#[test]
fn truncate_within_limit() {
let input = "line1\nline2\nline3";
assert_eq!(proc_truncate(input, 5), input);
}
#[test]
fn truncate_over_limit() {
let input = "line1\nline2\nline3\nline4\nline5";
let result = proc_truncate(input, 3);
assert!(result.starts_with("line1\nline2\nline3"));
assert!(result.contains("2 lines truncated"));
}
#[test]
fn token_budget_within() {
assert_eq!(proc_token_budget("short", 100), "short");
}
#[test]
fn token_budget_over() {
let input = "a".repeat(400); let result = proc_token_budget(&input, 50);
assert!(result.len() < input.len());
assert!(result.contains("truncated to"));
}
#[test]
fn dedup_blank() {
let input = "line1\n\n\n\nline2\n\nline3";
assert_eq!(proc_dedup_blank(input), "line1\n\nline2\n\nline3\n");
}
#[test]
fn apply_builtin_known() {
assert!(apply_builtin("strip-ansi", "t", Level::Full, None).is_some());
assert!(apply_builtin("truncate", "t", Level::Full, None).is_some());
assert!(apply_builtin("token-budget", "t", Level::Full, None).is_some());
assert!(apply_builtin("dedup-blank", "t", Level::Full, None).is_some());
assert!(apply_builtin("head", "t", Level::Full, None).is_some());
assert!(apply_builtin("passthrough", "t", Level::Full, None).is_some());
}
#[test]
fn apply_builtin_unknown() {
assert!(apply_builtin("git-compact", "t", Level::Full, None).is_none());
}
#[test]
fn parse_parameterized_stages() {
let p = Pipeline::parse("strip-ansi, truncate:100, token-budget:1500");
assert_eq!(p.len(), 3);
assert_eq!(p.stages[0].name, "strip-ansi");
assert_eq!(p.stages[0].param, None);
assert_eq!(p.stages[1].name, "truncate");
assert_eq!(p.stages[1].param, Some(100));
assert_eq!(p.stages[2].name, "token-budget");
assert_eq!(p.stages[2].param, Some(1500));
}
#[test]
fn display_with_params() {
let p = Pipeline::parse("strip-ansi, git-compact, truncate:100");
assert_eq!(p.display(), "strip-ansi → git-compact → truncate:100");
}
#[test]
fn param_overrides_level_default() {
let lines = (0..500).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
let default_result = apply_builtin("truncate", &lines, Level::Full, None).unwrap();
assert!(default_result.contains("truncated"));
let custom_result = apply_builtin("truncate", &lines, Level::Full, Some(50)).unwrap();
assert!(custom_result.contains("truncated"));
assert!(custom_result.lines().count() < default_result.lines().count());
}
}