use std::path::PathBuf;
use crate::autoload::AddonAutoload;
use crate::error::{AddonLoaderError, AddonLoaderResult};
use crate::registry::AddonRegistry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddonRoute {
pub addon: String,
pub controller: String,
pub action: String,
pub controller_class: String,
pub controller_file: Option<PathBuf>,
}
impl AddonRoute {
pub fn new(
addon: impl Into<String>,
controller: impl Into<String>,
action: impl Into<String>,
) -> Self {
let addon = addon.into();
let controller = controller.into();
let action = action.into();
let controller_class = build_controller_class(&addon, &controller);
Self {
addon,
controller,
action,
controller_class,
controller_file: None,
}
}
pub fn controller_file(&self) -> Option<&std::path::Path> {
self.controller_file.as_deref()
}
}
#[tracing::instrument(skip(registry, autoload))]
pub fn parse_route(
url: &str,
registry: &AddonRegistry,
autoload: &AddonAutoload,
) -> AddonLoaderResult<AddonRoute> {
let (addon, controller, action) =
parse_url_segments(url).ok_or_else(|| AddonLoaderError::RouteParse {
url: url.to_string(),
reason: "URL must be /addons/<addon>/<controller>/<action>".to_string(),
})?;
if addon.is_empty() || controller.is_empty() || action.is_empty() {
return Err(AddonLoaderError::RouteParse {
url: url.to_string(),
reason: "addon, controller, action cannot be empty".to_string(),
});
}
let manifest = registry.get(&addon)?;
if !manifest.is_enabled() {
return Err(AddonLoaderError::AddonDisabled(addon));
}
let mut route = AddonRoute::new(addon.clone(), controller, action);
let file_path = autoload.resolve_controller(&addon, &route.controller)?;
if file_path.is_none() {
return Err(AddonLoaderError::ControllerNotFound(
route.controller.clone(),
));
}
route.controller_file = file_path;
Ok(route)
}
fn parse_url_segments(url: &str) -> Option<(String, String, String)> {
let url = url.trim_start_matches('/');
if !url.starts_with("addons/") {
return None;
}
let rest = &url["addons/".len()..];
let parts: Vec<&str> = rest.split('/').collect();
let addon = parts.first().unwrap_or(&"").to_string();
let controller = parts.get(1).unwrap_or(&"").to_string();
let action = parts.get(2).unwrap_or(&"").to_string();
Some((addon, controller, action))
}
fn build_controller_class(addon: &str, controller: &str) -> String {
let resolved = parse_dotted_controller(controller);
format!("addons\\{}\\controller\\{}", addon, resolved)
}
fn parse_dotted_controller(controller: &str) -> String {
if !controller.contains('.') {
return controller.to_string();
}
let mut parts: Vec<&str> = controller.split('.').collect();
if parts.len() == 1 {
return controller.to_string();
}
let last = parts
.pop()
.expect("已通过 contains('.') 与 len 检查保证 parts 非空");
let last_studly = studly_case(last);
parts.push(&last_studly);
parts.join("\\")
}
fn studly_case(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn make_test_env() -> (tempfile::TempDir, AddonRegistry, AddonAutoload) {
let tmp = tempfile::tempdir().expect("create tempdir");
let addons_path = tmp.path().join("addons");
let operate_dir = addons_path.join("operate");
fs::create_dir_all(&operate_dir).unwrap();
fs::write(
operate_dir.join("Plugin.php"),
r#"
public $info = [
'name' => 'operate',
'status' => 1,
];
"#,
)
.unwrap();
let controller_dir = operate_dir.join("controller");
fs::create_dir_all(&controller_dir).unwrap();
fs::write(controller_dir.join("Order.php"), "<?php // stub").unwrap();
let admin_dir = controller_dir.join("admin");
fs::create_dir_all(&admin_dir).unwrap();
fs::write(admin_dir.join("Order.php"), "<?php // stub").unwrap();
let disabled_dir = addons_path.join("disabled");
fs::create_dir_all(&disabled_dir).unwrap();
fs::write(
disabled_dir.join("Plugin.php"),
r#"
public $info = [
'name' => 'disabled',
'status' => 0,
];
"#,
)
.unwrap();
let nonexistent_dir = addons_path.join("nonexistent");
fs::create_dir_all(&nonexistent_dir).unwrap();
fs::write(
nonexistent_dir.join("Plugin.php"),
r#"
public $info = [
'name' => 'nonexistent',
'status' => 1,
];
"#,
)
.unwrap();
let registry = AddonRegistry::new();
let _ = registry.load_from_directory(&addons_path).unwrap();
let autoload = AddonAutoload::new(&addons_path);
(tmp, registry, autoload)
}
#[test]
fn test_addon_route_new_simple() {
let route = AddonRoute::new("operate", "Order", "index");
assert_eq!(route.addon, "operate");
assert_eq!(route.controller, "Order");
assert_eq!(route.action, "index");
assert_eq!(route.controller_class, "addons\\operate\\controller\\Order");
assert!(route.controller_file.is_none());
}
#[test]
fn test_addon_route_new_dotted_controller() {
let route = AddonRoute::new("operate", "admin.Order", "index");
assert_eq!(route.controller, "admin.Order");
assert_eq!(
route.controller_class,
"addons\\operate\\controller\\admin\\Order"
);
}
#[test]
fn test_addon_route_new_three_level_dotted() {
let route = AddonRoute::new("operate", "admin.sub.Order", "index");
assert_eq!(
route.controller_class,
"addons\\operate\\controller\\admin\\sub\\Order"
);
}
#[test]
fn test_addon_route_controller_file_none() {
let route = AddonRoute::new("a", "b", "c");
assert!(route.controller_file().is_none());
}
#[test]
fn test_parse_url_segments_full_url() {
let result = parse_url_segments("/addons/operate/Order/index");
assert_eq!(
result,
Some((
"operate".to_string(),
"Order".to_string(),
"index".to_string()
))
);
}
#[test]
fn test_parse_url_segments_no_leading_slash() {
let result = parse_url_segments("addons/operate/Order/index");
assert_eq!(
result,
Some((
"operate".to_string(),
"Order".to_string(),
"index".to_string()
))
);
}
#[test]
fn test_parse_url_segments_missing_action() {
let result = parse_url_segments("/addons/operate/Order");
assert_eq!(
result,
Some(("operate".to_string(), "Order".to_string(), "".to_string()))
);
}
#[test]
fn test_parse_url_segments_missing_controller_and_action() {
let result = parse_url_segments("/addons/operate");
assert_eq!(
result,
Some(("operate".to_string(), "".to_string(), "".to_string()))
);
}
#[test]
fn test_parse_url_segments_dotted_controller() {
let result = parse_url_segments("/addons/operate/admin.Order/index");
assert_eq!(
result,
Some((
"operate".to_string(),
"admin.Order".to_string(),
"index".to_string()
))
);
}
#[test]
fn test_parse_url_segments_non_addons_url() {
let result = parse_url_segments("/api/users");
assert_eq!(result, None);
}
#[test]
fn test_parse_url_segments_empty_url() {
let result = parse_url_segments("");
assert_eq!(result, None);
}
#[test]
fn test_parse_url_segments_only_addons() {
let result = parse_url_segments("/addons/");
assert_eq!(
result,
Some(("".to_string(), "".to_string(), "".to_string()))
);
}
#[test]
fn test_parse_url_segments_trailing_slash() {
let result = parse_url_segments("/addons/operate/Order/index/");
assert_eq!(
result,
Some((
"operate".to_string(),
"Order".to_string(),
"index".to_string()
))
);
}
#[test]
fn test_build_controller_class_simple() {
let class = build_controller_class("operate", "Order");
assert_eq!(class, "addons\\operate\\controller\\Order");
}
#[test]
fn test_build_controller_class_dotted() {
let class = build_controller_class("operate", "admin.Order");
assert_eq!(class, "addons\\operate\\controller\\admin\\Order");
}
#[test]
fn test_build_controller_class_three_levels() {
let class = build_controller_class("operate", "admin.sub.Order");
assert_eq!(class, "addons\\operate\\controller\\admin\\sub\\Order");
}
#[test]
fn test_parse_route_valid() {
let (_tmp, registry, autoload) = make_test_env();
let route = parse_route("/addons/operate/Order/index", ®istry, &autoload).unwrap();
assert_eq!(route.addon, "operate");
assert_eq!(route.controller, "Order");
assert_eq!(route.action, "index");
assert!(route.controller_file.is_some());
}
#[test]
fn test_parse_route_dotted_controller() {
let (_tmp, registry, autoload) = make_test_env();
let route = parse_route("/addons/operate/admin.Order/index", ®istry, &autoload).unwrap();
assert_eq!(route.controller, "admin.Order");
assert_eq!(
route.controller_class,
"addons\\operate\\controller\\admin\\Order"
);
assert!(route.controller_file.is_some());
assert!(route
.controller_file
.unwrap()
.to_string_lossy()
.contains("admin"));
}
#[test]
fn test_parse_route_non_addons_url() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/api/users", ®istry, &autoload);
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::RouteParse { .. } => {}
other => panic!("expected RouteParse, got {:?}", other),
}
}
#[test]
fn test_parse_route_empty_action() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/addons/operate/Order", ®istry, &autoload);
assert!(result.is_err());
}
#[test]
fn test_parse_route_empty_controller() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/addons/operate", ®istry, &autoload);
assert!(result.is_err());
}
#[test]
fn test_parse_route_addon_not_found() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/addons/ghost/Order/index", ®istry, &autoload);
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::AddonNotFound(name) => assert_eq!(name, "ghost"),
other => panic!("expected AddonNotFound, got {:?}", other),
}
}
#[test]
fn test_parse_route_addon_disabled() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/addons/disabled/Order/index", ®istry, &autoload);
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::AddonDisabled(name) => assert_eq!(name, "disabled"),
other => panic!("expected AddonDisabled, got {:?}", other),
}
}
#[test]
fn test_parse_route_controller_not_found() {
let (_tmp, registry, autoload) = make_test_env();
let result = parse_route("/addons/nonexistent/Ghost/index", ®istry, &autoload);
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::ControllerNotFound(name) => assert_eq!(name, "Ghost"),
other => panic!("expected ControllerNotFound, got {:?}", other),
}
}
#[test]
fn test_parse_route_controller_file_resolved() {
let (_tmp, registry, autoload) = make_test_env();
let route = parse_route("/addons/operate/Order/index", ®istry, &autoload).unwrap();
let file = route.controller_file.unwrap();
assert!(file.exists());
assert!(file.to_string_lossy().ends_with("Order.php"));
}
#[test]
fn test_parse_route_multilevel_controller_file_resolved() {
let (_tmp, registry, autoload) = make_test_env();
let route = parse_route("/addons/operate/admin.Order/index", ®istry, &autoload).unwrap();
let file = route.controller_file.unwrap();
assert!(file.exists());
assert!(file.to_string_lossy().contains("admin"));
assert!(file.to_string_lossy().ends_with("Order.php"));
}
#[test]
fn test_parse_route_no_leading_slash() {
let (_tmp, registry, autoload) = make_test_env();
let route = parse_route("addons/operate/Order/index", ®istry, &autoload).unwrap();
assert_eq!(route.addon, "operate");
}
#[test]
fn test_addon_route_clone_eq() {
let r1 = AddonRoute::new("a", "b", "c");
let r2 = r1.clone();
assert_eq!(r1, r2);
}
#[test]
fn test_addon_route_with_controller_file() {
let mut route = AddonRoute::new("a", "b", "c");
route.controller_file = Some(PathBuf::from("/addons/a/controller/B.php"));
assert_eq!(
route.controller_file(),
Some(std::path::Path::new("/addons/a/controller/B.php"))
);
}
#[test]
fn test_route_status_check_reflects_manifest() {
let (_tmp, registry, autoload) = make_test_env();
assert!(registry.is_enabled("operate").unwrap());
assert!(!registry.is_enabled("disabled").unwrap());
registry.set_enabled("disabled", true).unwrap();
assert!(registry.is_enabled("disabled").unwrap());
let result = parse_route("/addons/disabled/Order/index", ®istry, &autoload);
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::ControllerNotFound(_) => {}
AddonLoaderError::AddonDisabled(_) => {
panic!("should be ControllerNotFound after enabling")
}
other => panic!("unexpected error: {:?}", other),
}
}
}