1use anyhow::Result;
2use lowfat_core::pipeline::{apply_builtin, proc_normalize, Pipeline, StageType};
3use lowfat_plugin::discovery::DiscoveredPlugin;
4use lowfat_plugin::plugin::{FilterInput, FilterPlugin, PluginInfo};
5use lowfat_plugin::security;
6use std::collections::HashMap;
7
8use crate::lf_filter::LfFilter;
9use crate::process::ProcessFilter;
10
11pub struct HybridRunner;
20
21impl HybridRunner {
22 pub fn load(plugin: &DiscoveredPlugin) -> Result<Box<dyn FilterPlugin>> {
23 let manifest = &plugin.manifest;
24 let entry_path = plugin
25 .base_dir
26 .join(manifest.runtime.resolve_entry(&plugin.base_dir));
27
28 let info = PluginInfo {
29 name: manifest.plugin.name.clone(),
30 version: manifest
31 .plugin
32 .version
33 .clone()
34 .unwrap_or_else(|| "0.0.0".to_string()),
35 commands: manifest.plugin.commands.clone(),
36 subcommands: manifest
37 .plugin
38 .subcommands
39 .clone()
40 .unwrap_or_default(),
41 };
42
43 if let Err(e) = security::validate_plugin(manifest, &plugin.base_dir) {
45 anyhow::bail!("security check failed for '{}': {e}", manifest.plugin.name);
46 }
47
48 let is_lf = entry_path
49 .extension()
50 .map(|e| e == "lf")
51 .unwrap_or(false);
52 if is_lf {
53 let filter = LfFilter::load(info, entry_path)?;
54 Ok(Box::new(filter))
55 } else {
56 let filter = ProcessFilter {
57 info,
58 entry: entry_path,
59 base_dir: plugin.base_dir.clone(),
60 };
61 Ok(Box::new(filter))
62 }
63 }
64}
65
66pub fn execute_pipeline(
72 pipeline: &Pipeline,
73 raw: &str,
74 input_template: &FilterInput,
75 plugin_map: &HashMap<String, Box<dyn FilterPlugin>>,
76) -> Result<String> {
77 let mut text = raw.to_string();
78
79 for stage in &pipeline.stages {
80 if let Some(filter) = plugin_map.get(&stage.name) {
83 let mut stage_input = input_template.clone();
84 stage_input.raw = text.clone();
85 match filter.filter(&stage_input) {
86 Ok(out) if !out.passthrough => {
87 text = out.text;
88 }
89 Ok(_) => {}
90 Err(_) => {}
91 }
92 continue;
93 }
94
95 if stage.stage_type == StageType::Builtin {
97 if let Some(processed) = apply_builtin(&stage.name, &text, input_template.level, stage.param, stage.pattern.as_deref()) {
98 text = processed;
99 }
100 }
101 }
103
104 Ok(proc_normalize(&text))
106}
107
108pub fn exec_command(cmd: &str, args: &[String]) -> Result<(String, i32)> {
110 let output = std::process::Command::new(cmd)
111 .args(args)
112 .output()?;
113
114 let exit_code = output.status.code().unwrap_or(1);
115 let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
116 let stderr = String::from_utf8_lossy(&output.stderr);
117 if !stderr.is_empty() {
118 if !combined.is_empty() {
119 combined.push('\n');
120 }
121 combined.push_str(&stderr);
122 }
123
124 Ok((combined, exit_code))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use lowfat_core::level::Level;
131 use lowfat_core::pipeline::Pipeline;
132
133 fn make_input(raw: &str) -> FilterInput {
134 FilterInput {
135 raw: raw.to_string(),
136 command: "test".to_string(),
137 subcommand: String::new(),
138 args: vec![],
139 level: Level::Full,
140 head_limit: 40,
141 exit_code: 0,
142 }
143 }
144
145 #[test]
146 fn execute_builtin_only_pipeline() {
147 let pipeline = Pipeline::parse("strip-ansi | dedup-blank");
148 let raw = "\x1b[31mERROR\x1b[0m\n\n\n\nline2";
149 let input = make_input(raw);
150 let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
151 assert_eq!(result, "ERROR\n\nline2\n"); }
153
154 #[test]
155 fn execute_passthrough_pipeline() {
156 let pipeline = Pipeline::parse("passthrough");
157 let raw = "hello world";
158 let input = make_input(raw);
159 let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
160 assert_eq!(result, "hello world\n"); }
162
163 #[test]
164 fn execute_truncate_pipeline() {
165 let pipeline = Pipeline::parse("head");
166 let raw = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
167 let input = make_input(&raw);
168 let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
169 let line_count = result.lines().count();
171 assert!(line_count <= 41); }
173
174 #[test]
175 fn execute_chain_strip_then_truncate() {
176 let pipeline = Pipeline::parse("strip-ansi | head");
177 let mut raw = String::new();
178 for i in 0..100 {
179 raw.push_str(&format!("\x1b[32mline{i}\x1b[0m\n"));
180 }
181 let input = make_input(&raw);
182 let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
183 assert!(!result.contains("\x1b["));
185 assert!(result.lines().count() <= 41);
186 }
187
188 #[test]
189 fn missing_plugin_skipped() {
190 let pipeline = Pipeline::parse("strip-ansi | nonexistent-plugin | head");
191 let raw = "\x1b[31mhello\x1b[0m\nworld";
192 let input = make_input(raw);
193 let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
195 assert!(result.contains("hello"));
196 assert!(!result.contains("\x1b["));
197 }
198}