Skip to main content

lowfat_runner/
runner.rs

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
11/// Hybrid runner: dispatches to the correct runner based on RuntimeType.
12pub struct HybridRunner;
13
14impl HybridRunner {
15    /// Create a FilterPlugin implementation from a discovered plugin.
16    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        // Security validation — reject unsafe plugins
48        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
75/// Execute a pipeline chain against raw command output.
76/// Chains built-in processors and plugin filters in order.
77///
78/// For built-in stages: runs in-process (zero overhead).
79/// For plugin stages: looks up the plugin by name and delegates.
80pub 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(_) => {} // passthrough — keep text as-is
104                        Err(_) => {} // filter error — keep text as-is
105                    }
106                }
107                // If plugin not found, skip stage (passthrough behavior)
108            }
109        }
110    }
111
112    Ok(text)
113}
114
115/// Execute a command and capture its output.
116pub 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        // Full level head limit for base 40 = 40 lines
177        let line_count = result.lines().count();
178        assert!(line_count <= 41); // 40 lines + truncation message
179    }
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        // Should be ANSI-stripped AND truncated
191        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        // nonexistent-plugin is StageType::Plugin, not in map → skipped
201        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
202        assert!(result.contains("hello"));
203        assert!(!result.contains("\x1b["));
204    }
205}