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