apcore_cli/
fs_discoverer.rs1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use async_trait::async_trait;
9
10use apcore::errors::ModuleError;
11use apcore::module::ModuleAnnotations;
12use apcore::registry::registry::{DiscoveredModule, Discoverer, ModuleDescriptor};
13
14#[derive(Debug, serde::Deserialize)]
19struct ModuleJson {
20 name: String,
21 #[serde(default)]
22 #[allow(dead_code)]
23 description: String,
24 #[serde(default)]
25 tags: Vec<String>,
26 #[serde(default = "default_schema")]
27 input_schema: serde_json::Value,
28 #[serde(default = "default_schema")]
29 output_schema: serde_json::Value,
30 #[serde(default)]
32 executable: Option<String>,
33}
34
35fn default_schema() -> serde_json::Value {
36 serde_json::json!({})
37}
38
39pub struct FsDiscoverer {
44 root: PathBuf,
45 executables: std::sync::Mutex<HashMap<String, PathBuf>>,
47}
48
49impl FsDiscoverer {
50 pub fn new(root: impl Into<PathBuf>) -> Self {
52 Self {
53 root: root.into(),
54 executables: std::sync::Mutex::new(HashMap::new()),
55 }
56 }
57
58 pub fn get_executable(&self, module_name: &str) -> Option<PathBuf> {
60 match self.executables.lock() {
61 Ok(map) => map.get(module_name).cloned(),
62 Err(_poisoned) => {
63 tracing::warn!("Executables mutex poisoned — returning None for '{module_name}'");
64 None
65 }
66 }
67 }
68
69 pub fn executables_snapshot(&self) -> HashMap<String, PathBuf> {
71 self.executables
72 .lock()
73 .map(|map| map.clone())
74 .unwrap_or_default()
75 }
76
77 pub fn load_descriptions(&self) -> std::collections::HashMap<String, String> {
82 let paths = Self::collect_module_jsons(&self.root);
83 let mut map = std::collections::HashMap::new();
84 for path in paths {
85 if let Ok(content) = std::fs::read_to_string(&path) {
86 if let Ok(mj) = serde_json::from_str::<ModuleJson>(&content) {
87 if !mj.description.is_empty() {
88 map.insert(mj.name, mj.description);
89 }
90 }
91 }
92 }
93 map
94 }
95
96 fn collect_module_jsons(dir: &Path) -> Vec<PathBuf> {
98 let mut result = Vec::new();
99 let entries = match std::fs::read_dir(dir) {
100 Ok(e) => e,
101 Err(_) => return result,
102 };
103 for entry in entries.flatten() {
104 let path = entry.path();
105 if path.is_dir() {
106 result.extend(Self::collect_module_jsons(&path));
107 } else if path.file_name().and_then(|n| n.to_str()) == Some("module.json") {
108 result.push(path);
109 }
110 }
111 result
112 }
113}
114
115#[async_trait]
116impl Discoverer for FsDiscoverer {
117 async fn discover(&self) -> Result<Vec<DiscoveredModule>, ModuleError> {
118 let paths = Self::collect_module_jsons(&self.root);
119 let mut modules = Vec::new();
120
121 for path in paths {
122 let content = std::fs::read_to_string(&path).map_err(|e| {
123 ModuleError::new(
124 apcore::errors::ErrorCode::ModuleLoadError,
125 format!("Failed to read {}: {}", path.display(), e),
126 )
127 })?;
128
129 let mj: ModuleJson = serde_json::from_str(&content).map_err(|e| {
130 ModuleError::new(
131 apcore::errors::ErrorCode::ModuleLoadError,
132 format!("Failed to parse {}: {}", path.display(), e),
133 )
134 })?;
135
136 if let Some(ref exec_rel) = mj.executable {
139 if let Some(parent) = path.parent() {
140 let exec_path = parent.join(exec_rel);
141 if exec_path.exists() {
142 let safe = match (exec_path.canonicalize(), self.root.canonicalize()) {
144 (Ok(exec_canon), Ok(root_canon)) => exec_canon.starts_with(&root_canon),
145 _ => false,
146 };
147 if safe {
148 if let Ok(mut map) = self.executables.lock() {
149 map.insert(mj.name.clone(), exec_path);
150 }
151 } else {
152 tracing::warn!(
153 "Executable '{}' for module '{}' escapes extensions root — skipped",
154 exec_path.display(),
155 mj.name
156 );
157 }
158 }
159 }
160 }
161
162 let descriptor = ModuleDescriptor {
163 name: mj.name.clone(),
164 annotations: ModuleAnnotations::default(),
165 input_schema: mj.input_schema,
166 output_schema: mj.output_schema,
167 enabled: true,
168 tags: mj.tags,
169 dependencies: vec![],
170 };
171
172 modules.push(DiscoveredModule {
173 name: mj.name,
174 source: path.display().to_string(),
175 descriptor,
176 });
177 }
178
179 Ok(modules)
180 }
181}
182
183#[cfg(test)]
188mod tests {
189 use super::*;
190 use std::fs;
191 use tempfile::TempDir;
192
193 fn write_module_json(dir: &Path, name: &str, description: &str, tags: &[&str]) {
194 let tags_json: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t)).collect();
195 let content = format!(
196 r#"{{
197 "name": "{}",
198 "description": "{}",
199 "tags": [{}],
200 "input_schema": {{"type": "object"}},
201 "output_schema": {{"type": "object"}}
202}}"#,
203 name,
204 description,
205 tags_json.join(", ")
206 );
207 fs::create_dir_all(dir).unwrap();
208 fs::write(dir.join("module.json"), content).unwrap();
209 }
210
211 #[tokio::test]
212 async fn test_discover_finds_modules() {
213 let tmp = TempDir::new().unwrap();
214 let root = tmp.path();
215
216 write_module_json(&root.join("math/add"), "math.add", "Add numbers", &["math"]);
217 write_module_json(
218 &root.join("text/upper"),
219 "text.upper",
220 "Uppercase text",
221 &["text"],
222 );
223
224 let discoverer = FsDiscoverer::new(root);
225 let modules = discoverer.discover().await.unwrap();
226 assert_eq!(modules.len(), 2);
227
228 let names: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
229 assert!(names.contains(&"math.add"));
230 assert!(names.contains(&"text.upper"));
231 }
232
233 #[tokio::test]
234 async fn test_discover_empty_dir() {
235 let tmp = TempDir::new().unwrap();
236 let discoverer = FsDiscoverer::new(tmp.path());
237 let modules = discoverer.discover().await.unwrap();
238 assert!(modules.is_empty());
239 }
240
241 #[tokio::test]
242 async fn test_discover_nonexistent_dir() {
243 let discoverer = FsDiscoverer::new("/nonexistent/path/xxx");
244 let modules = discoverer.discover().await.unwrap();
245 assert!(modules.is_empty());
246 }
247
248 #[tokio::test]
249 async fn test_discover_invalid_json_returns_error() {
250 let tmp = TempDir::new().unwrap();
251 let dir = tmp.path().join("bad");
252 fs::create_dir_all(&dir).unwrap();
253 fs::write(dir.join("module.json"), "not valid json").unwrap();
254
255 let discoverer = FsDiscoverer::new(tmp.path());
256 let result = discoverer.discover().await;
257 assert!(result.is_err());
258 }
259
260 #[tokio::test]
261 async fn test_discover_sets_descriptor_fields() {
262 let tmp = TempDir::new().unwrap();
263 write_module_json(
264 &tmp.path().join("a"),
265 "test.mod",
266 "A test module",
267 &["demo", "test"],
268 );
269
270 let discoverer = FsDiscoverer::new(tmp.path());
271 let modules = discoverer.discover().await.unwrap();
272 assert_eq!(modules.len(), 1);
273
274 let m = &modules[0];
275 assert_eq!(m.name, "test.mod");
276 assert_eq!(m.descriptor.name, "test.mod");
277 assert!(m.descriptor.enabled);
278 assert_eq!(m.descriptor.tags, vec!["demo", "test"]);
279 assert!(m.descriptor.dependencies.is_empty());
280 }
281
282 #[tokio::test]
283 async fn test_discover_and_register_populates_registry() {
284 let tmp = TempDir::new().unwrap();
285 write_module_json(
286 &tmp.path().join("math/add"),
287 "math.add",
288 "Add numbers",
289 &["math"],
290 );
291
292 let discoverer = FsDiscoverer::new(tmp.path());
293 let mut registry = apcore::Registry::new();
294 let names = registry.discover(&discoverer).await.unwrap();
295
296 assert_eq!(names, vec!["math.add"]);
297 assert!(registry.get_definition("math.add").is_some());
298 }
299}