axum_apcore/engine/
extensions.rs1use apcore::module::ModuleAnnotations;
6
7use crate::config::ApcoreSettings;
8use crate::errors::AxumApcoreError;
9
10const MAX_MODULE_ID_LENGTH: usize = 256;
12
13const RESERVED_WORDS: &[&str] = &["__init__", "__main__", "apcore", "system"];
15
16pub struct AxumDiscoverer {
20 settings: ApcoreSettings,
21}
22
23impl AxumDiscoverer {
24 pub fn new(settings: ApcoreSettings) -> Self {
25 Self { settings }
26 }
27
28 pub fn discover(&self) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
30 let mut modules = Vec::new();
31
32 let module_dir = &self.settings.module_dir;
33 if !module_dir.exists() {
34 tracing::debug!(
35 path = %module_dir.display(),
36 "Module directory does not exist, skipping discovery"
37 );
38 return Ok(modules);
39 }
40
41 let pattern = module_dir.join(&self.settings.binding_pattern);
42 let pattern_str = pattern.to_string_lossy();
43
44 let entries = glob_binding_files(&pattern_str);
45 for path in entries {
46 match load_binding_file(&path) {
47 Ok(mut discovered) => modules.append(&mut discovered),
48 Err(e) => {
49 tracing::warn!(path = %path, error = %e, "Failed to load binding file");
50 }
51 }
52 }
53
54 tracing::info!(count = modules.len(), "Discovered modules from bindings");
55 Ok(modules)
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct DiscoveredModule {
62 pub module_id: String,
63 pub target: String,
64 pub description: String,
65 pub input_schema: serde_json::Value,
66 pub output_schema: serde_json::Value,
67 pub tags: Vec<String>,
68 pub annotations: ModuleAnnotations,
69}
70
71pub struct AxumModuleValidator;
73
74impl AxumModuleValidator {
75 pub fn new() -> Self {
76 Self
77 }
78
79 pub fn validate(&self, module_id: &str) -> Vec<String> {
81 let mut errors = Vec::new();
82
83 if module_id.is_empty() {
84 errors.push("Module ID cannot be empty".into());
85 return errors;
86 }
87
88 if module_id.len() > MAX_MODULE_ID_LENGTH {
89 errors.push(format!(
90 "Module ID '{}' exceeds maximum length of {}",
91 module_id, MAX_MODULE_ID_LENGTH
92 ));
93 }
94
95 if RESERVED_WORDS.contains(&module_id) {
96 errors.push(format!("Module ID '{}' is a reserved word", module_id));
97 }
98
99 for segment in module_id.split('.') {
101 if segment.is_empty() {
102 errors.push(format!(
103 "Module ID '{}' contains empty segment (double dot)",
104 module_id
105 ));
106 }
107 }
108
109 errors
110 }
111}
112
113impl Default for AxumModuleValidator {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119fn glob_binding_files(pattern: &str) -> Vec<String> {
121 let dir = std::path::Path::new(pattern)
123 .parent()
124 .unwrap_or(std::path::Path::new("."));
125
126 let extension_pattern = std::path::Path::new(pattern)
127 .file_name()
128 .map(|f| f.to_string_lossy().to_string())
129 .unwrap_or_default();
130
131 let suffix = extension_pattern
132 .strip_prefix('*')
133 .unwrap_or(&extension_pattern);
134
135 let mut results = Vec::new();
136 if let Ok(entries) = std::fs::read_dir(dir) {
137 for entry in entries.flatten() {
138 let name = entry.file_name().to_string_lossy().to_string();
139 if name.ends_with(suffix) {
140 results.push(entry.path().to_string_lossy().to_string());
141 }
142 }
143 }
144 results
145}
146
147fn load_binding_file(path: &str) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
149 let content = std::fs::read_to_string(path)
150 .map_err(|e| AxumApcoreError::Config(format!("Failed to read {}: {}", path, e)))?;
151
152 let value: serde_json::Value = serde_yaml::from_str(&content)
153 .map_err(|e| AxumApcoreError::Config(format!("Failed to parse {}: {}", path, e)))?;
154
155 let modules_value = value
156 .get("modules")
157 .and_then(|v| v.as_array())
158 .ok_or_else(|| {
159 AxumApcoreError::Config(format!("No 'modules' array in binding file: {}", path))
160 })?;
161
162 let mut modules = Vec::new();
163 for module_val in modules_value {
164 let module_id = module_val
165 .get("module_id")
166 .and_then(|v| v.as_str())
167 .unwrap_or("")
168 .to_string();
169
170 let target = module_val
171 .get("target")
172 .and_then(|v| v.as_str())
173 .unwrap_or("")
174 .to_string();
175
176 let description = module_val
177 .get("description")
178 .and_then(|v| v.as_str())
179 .unwrap_or("")
180 .to_string();
181
182 let input_schema = module_val
183 .get("input_schema")
184 .cloned()
185 .unwrap_or(serde_json::json!({"type": "object"}));
186
187 let output_schema = module_val
188 .get("output_schema")
189 .cloned()
190 .unwrap_or(serde_json::json!({"type": "object"}));
191
192 let tags = module_val
193 .get("tags")
194 .and_then(|v| v.as_array())
195 .map(|arr| {
196 arr.iter()
197 .filter_map(|v| v.as_str())
198 .map(String::from)
199 .collect()
200 })
201 .unwrap_or_default();
202
203 modules.push(DiscoveredModule {
204 module_id,
205 target,
206 description,
207 input_schema,
208 output_schema,
209 tags,
210 annotations: ModuleAnnotations::default(),
211 });
212 }
213
214 Ok(modules)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_validator_valid_id() {
223 let v = AxumModuleValidator::new();
224 assert!(v.validate("users.get_user.get").is_empty());
225 }
226
227 #[test]
228 fn test_validator_empty_id() {
229 let v = AxumModuleValidator::new();
230 let errors = v.validate("");
231 assert!(!errors.is_empty());
232 assert!(errors[0].contains("empty"));
233 }
234
235 #[test]
236 fn test_validator_reserved_word() {
237 let v = AxumModuleValidator::new();
238 let errors = v.validate("apcore");
239 assert!(!errors.is_empty());
240 assert!(errors[0].contains("reserved"));
241 }
242
243 #[test]
244 fn test_validator_too_long() {
245 let v = AxumModuleValidator::new();
246 let long_id = "a".repeat(300);
247 let errors = v.validate(&long_id);
248 assert!(!errors.is_empty());
249 assert!(errors[0].contains("exceeds"));
250 }
251
252 #[test]
253 fn test_validator_double_dot() {
254 let v = AxumModuleValidator::new();
255 let errors = v.validate("users..get");
256 assert!(!errors.is_empty());
257 assert!(errors[0].contains("empty segment"));
258 }
259
260 #[test]
261 fn test_load_binding_file_missing() {
262 let result = load_binding_file("/nonexistent/file.yaml");
263 assert!(result.is_err());
264 }
265}