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::process::ProcessFilter;
9
10/// Loads a discovered plugin into a runnable ProcessFilter.
11pub 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        // Security validation
34        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
47/// Execute a pipeline chain against raw command output.
48/// Chains built-in processors and plugin filters in order.
49///
50/// For built-in stages: runs in-process (zero overhead).
51/// For plugin stages: looks up the plugin by name and delegates.
52pub 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        // Plugin override: if a plugin exists with the same name as a builtin, plugin wins.
62        // This lets users replace any built-in processor with their own implementation.
63        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        // Fall back to built-in processor
77        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        // Unknown plugin not in map → skip (passthrough)
83    }
84
85    // Final cleanup: trim trailing whitespace, collapse blank lines
86    Ok(proc_normalize(&text))
87}
88
89/// Execute a command and capture its output.
90pub fn exec_command(cmd: &str, args: &[String]) -> Result<(String, i32)> {
91    let output = std::process::Command::new(cmd)
92        .args(args)
93        .output()?;
94
95    let exit_code = output.status.code().unwrap_or(1);
96    let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
97    let stderr = String::from_utf8_lossy(&output.stderr);
98    if !stderr.is_empty() {
99        if !combined.is_empty() {
100            combined.push('\n');
101        }
102        combined.push_str(&stderr);
103    }
104
105    Ok((combined, exit_code))
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use lowfat_core::level::Level;
112    use lowfat_core::pipeline::Pipeline;
113
114    fn make_input(raw: &str) -> FilterInput {
115        FilterInput {
116            raw: raw.to_string(),
117            command: "test".to_string(),
118            subcommand: String::new(),
119            args: vec![],
120            level: Level::Full,
121            head_limit: 40,
122            exit_code: 0,
123        }
124    }
125
126    #[test]
127    fn execute_builtin_only_pipeline() {
128        let pipeline = Pipeline::parse("strip-ansi | dedup-blank");
129        let raw = "\x1b[31mERROR\x1b[0m\n\n\n\nline2";
130        let input = make_input(raw);
131        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
132        assert_eq!(result, "ERROR\n\nline2\n");  // normalize collapses blanks + trims
133    }
134
135    #[test]
136    fn execute_passthrough_pipeline() {
137        let pipeline = Pipeline::parse("passthrough");
138        let raw = "hello world";
139        let input = make_input(raw);
140        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
141        assert_eq!(result, "hello world\n");  // normalize ensures trailing newline
142    }
143
144    #[test]
145    fn execute_truncate_pipeline() {
146        let pipeline = Pipeline::parse("head");
147        let raw = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
148        let input = make_input(&raw);
149        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
150        // Full level head limit for base 40 = 40 lines
151        let line_count = result.lines().count();
152        assert!(line_count <= 41); // 40 lines + truncation message
153    }
154
155    #[test]
156    fn execute_chain_strip_then_truncate() {
157        let pipeline = Pipeline::parse("strip-ansi | head");
158        let mut raw = String::new();
159        for i in 0..100 {
160            raw.push_str(&format!("\x1b[32mline{i}\x1b[0m\n"));
161        }
162        let input = make_input(&raw);
163        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
164        // Should be ANSI-stripped AND truncated
165        assert!(!result.contains("\x1b["));
166        assert!(result.lines().count() <= 41);
167    }
168
169    #[test]
170    fn missing_plugin_skipped() {
171        let pipeline = Pipeline::parse("strip-ansi | nonexistent-plugin | head");
172        let raw = "\x1b[31mhello\x1b[0m\nworld";
173        let input = make_input(raw);
174        // nonexistent-plugin is StageType::Plugin, not in map → skipped
175        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
176        assert!(result.contains("hello"));
177        assert!(!result.contains("\x1b["));
178    }
179}