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