Skip to main content

apcore_cli/
fs_discoverer.rs

1// apcore-cli -- Filesystem-based module discoverer.
2// Scans a directory recursively for module.json descriptor files and
3// produces DiscoveredModule entries for registration in the apcore Registry.
4
5use 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/// Intermediate struct for deserializing module.json files.
15///
16/// Fields that are optional in the JSON map to defaults suitable for
17/// constructing a full `ModuleDescriptor`.
18#[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    /// Optional relative path to an executable script (e.g. "run.sh").
31    #[serde(default)]
32    executable: Option<String>,
33}
34
35fn default_schema() -> serde_json::Value {
36    serde_json::json!({})
37}
38
39/// Filesystem-based module discoverer.
40///
41/// Recursively walks `root` looking for files named `module.json`, parses each
42/// one into a `DiscoveredModule`, and returns them all from `discover()`.
43pub struct FsDiscoverer {
44    root: PathBuf,
45    /// Map of module name to resolved executable path (built during discovery).
46    executables: std::sync::Mutex<HashMap<String, PathBuf>>,
47}
48
49impl FsDiscoverer {
50    /// Create a new discoverer rooted at the given directory path.
51    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    /// Return the resolved executable path for a module, if one was declared.
59    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    /// Return a snapshot of all executable paths discovered so far.
70    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    /// Scan the extensions directory and return a map of module name to description.
78    ///
79    /// This is a convenience method for populating description metadata that
80    /// `ModuleDescriptor` does not carry. Non-parseable files are silently skipped.
81    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    /// Recursively collect all `module.json` paths under `dir`.
97    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            // Resolve executable path relative to module.json directory.
137            // Security: validate the resolved path stays within the extensions root.
138            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                        // Canonicalize both paths to prevent traversal via ../../
143                        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// ---------------------------------------------------------------------------
184// Unit tests
185// ---------------------------------------------------------------------------
186
187#[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}