linesmith_plugin/
discovery.rs1use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20#[must_use]
25pub fn scan_plugin_dirs(config_dirs: &[PathBuf]) -> Vec<PathBuf> {
26 scan_dirs(config_dirs, xdg_segments_dir().as_deref())
27}
28
29pub(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
53fn 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
67fn 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 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 #[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 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 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 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}