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