Skip to main content

linesmith_plugin/
discovery.rs

1//! Plugin file discovery. Scans the configured `plugin_dirs` plus the
2//! default `$XDG_CONFIG_HOME/linesmith/segments/` (falling back to
3//! `~/.config/linesmith/segments/`) for `.rhai` files, returning an
4//! ordered deduplicated path list.
5//!
6//! Discovery order per `docs/specs/plugin-api.md` §Plugin file
7//! location: config-declared directories first (in list order), then
8//! the XDG default. Dedupe is by canonical absolute path — symlinks
9//! to the same file appear once — but not by plugin id. Id-dedup
10//! lands in the registry step when the script is compiled and its
11//! `const ID` is known.
12//!
13//! Missing directories are treated as empty (not an error); the spec
14//! §Edge cases row for "Plugin dir doesn't exist" says the dir is
15//! silently skipped and `linesmith doctor` handles the hint.
16
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20/// Walk `config_dirs` followed by the XDG default, returning every
21/// `.rhai` file in discovery order with duplicates (by canonical
22/// path) removed. Non-recursive: files in subdirectories are not
23/// picked up.
24#[must_use]
25pub fn scan_plugin_dirs(config_dirs: &[PathBuf]) -> Vec<PathBuf> {
26    scan_dirs(config_dirs, xdg_segments_dir().as_deref())
27}
28
29/// Pure-logic helper used by [`scan_plugin_dirs`], by the plugin
30/// registry's test-isolated load path, and by tests. Kept separate
31/// from the env-reading public API so callers (and tests) can pin
32/// `xdg_dir` to a specific value without mutating process env (which
33/// is racy under the default parallel test runner).
34pub(crate) fn scan_dirs(config_dirs: &[PathBuf], xdg_dir: Option<&Path>) -> Vec<PathBuf> {
35    let mut ordered: Vec<&Path> = config_dirs.iter().map(PathBuf::as_path).collect();
36    if let Some(p) = xdg_dir {
37        ordered.push(p);
38    }
39
40    let mut results = Vec::new();
41    let mut seen: HashSet<PathBuf> = HashSet::new();
42    for dir in ordered {
43        for path in list_rhai_files(dir) {
44            let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
45            if seen.insert(key) {
46                results.push(path);
47            }
48        }
49    }
50    results
51}
52
53/// `$XDG_CONFIG_HOME/linesmith/segments/` if `XDG_CONFIG_HOME` is set
54/// and non-empty, otherwise `$HOME/.config/linesmith/segments/`.
55/// Returns `None` when neither env var is usable (CI sandbox with no
56/// home).
57fn xdg_segments_dir() -> Option<PathBuf> {
58    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
59        if !xdg.is_empty() {
60            return Some(PathBuf::from(xdg).join("linesmith/segments"));
61        }
62    }
63    let home = std::env::var("HOME").ok().filter(|h| !h.is_empty())?;
64    Some(PathBuf::from(home).join(".config/linesmith/segments"))
65}
66
67/// Non-recursive listing of `.rhai` files in `dir`. Missing directory
68/// or read error returns empty. Result is sorted by path for
69/// deterministic ordering within a directory.
70fn list_rhai_files(dir: &Path) -> Vec<PathBuf> {
71    let Ok(entries) = std::fs::read_dir(dir) else {
72        return Vec::new();
73    };
74    let mut paths: Vec<PathBuf> = entries
75        .flatten()
76        .map(|e| e.path())
77        .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "rhai"))
78        .collect();
79    paths.sort();
80    paths
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::fs;
87    use tempfile::TempDir;
88
89    /// Write an empty `.rhai` file at `dir/name` and return the path.
90    fn touch_rhai(dir: &Path, name: &str) -> PathBuf {
91        let path = dir.join(name);
92        fs::write(&path, "fn render(ctx) {}\n").expect("write plugin fixture");
93        path
94    }
95
96    // Tests target the pure `scan_dirs` helper so they don't have
97    // to mutate `XDG_CONFIG_HOME` (process-wide env mutation is racy
98    // under the default parallel test runner).
99
100    #[test]
101    fn missing_directory_returns_empty() {
102        let nonexistent = PathBuf::from("/does/not/exist/path/for/linesmith/test");
103        assert!(scan_dirs(&[nonexistent], None).is_empty());
104    }
105
106    #[test]
107    fn empty_directory_returns_empty() {
108        let tmp = TempDir::new().expect("tempdir");
109        assert!(scan_dirs(&[tmp.path().to_path_buf()], None).is_empty());
110    }
111
112    #[test]
113    fn finds_rhai_files_in_single_dir_sorted() {
114        let tmp = TempDir::new().expect("tempdir");
115        let b = touch_rhai(tmp.path(), "b.rhai");
116        let a = touch_rhai(tmp.path(), "a.rhai");
117        let found = scan_dirs(&[tmp.path().to_path_buf()], None);
118        assert_eq!(found, vec![a, b]);
119    }
120
121    #[test]
122    fn ignores_non_rhai_extensions() {
123        let tmp = TempDir::new().expect("tempdir");
124        fs::write(tmp.path().join("note.txt"), "not rhai").expect("write txt");
125        fs::write(tmp.path().join("script.rs"), "fn main(){}").expect("write rs");
126        let plugin = touch_rhai(tmp.path(), "real.rhai");
127        assert_eq!(scan_dirs(&[tmp.path().to_path_buf()], None), vec![plugin]);
128    }
129
130    #[test]
131    fn ignores_subdirectories_non_recursive() {
132        let tmp = TempDir::new().expect("tempdir");
133        let subdir = tmp.path().join("sub");
134        fs::create_dir(&subdir).expect("mkdir");
135        touch_rhai(&subdir, "nested.rhai");
136        let top = touch_rhai(tmp.path(), "top.rhai");
137        assert_eq!(scan_dirs(&[tmp.path().to_path_buf()], None), vec![top]);
138    }
139
140    #[test]
141    fn config_dirs_scanned_before_xdg_default() {
142        // Config dir and XDG dir each have a distinct plugin. Config
143        // entry must appear first in the returned order.
144        let cfg_dir = TempDir::new().expect("tempdir");
145        let xdg_dir = TempDir::new().expect("tempdir");
146        let cfg_plugin = touch_rhai(cfg_dir.path(), "from_config.rhai");
147        let xdg_plugin = touch_rhai(xdg_dir.path(), "from_xdg.rhai");
148
149        let found = scan_dirs(&[cfg_dir.path().to_path_buf()], Some(xdg_dir.path()));
150        assert_eq!(found, vec![cfg_plugin, xdg_plugin]);
151    }
152
153    #[test]
154    fn duplicate_path_across_dirs_deduped_by_canonical() {
155        // Put the same dir in config_dirs twice; the plugin inside
156        // should only appear once.
157        let tmp = TempDir::new().expect("tempdir");
158        let plugin = touch_rhai(tmp.path(), "shared.rhai");
159        let dir = tmp.path().to_path_buf();
160        let found = scan_dirs(&[dir.clone(), dir], None);
161        assert_eq!(found, vec![plugin]);
162    }
163
164    #[test]
165    fn same_plugin_in_config_and_xdg_keeps_config_copy() {
166        // Config-declared dir wins over XDG default for a duplicate
167        // canonical path (the first occurrence in discovery order).
168        let tmp = TempDir::new().expect("tempdir");
169        let plugin = touch_rhai(tmp.path(), "shared.rhai");
170        let found = scan_dirs(&[tmp.path().to_path_buf()], Some(tmp.path()));
171        assert_eq!(found, vec![plugin]);
172    }
173
174    #[test]
175    fn multiple_config_dirs_preserve_declaration_order() {
176        let a_dir = TempDir::new().expect("tempdir");
177        let b_dir = TempDir::new().expect("tempdir");
178        let a_plugin = touch_rhai(a_dir.path(), "from_a.rhai");
179        let b_plugin = touch_rhai(b_dir.path(), "from_b.rhai");
180        let found = scan_dirs(
181            &[a_dir.path().to_path_buf(), b_dir.path().to_path_buf()],
182            None,
183        );
184        assert_eq!(found, vec![a_plugin, b_plugin]);
185    }
186
187    #[test]
188    fn missing_xdg_dir_silently_skipped() {
189        let cfg_dir = TempDir::new().expect("tempdir");
190        let plugin = touch_rhai(cfg_dir.path(), "only.rhai");
191        let missing = PathBuf::from("/nonexistent/xdg/path");
192        let found = scan_dirs(&[cfg_dir.path().to_path_buf()], Some(&missing));
193        assert_eq!(found, vec![plugin]);
194    }
195}