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, PluginSource};
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 by
12/// source first, then by entry extension:
13/// - [`PluginSource::Embedded`] → always [`LfFilter`] from the in-memory string
14/// - [`PluginSource::Disk`] with `.lf` entry → [`LfFilter`] reading from disk
15/// - [`PluginSource::Disk`] with any other extension → [`ProcessFilter`] via `sh`
16///
17/// The entrypoint for disk plugins is resolved by `RuntimeConfig::resolve_entry`:
18/// an explicit `runtime.entry` in the manifest wins; otherwise auto-detected.
19pub struct HybridRunner;
20
21impl HybridRunner {
22    pub fn load(plugin: &DiscoveredPlugin) -> Result<Box<dyn FilterPlugin>> {
23        let manifest = &plugin.manifest;
24        let info = PluginInfo {
25            name: manifest.plugin.name.clone(),
26            version: manifest
27                .plugin
28                .version
29                .clone()
30                .unwrap_or_else(|| "0.0.0".to_string()),
31            commands: manifest.plugin.commands.clone(),
32            subcommands: manifest
33                .plugin
34                .subcommands
35                .clone()
36                .unwrap_or_default(),
37        };
38
39        match &plugin.source {
40            PluginSource::Embedded { filter_lf } => {
41                let entry = plugin.base_dir().join("filter.lf");
42                let filter = LfFilter::from_source(info, filter_lf, entry)?;
43                Ok(Box::new(filter))
44            }
45            PluginSource::Disk { base_dir } => {
46                let entry_path = base_dir.join(manifest.runtime.resolve_entry(base_dir));
47
48                if let Err(e) = security::validate_plugin(manifest, base_dir) {
49                    anyhow::bail!("security check failed for '{}': {e}", manifest.plugin.name);
50                }
51
52                let is_lf = entry_path
53                    .extension()
54                    .map(|e| e == "lf")
55                    .unwrap_or(false);
56                if is_lf {
57                    let filter = LfFilter::load(info, entry_path)?;
58                    Ok(Box::new(filter))
59                } else {
60                    let filter = ProcessFilter {
61                        info,
62                        entry: entry_path,
63                        base_dir: base_dir.clone(),
64                    };
65                    Ok(Box::new(filter))
66                }
67            }
68        }
69    }
70}
71
72/// Execute a pipeline chain against raw command output.
73/// Chains built-in processors and plugin filters in order.
74///
75/// For built-in stages: runs in-process (zero overhead).
76/// For plugin stages: looks up the plugin by name and delegates.
77pub fn execute_pipeline(
78    pipeline: &Pipeline,
79    raw: &str,
80    input_template: &FilterInput,
81    plugin_map: &HashMap<String, Box<dyn FilterPlugin>>,
82) -> Result<String> {
83    let mut text = raw.to_string();
84
85    for stage in &pipeline.stages {
86        // Plugin override: if a plugin exists with the same name as a builtin, plugin wins.
87        // This lets users replace any built-in processor with their own implementation.
88        if let Some(filter) = plugin_map.get(&stage.name) {
89            let mut stage_input = input_template.clone();
90            stage_input.raw = text.clone();
91            match filter.filter(&stage_input) {
92                Ok(out) if !out.passthrough => {
93                    text = out.text;
94                }
95                Ok(_) => {}
96                Err(_) => {}
97            }
98            continue;
99        }
100
101        // Fall back to built-in processor
102        if stage.stage_type == StageType::Builtin {
103            if let Some(processed) = apply_builtin(&stage.name, &text, input_template.level, stage.param, stage.pattern.as_deref()) {
104                text = processed;
105            }
106        }
107        // Unknown plugin not in map → skip (passthrough)
108    }
109
110    // Final cleanup: trim trailing whitespace, collapse blank lines
111    Ok(proc_normalize(&text))
112}
113
114/// Execute a command and capture its output.
115pub fn exec_command(cmd: &str, args: &[String]) -> Result<(String, i32)> {
116    let output = std::process::Command::new(cmd)
117        .args(args)
118        .output()?;
119
120    let exit_code = output.status.code().unwrap_or(1);
121    let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
122    let stderr = String::from_utf8_lossy(&output.stderr);
123    if !stderr.is_empty() {
124        if !combined.is_empty() {
125            combined.push('\n');
126        }
127        combined.push_str(&stderr);
128    }
129
130    Ok((combined, exit_code))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use lowfat_core::level::Level;
137    use lowfat_core::pipeline::Pipeline;
138
139    fn make_input(raw: &str) -> FilterInput {
140        FilterInput {
141            raw: raw.to_string(),
142            command: "test".to_string(),
143            subcommand: String::new(),
144            args: vec![],
145            level: Level::Full,
146            head_limit: 40,
147            exit_code: 0,
148        }
149    }
150
151    #[test]
152    fn execute_builtin_only_pipeline() {
153        let pipeline = Pipeline::parse("strip-ansi | dedup-blank");
154        let raw = "\x1b[31mERROR\x1b[0m\n\n\n\nline2";
155        let input = make_input(raw);
156        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
157        assert_eq!(result, "ERROR\n\nline2\n");  // normalize collapses blanks + trims
158    }
159
160    #[test]
161    fn execute_passthrough_pipeline() {
162        let pipeline = Pipeline::parse("passthrough");
163        let raw = "hello world";
164        let input = make_input(raw);
165        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
166        assert_eq!(result, "hello world\n");  // normalize ensures trailing newline
167    }
168
169    #[test]
170    fn execute_truncate_pipeline() {
171        let pipeline = Pipeline::parse("head");
172        let raw = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
173        let input = make_input(&raw);
174        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
175        // Full level head limit for base 40 = 40 lines
176        let line_count = result.lines().count();
177        assert!(line_count <= 41); // 40 lines + truncation message
178    }
179
180    #[test]
181    fn execute_chain_strip_then_truncate() {
182        let pipeline = Pipeline::parse("strip-ansi | head");
183        let mut raw = String::new();
184        for i in 0..100 {
185            raw.push_str(&format!("\x1b[32mline{i}\x1b[0m\n"));
186        }
187        let input = make_input(&raw);
188        let result = execute_pipeline(&pipeline, &raw, &input, &HashMap::new()).unwrap();
189        // Should be ANSI-stripped AND truncated
190        assert!(!result.contains("\x1b["));
191        assert!(result.lines().count() <= 41);
192    }
193
194    #[test]
195    fn missing_plugin_skipped() {
196        let pipeline = Pipeline::parse("strip-ansi | nonexistent-plugin | head");
197        let raw = "\x1b[31mhello\x1b[0m\nworld";
198        let input = make_input(raw);
199        // nonexistent-plugin is StageType::Plugin, not in map → skipped
200        let result = execute_pipeline(&pipeline, raw, &input, &HashMap::new()).unwrap();
201        assert!(result.contains("hello"));
202        assert!(!result.contains("\x1b["));
203    }
204}