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