Skip to main content

lowfat_plugin/
discovery.rs

1use crate::embedded::{EmbeddedPlugin, EMBEDDED};
2use crate::manifest::PluginManifest;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Where a plugin's `filter.lf` lives. Disk-backed plugins are user-installed
8/// under `~/.lowfat/plugins/`; embedded plugins are baked into the binary at
9/// compile time (see [`crate::embedded`]).
10#[derive(Debug)]
11pub enum PluginSource {
12    Disk { base_dir: PathBuf },
13    Embedded { filter_lf: &'static str },
14}
15
16impl PluginSource {
17    /// Path to use for resolving relative paths, logs, etc. Embedded plugins
18    /// get a synthetic `<embedded>/<category>/<name>` placeholder so callers
19    /// that just want to print a location have something readable.
20    pub fn display_path(&self, category: &str, name: &str) -> PathBuf {
21        match self {
22            PluginSource::Disk { base_dir } => base_dir.clone(),
23            PluginSource::Embedded { .. } => {
24                PathBuf::from(format!("<embedded>/{category}/{name}"))
25            }
26        }
27    }
28
29    pub fn is_embedded(&self) -> bool {
30        matches!(self, PluginSource::Embedded { .. })
31    }
32}
33
34/// A discovered plugin with its manifest and source.
35#[derive(Debug)]
36pub struct DiscoveredPlugin {
37    pub manifest: PluginManifest,
38    pub category: String,
39    pub source: PluginSource,
40}
41
42impl DiscoveredPlugin {
43    /// Path to the plugin's root directory (for disk-backed plugins) or a
44    /// synthetic `<embedded>/...` placeholder. Use for display only.
45    pub fn base_dir(&self) -> PathBuf {
46        self.source.display_path(&self.category, &self.manifest.plugin.name)
47    }
48
49    pub fn is_embedded(&self) -> bool {
50        self.source.is_embedded()
51    }
52}
53
54/// Discover plugins from `plugin_dir` (`~/.lowfat/plugins/` by default) merged
55/// with the embedded set baked into the binary. Disk plugins win on name
56/// collision — a user-installed `git-compact` shadows the bundled one.
57///
58/// Directory structure for disk plugins:
59///   plugin_dir/category/plugin-name/lowfat.toml (or init.toml)
60pub fn discover_plugins(plugin_dir: &Path) -> Vec<DiscoveredPlugin> {
61    let mut plugins = Vec::new();
62    scan_plugin_dir(plugin_dir, &mut plugins);
63
64    // Append embedded plugins whose names aren't already taken by a disk
65    // plugin. Disk wins so the user can override a bundled `git-compact`
66    // by writing one to ~/.lowfat/plugins/git/git-compact/.
67    let taken: std::collections::HashSet<String> = plugins
68        .iter()
69        .map(|p| p.manifest.plugin.name.clone())
70        .collect();
71    for emb in EMBEDDED {
72        if taken.contains(emb.name) {
73            continue;
74        }
75        if let Some(plugin) = build_embedded(emb) {
76            plugins.push(plugin);
77        }
78    }
79    plugins
80}
81
82fn build_embedded(emb: &'static EmbeddedPlugin) -> Option<DiscoveredPlugin> {
83    let manifest = match PluginManifest::parse(emb.manifest) {
84        Ok(m) => m,
85        Err(e) => {
86            eprintln!(
87                "[lowfat] internal: embedded plugin {} has invalid manifest: {e}",
88                emb.name
89            );
90            return None;
91        }
92    };
93    Some(DiscoveredPlugin {
94        manifest,
95        category: emb.category.into(),
96        source: PluginSource::Embedded {
97            filter_lf: emb.filter_lf,
98        },
99    })
100}
101
102fn scan_plugin_dir(dir: &Path, plugins: &mut Vec<DiscoveredPlugin>) {
103    let entries = match fs::read_dir(dir) {
104        Ok(e) => e,
105        Err(_) => return,
106    };
107
108    for category_entry in entries.flatten() {
109        let category_path = category_entry.path();
110        if !category_path.is_dir() {
111            continue;
112        }
113        let category = category_entry
114            .file_name()
115            .to_string_lossy()
116            .to_string();
117
118        let plugin_entries = match fs::read_dir(&category_path) {
119            Ok(e) => e,
120            Err(_) => continue,
121        };
122
123        for plugin_entry in plugin_entries.flatten() {
124            let plugin_path = plugin_entry.path();
125
126            // Try lowfat.toml first, then init.toml for backwards compat
127            let manifest_path = if plugin_path.join("lowfat.toml").is_file() {
128                plugin_path.join("lowfat.toml")
129            } else if plugin_path.join("init.toml").is_file() {
130                plugin_path.join("init.toml")
131            } else {
132                continue;
133            };
134
135            let content = match fs::read_to_string(&manifest_path) {
136                Ok(c) => c,
137                Err(_) => continue,
138            };
139
140            let manifest = match PluginManifest::parse(&content) {
141                Ok(m) => m,
142                Err(e) => {
143                    eprintln!(
144                        "[lowfat] warning: invalid manifest at {}: {}",
145                        manifest_path.display(),
146                        e
147                    );
148                    continue;
149                }
150            };
151
152            plugins.push(DiscoveredPlugin {
153                manifest,
154                category: category.clone(),
155                source: PluginSource::Disk { base_dir: plugin_path },
156            });
157            break;
158        }
159    }
160}
161
162/// Build a command → plugin mapping. If multiple plugins claim the same command,
163/// the last one wins.
164pub fn resolve_plugins(plugins: &[DiscoveredPlugin]) -> HashMap<String, usize> {
165    let mut map = HashMap::new();
166    for (idx, plugin) in plugins.iter().enumerate() {
167        for cmd in &plugin.manifest.plugin.commands {
168            map.insert(cmd.clone(), idx);
169        }
170    }
171    map
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    // The embedded set must survive discovery: every baked-in manifest parses
179    // and its filter.lf is valid lf. `include_str!` only proves the files were
180    // found at build time, not that their contents are well-formed.
181    #[test]
182    fn embedded_plugins_discover_and_parse() {
183        // A nonexistent dir yields only the embedded set (no disk plugins).
184        let found = discover_plugins(Path::new("/nonexistent-lowfat-test-dir"));
185        let embedded: Vec<_> = found.iter().filter(|p| p.is_embedded()).collect();
186        assert_eq!(embedded.len(), EMBEDDED.len(), "all embedded plugins discovered");
187
188        for p in &embedded {
189            if let PluginSource::Embedded { filter_lf } = &p.source {
190                assert!(
191                    lowfat_core::lf::parse(filter_lf).is_ok(),
192                    "{}: embedded filter.lf failed to parse",
193                    p.manifest.plugin.name
194                );
195            }
196        }
197    }
198}