1use std::path::PathBuf;
45
46use crate::autoload::AddonAutoload;
47use crate::error::{AddonLoaderError, AddonLoaderResult};
48use crate::registry::AddonRegistry;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AddonRoute {
55 pub addon: String,
57 pub controller: String,
59 pub action: String,
61 pub controller_class: String,
63 pub controller_file: Option<PathBuf>,
65}
66
67impl AddonRoute {
68 pub fn new(
70 addon: impl Into<String>,
71 controller: impl Into<String>,
72 action: impl Into<String>,
73 ) -> Self {
74 let addon = addon.into();
75 let controller = controller.into();
76 let action = action.into();
77
78 let controller_class = build_controller_class(&addon, &controller);
80
81 Self {
82 addon,
83 controller,
84 action,
85 controller_class,
86 controller_file: None,
87 }
88 }
89
90 pub fn controller_file(&self) -> Option<&std::path::Path> {
92 self.controller_file.as_deref()
93 }
94}
95
96#[tracing::instrument(skip(registry, autoload))]
119pub fn parse_route(
120 url: &str,
121 registry: &AddonRegistry,
122 autoload: &AddonAutoload,
123) -> AddonLoaderResult<AddonRoute> {
124 let (addon, controller, action) =
126 parse_url_segments(url).ok_or_else(|| AddonLoaderError::RouteParse {
127 url: url.to_string(),
128 reason: "URL must be /addons/<addon>/<controller>/<action>".to_string(),
129 })?;
130
131 if addon.is_empty() || controller.is_empty() || action.is_empty() {
133 return Err(AddonLoaderError::RouteParse {
134 url: url.to_string(),
135 reason: "addon, controller, action cannot be empty".to_string(),
136 });
137 }
138
139 let manifest = registry.get(&addon)?;
141
142 if !manifest.is_enabled() {
144 return Err(AddonLoaderError::AddonDisabled(addon));
145 }
146
147 let mut route = AddonRoute::new(addon.clone(), controller, action);
149
150 let file_path = autoload.resolve_controller(&addon, &route.controller)?;
152
153 if file_path.is_none() {
154 return Err(AddonLoaderError::ControllerNotFound(
156 route.controller.clone(),
157 ));
158 }
159
160 route.controller_file = file_path;
161
162 Ok(route)
163}
164
165fn parse_url_segments(url: &str) -> Option<(String, String, String)> {
179 let url = url.trim_start_matches('/');
181
182 if !url.starts_with("addons/") {
184 return None;
185 }
186
187 let rest = &url["addons/".len()..];
188
189 let parts: Vec<&str> = rest.split('/').collect();
191
192 let addon = parts.first().unwrap_or(&"").to_string();
193 let controller = parts.get(1).unwrap_or(&"").to_string();
194 let action = parts.get(2).unwrap_or(&"").to_string();
195
196 Some((addon, controller, action))
197}
198
199fn build_controller_class(addon: &str, controller: &str) -> String {
214 let resolved = parse_dotted_controller(controller);
215 format!("addons\\{}\\controller\\{}", addon, resolved)
216}
217
218fn parse_dotted_controller(controller: &str) -> String {
222 if !controller.contains('.') {
223 return controller.to_string();
224 }
225
226 let mut parts: Vec<&str> = controller.split('.').collect();
227 if parts.len() == 1 {
228 return controller.to_string();
229 }
230
231 let last = parts
232 .pop()
233 .expect("已通过 contains('.') 与 len 检查保证 parts 非空");
234 let last_studly = studly_case(last);
235 parts.push(&last_studly);
236 parts.join("\\")
237}
238
239fn studly_case(s: &str) -> String {
241 s.split('_')
242 .map(|part| {
243 let mut chars = part.chars();
244 match chars.next() {
245 None => String::new(),
246 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
247 }
248 })
249 .collect()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use std::fs;
256 use std::path::PathBuf;
257
258 fn make_test_env() -> (tempfile::TempDir, AddonRegistry, AddonAutoload) {
259 let tmp = tempfile::tempdir().expect("create tempdir");
260 let addons_path = tmp.path().join("addons");
261
262 let operate_dir = addons_path.join("operate");
264 fs::create_dir_all(&operate_dir).unwrap();
265 fs::write(
266 operate_dir.join("Plugin.php"),
267 r#"
268public $info = [
269 'name' => 'operate',
270 'status' => 1,
271];
272"#,
273 )
274 .unwrap();
275
276 let controller_dir = operate_dir.join("controller");
278 fs::create_dir_all(&controller_dir).unwrap();
279 fs::write(controller_dir.join("Order.php"), "<?php // stub").unwrap();
280
281 let admin_dir = controller_dir.join("admin");
283 fs::create_dir_all(&admin_dir).unwrap();
284 fs::write(admin_dir.join("Order.php"), "<?php // stub").unwrap();
285
286 let disabled_dir = addons_path.join("disabled");
288 fs::create_dir_all(&disabled_dir).unwrap();
289 fs::write(
290 disabled_dir.join("Plugin.php"),
291 r#"
292public $info = [
293 'name' => 'disabled',
294 'status' => 0,
295];
296"#,
297 )
298 .unwrap();
299
300 let nonexistent_dir = addons_path.join("nonexistent");
302 fs::create_dir_all(&nonexistent_dir).unwrap();
303 fs::write(
304 nonexistent_dir.join("Plugin.php"),
305 r#"
306public $info = [
307 'name' => 'nonexistent',
308 'status' => 1,
309];
310"#,
311 )
312 .unwrap();
313
314 let registry = AddonRegistry::new();
316 let _ = registry.load_from_directory(&addons_path).unwrap();
317
318 let autoload = AddonAutoload::new(&addons_path);
319
320 (tmp, registry, autoload)
321 }
322
323 #[test]
324 fn test_addon_route_new_simple() {
325 let route = AddonRoute::new("operate", "Order", "index");
326 assert_eq!(route.addon, "operate");
327 assert_eq!(route.controller, "Order");
328 assert_eq!(route.action, "index");
329 assert_eq!(route.controller_class, "addons\\operate\\controller\\Order");
330 assert!(route.controller_file.is_none());
331 }
332
333 #[test]
334 fn test_addon_route_new_dotted_controller() {
335 let route = AddonRoute::new("operate", "admin.Order", "index");
336 assert_eq!(route.controller, "admin.Order");
337 assert_eq!(
338 route.controller_class,
339 "addons\\operate\\controller\\admin\\Order"
340 );
341 }
342
343 #[test]
344 fn test_addon_route_new_three_level_dotted() {
345 let route = AddonRoute::new("operate", "admin.sub.Order", "index");
346 assert_eq!(
347 route.controller_class,
348 "addons\\operate\\controller\\admin\\sub\\Order"
349 );
350 }
351
352 #[test]
353 fn test_addon_route_controller_file_none() {
354 let route = AddonRoute::new("a", "b", "c");
355 assert!(route.controller_file().is_none());
356 }
357
358 #[test]
359 fn test_parse_url_segments_full_url() {
360 let result = parse_url_segments("/addons/operate/Order/index");
361 assert_eq!(
362 result,
363 Some((
364 "operate".to_string(),
365 "Order".to_string(),
366 "index".to_string()
367 ))
368 );
369 }
370
371 #[test]
372 fn test_parse_url_segments_no_leading_slash() {
373 let result = parse_url_segments("addons/operate/Order/index");
374 assert_eq!(
375 result,
376 Some((
377 "operate".to_string(),
378 "Order".to_string(),
379 "index".to_string()
380 ))
381 );
382 }
383
384 #[test]
385 fn test_parse_url_segments_missing_action() {
386 let result = parse_url_segments("/addons/operate/Order");
387 assert_eq!(
388 result,
389 Some(("operate".to_string(), "Order".to_string(), "".to_string()))
390 );
391 }
392
393 #[test]
394 fn test_parse_url_segments_missing_controller_and_action() {
395 let result = parse_url_segments("/addons/operate");
396 assert_eq!(
397 result,
398 Some(("operate".to_string(), "".to_string(), "".to_string()))
399 );
400 }
401
402 #[test]
403 fn test_parse_url_segments_dotted_controller() {
404 let result = parse_url_segments("/addons/operate/admin.Order/index");
405 assert_eq!(
406 result,
407 Some((
408 "operate".to_string(),
409 "admin.Order".to_string(),
410 "index".to_string()
411 ))
412 );
413 }
414
415 #[test]
416 fn test_parse_url_segments_non_addons_url() {
417 let result = parse_url_segments("/api/users");
418 assert_eq!(result, None);
419 }
420
421 #[test]
422 fn test_parse_url_segments_empty_url() {
423 let result = parse_url_segments("");
424 assert_eq!(result, None);
425 }
426
427 #[test]
428 fn test_parse_url_segments_only_addons() {
429 let result = parse_url_segments("/addons/");
430 assert_eq!(
431 result,
432 Some(("".to_string(), "".to_string(), "".to_string()))
433 );
434 }
435
436 #[test]
437 fn test_parse_url_segments_trailing_slash() {
438 let result = parse_url_segments("/addons/operate/Order/index/");
439 assert_eq!(
440 result,
441 Some((
442 "operate".to_string(),
443 "Order".to_string(),
444 "index".to_string()
445 ))
446 );
447 }
448
449 #[test]
450 fn test_build_controller_class_simple() {
451 let class = build_controller_class("operate", "Order");
452 assert_eq!(class, "addons\\operate\\controller\\Order");
453 }
454
455 #[test]
456 fn test_build_controller_class_dotted() {
457 let class = build_controller_class("operate", "admin.Order");
458 assert_eq!(class, "addons\\operate\\controller\\admin\\Order");
459 }
460
461 #[test]
462 fn test_build_controller_class_three_levels() {
463 let class = build_controller_class("operate", "admin.sub.Order");
464 assert_eq!(class, "addons\\operate\\controller\\admin\\sub\\Order");
465 }
466
467 #[test]
468 fn test_parse_route_valid() {
469 let (_tmp, registry, autoload) = make_test_env();
470
471 let route = parse_route("/addons/operate/Order/index", ®istry, &autoload).unwrap();
472 assert_eq!(route.addon, "operate");
473 assert_eq!(route.controller, "Order");
474 assert_eq!(route.action, "index");
475 assert!(route.controller_file.is_some());
476 }
477
478 #[test]
479 fn test_parse_route_dotted_controller() {
480 let (_tmp, registry, autoload) = make_test_env();
481
482 let route = parse_route("/addons/operate/admin.Order/index", ®istry, &autoload).unwrap();
483 assert_eq!(route.controller, "admin.Order");
484 assert_eq!(
485 route.controller_class,
486 "addons\\operate\\controller\\admin\\Order"
487 );
488 assert!(route.controller_file.is_some());
489 assert!(route
490 .controller_file
491 .unwrap()
492 .to_string_lossy()
493 .contains("admin"));
494 }
495
496 #[test]
497 fn test_parse_route_non_addons_url() {
498 let (_tmp, registry, autoload) = make_test_env();
499
500 let result = parse_route("/api/users", ®istry, &autoload);
501 assert!(result.is_err());
502 match result.unwrap_err() {
503 AddonLoaderError::RouteParse { .. } => {}
504 other => panic!("expected RouteParse, got {:?}", other),
505 }
506 }
507
508 #[test]
509 fn test_parse_route_empty_action() {
510 let (_tmp, registry, autoload) = make_test_env();
511
512 let result = parse_route("/addons/operate/Order", ®istry, &autoload);
513 assert!(result.is_err());
514 }
516
517 #[test]
518 fn test_parse_route_empty_controller() {
519 let (_tmp, registry, autoload) = make_test_env();
520
521 let result = parse_route("/addons/operate", ®istry, &autoload);
522 assert!(result.is_err());
523 }
524
525 #[test]
526 fn test_parse_route_addon_not_found() {
527 let (_tmp, registry, autoload) = make_test_env();
528
529 let result = parse_route("/addons/ghost/Order/index", ®istry, &autoload);
530 assert!(result.is_err());
531 match result.unwrap_err() {
532 AddonLoaderError::AddonNotFound(name) => assert_eq!(name, "ghost"),
533 other => panic!("expected AddonNotFound, got {:?}", other),
534 }
535 }
536
537 #[test]
538 fn test_parse_route_addon_disabled() {
539 let (_tmp, registry, autoload) = make_test_env();
540
541 let result = parse_route("/addons/disabled/Order/index", ®istry, &autoload);
542 assert!(result.is_err());
543 match result.unwrap_err() {
544 AddonLoaderError::AddonDisabled(name) => assert_eq!(name, "disabled"),
545 other => panic!("expected AddonDisabled, got {:?}", other),
546 }
547 }
548
549 #[test]
550 fn test_parse_route_controller_not_found() {
551 let (_tmp, registry, autoload) = make_test_env();
552
553 let result = parse_route("/addons/nonexistent/Ghost/index", ®istry, &autoload);
555 assert!(result.is_err());
556 match result.unwrap_err() {
557 AddonLoaderError::ControllerNotFound(name) => assert_eq!(name, "Ghost"),
558 other => panic!("expected ControllerNotFound, got {:?}", other),
559 }
560 }
561
562 #[test]
563 fn test_parse_route_controller_file_resolved() {
564 let (_tmp, registry, autoload) = make_test_env();
565
566 let route = parse_route("/addons/operate/Order/index", ®istry, &autoload).unwrap();
567 let file = route.controller_file.unwrap();
568 assert!(file.exists());
569 assert!(file.to_string_lossy().ends_with("Order.php"));
570 }
571
572 #[test]
573 fn test_parse_route_multilevel_controller_file_resolved() {
574 let (_tmp, registry, autoload) = make_test_env();
575
576 let route = parse_route("/addons/operate/admin.Order/index", ®istry, &autoload).unwrap();
577 let file = route.controller_file.unwrap();
578 assert!(file.exists());
579 assert!(file.to_string_lossy().contains("admin"));
580 assert!(file.to_string_lossy().ends_with("Order.php"));
581 }
582
583 #[test]
584 fn test_parse_route_no_leading_slash() {
585 let (_tmp, registry, autoload) = make_test_env();
586
587 let route = parse_route("addons/operate/Order/index", ®istry, &autoload).unwrap();
588 assert_eq!(route.addon, "operate");
589 }
590
591 #[test]
592 fn test_addon_route_clone_eq() {
593 let r1 = AddonRoute::new("a", "b", "c");
594 let r2 = r1.clone();
595 assert_eq!(r1, r2);
596 }
597
598 #[test]
599 fn test_addon_route_with_controller_file() {
600 let mut route = AddonRoute::new("a", "b", "c");
601 route.controller_file = Some(PathBuf::from("/addons/a/controller/B.php"));
602 assert_eq!(
603 route.controller_file(),
604 Some(std::path::Path::new("/addons/a/controller/B.php"))
605 );
606 }
607
608 #[test]
610 fn test_route_status_check_reflects_manifest() {
611 let (_tmp, registry, autoload) = make_test_env();
612
613 assert!(registry.is_enabled("operate").unwrap());
615
616 assert!(!registry.is_enabled("disabled").unwrap());
618
619 registry.set_enabled("disabled", true).unwrap();
621 assert!(registry.is_enabled("disabled").unwrap());
622
623 let result = parse_route("/addons/disabled/Order/index", ®istry, &autoload);
625 assert!(result.is_err());
626 match result.unwrap_err() {
627 AddonLoaderError::ControllerNotFound(_) => {}
628 AddonLoaderError::AddonDisabled(_) => {
629 panic!("should be ControllerNotFound after enabling")
630 }
631 other => panic!("unexpected error: {:?}", other),
632 }
633 }
634}