use apcore::module::ModuleAnnotations;
use crate::config::ApcoreSettings;
use crate::errors::AxumApcoreError;
const MAX_MODULE_ID_LENGTH: usize = 256;
const RESERVED_WORDS: &[&str] = &["__init__", "__main__", "apcore", "system"];
pub struct AxumDiscoverer {
settings: ApcoreSettings,
}
impl AxumDiscoverer {
pub fn new(settings: ApcoreSettings) -> Self {
Self { settings }
}
pub fn discover(&self) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
let mut modules = Vec::new();
let module_dir = &self.settings.module_dir;
if !module_dir.exists() {
tracing::debug!(
path = %module_dir.display(),
"Module directory does not exist, skipping discovery"
);
return Ok(modules);
}
let pattern = module_dir.join(&self.settings.binding_pattern);
let pattern_str = pattern.to_string_lossy();
let entries = glob_binding_files(&pattern_str);
for path in entries {
match load_binding_file(&path) {
Ok(mut discovered) => modules.append(&mut discovered),
Err(e) => {
tracing::warn!(path = %path, error = %e, "Failed to load binding file");
}
}
}
tracing::info!(count = modules.len(), "Discovered modules from bindings");
Ok(modules)
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredModule {
pub module_id: String,
pub target: String,
pub description: String,
pub input_schema: serde_json::Value,
pub output_schema: serde_json::Value,
pub tags: Vec<String>,
pub annotations: ModuleAnnotations,
}
pub struct AxumModuleValidator;
impl AxumModuleValidator {
pub fn new() -> Self {
Self
}
pub fn validate(&self, module_id: &str) -> Vec<String> {
let mut errors = Vec::new();
if module_id.is_empty() {
errors.push("Module ID cannot be empty".into());
return errors;
}
if module_id.len() > MAX_MODULE_ID_LENGTH {
errors.push(format!(
"Module ID '{}' exceeds maximum length of {}",
module_id, MAX_MODULE_ID_LENGTH
));
}
if RESERVED_WORDS.contains(&module_id) {
errors.push(format!("Module ID '{}' is a reserved word", module_id));
}
for segment in module_id.split('.') {
if segment.is_empty() {
errors.push(format!(
"Module ID '{}' contains empty segment (double dot)",
module_id
));
}
}
errors
}
}
impl Default for AxumModuleValidator {
fn default() -> Self {
Self::new()
}
}
fn glob_binding_files(pattern: &str) -> Vec<String> {
let dir = std::path::Path::new(pattern)
.parent()
.unwrap_or(std::path::Path::new("."));
let extension_pattern = std::path::Path::new(pattern)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let suffix = extension_pattern
.strip_prefix('*')
.unwrap_or(&extension_pattern);
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(suffix) {
results.push(entry.path().to_string_lossy().to_string());
}
}
}
results
}
fn load_binding_file(path: &str) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
let content = std::fs::read_to_string(path)
.map_err(|e| AxumApcoreError::Config(format!("Failed to read {}: {}", path, e)))?;
let value: serde_json::Value = serde_yaml::from_str(&content)
.map_err(|e| AxumApcoreError::Config(format!("Failed to parse {}: {}", path, e)))?;
let modules_value = value
.get("modules")
.and_then(|v| v.as_array())
.ok_or_else(|| {
AxumApcoreError::Config(format!("No 'modules' array in binding file: {}", path))
})?;
let mut modules = Vec::new();
for module_val in modules_value {
let module_id = module_val
.get("module_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let target = module_val
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = module_val
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input_schema = module_val
.get("input_schema")
.cloned()
.unwrap_or(serde_json::json!({"type": "object"}));
let output_schema = module_val
.get("output_schema")
.cloned()
.unwrap_or(serde_json::json!({"type": "object"}));
let tags = module_val
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
modules.push(DiscoveredModule {
module_id,
target,
description,
input_schema,
output_schema,
tags,
annotations: ModuleAnnotations::default(),
});
}
Ok(modules)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validator_valid_id() {
let v = AxumModuleValidator::new();
assert!(v.validate("users.get_user.get").is_empty());
}
#[test]
fn test_validator_empty_id() {
let v = AxumModuleValidator::new();
let errors = v.validate("");
assert!(!errors.is_empty());
assert!(errors[0].contains("empty"));
}
#[test]
fn test_validator_reserved_word() {
let v = AxumModuleValidator::new();
let errors = v.validate("apcore");
assert!(!errors.is_empty());
assert!(errors[0].contains("reserved"));
}
#[test]
fn test_validator_too_long() {
let v = AxumModuleValidator::new();
let long_id = "a".repeat(300);
let errors = v.validate(&long_id);
assert!(!errors.is_empty());
assert!(errors[0].contains("exceeds"));
}
#[test]
fn test_validator_double_dot() {
let v = AxumModuleValidator::new();
let errors = v.validate("users..get");
assert!(!errors.is_empty());
assert!(errors[0].contains("empty segment"));
}
#[test]
fn test_load_binding_file_missing() {
let result = load_binding_file("/nonexistent/file.yaml");
assert!(result.is_err());
}
}