Skip to main content

lowfat_runner/
runner.rs

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
11/// Loads a discovered plugin into a runnable [`FilterPlugin`]. Dispatches
12/// on the entry's file extension: `.lf` → in-process [`LfFilter`], any
13/// other extension → external [`ProcessFilter`] via `sh`.
14///
15/// When both `filter.lf` and `filter.sh` exist in the plugin dir, the
16/// manifest's `runtime.entry` decides — defaulting to `filter.sh` for
17/// backward compatibility. New plugins should set `entry = "filter.lf"`.
18pub 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        // Security validation
41        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
63/// Execute a pipeline chain against raw command output.
64/// Chains built-in processors and plugin filters in order.
65///
66/// For built-in stages: runs in-process (zero overhead).
67/// For plugin stages: looks up the plugin by name and delegates.
68pub 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        // Plugin override: if a plugin exists with the same name as a builtin, plugin wins.
78        // This lets users replace any built-in processor with their own implementation.
79        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        // Fall back to built-in processor
93        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        // Unknown plugin not in map → skip (passthrough)
99    }
100
101    // Final cleanup: trim trailing whitespace, collapse blank lines
102    Ok(proc_normalize(&text))
103}
104
105/// Execute a command and capture its output.
106pub 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");  // normalize collapses blanks + trims
149    }
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");  // normalize ensures trailing newline
158    }
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        // Full level head limit for base 40 = 40 lines
167        let line_count = result.lines().count();
168        assert!(line_count <= 41); // 40 lines + truncation message
169    }
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        // Should be ANSI-stripped AND truncated
181        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        // nonexistent-plugin is StageType::Plugin, not in map → skipped
191        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
192        assert!(result.contains("hello"));
193        assert!(!result.contains("\x1b["));
194    }
195}