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