1use crate::signals;
11use std::collections::HashMap;
12use std::error::Error;
13use std::sync::{Arc, Mutex, PoisonError};
14use thiserror::Error as ThisError;
15
16#[derive(Debug, ThisError)]
18pub enum AppError {
19 #[error("Application not found: {0}")]
21 NotFound(String),
22
23 #[error("Application already registered: {0}")]
25 AlreadyRegistered(String),
26
27 #[error("Invalid application label: {0}")]
29 InvalidLabel(String),
30
31 #[error("Duplicate application label: {0}")]
33 DuplicateLabel(String),
34
35 #[error("Duplicate application name: {0}")]
37 DuplicateName(String),
38
39 #[error("Application registry not ready")]
41 NotReady,
42
43 #[error("Application configuration error: {0}")]
45 ConfigError(String),
46
47 #[error("Registry state error: {0}")]
49 RegistryState(String),
50}
51
52pub type AppResult<T> = Result<T, AppError>;
54
55#[derive(Clone, Debug)]
57pub struct AppConfig {
58 pub name: String,
60
61 pub label: String,
63
64 pub verbose_name: Option<String>,
66
67 pub path: Option<String>,
69
70 pub default_auto_field: Option<String>,
72
73 pub models_ready: bool,
75}
76
77impl AppConfig {
78 pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
80 Self {
81 name: name.into(),
82 label: label.into(),
83 verbose_name: None,
84 path: None,
85 default_auto_field: None,
86 models_ready: false,
87 }
88 }
89
90 pub fn with_verbose_name(mut self, verbose_name: impl Into<String>) -> Self {
92 self.verbose_name = Some(verbose_name.into());
93 self
94 }
95
96 pub fn with_path(mut self, path: impl Into<String>) -> AppResult<Self> {
108 let path = path.into();
109 Self::validate_path(&path)?;
110 self.path = Some(path);
111 Ok(self)
112 }
113
114 fn validate_path(path: &str) -> AppResult<()> {
122 if path.is_empty() {
123 return Err(AppError::ConfigError(
124 "application path cannot be empty".to_string(),
125 ));
126 }
127
128 if path.contains('\0') {
130 return Err(AppError::ConfigError(
131 "application path must not contain null bytes".to_string(),
132 ));
133 }
134
135 if path.chars().any(|c| c.is_control()) {
137 return Err(AppError::ConfigError(
138 "application path must not contain control characters".to_string(),
139 ));
140 }
141
142 if path.starts_with('/') || path.starts_with('\\') {
144 return Err(AppError::ConfigError(
145 "application path must be relative, not absolute".to_string(),
146 ));
147 }
148
149 if path.len() >= 2 && path.as_bytes()[0].is_ascii_alphabetic() && path.as_bytes()[1] == b':'
151 {
152 return Err(AppError::ConfigError(
153 "application path must be relative, not absolute".to_string(),
154 ));
155 }
156
157 for component in path.split(['/', '\\']) {
159 if component == ".." {
160 return Err(AppError::ConfigError(
161 "application path must not contain path traversal sequences".to_string(),
162 ));
163 }
164 }
165
166 Ok(())
167 }
168
169 pub fn with_default_auto_field(mut self, field: impl Into<String>) -> Self {
171 self.default_auto_field = Some(field.into());
172 self
173 }
174
175 pub fn validate_label(&self) -> AppResult<()> {
177 if self.label.is_empty() {
178 return Err(AppError::InvalidLabel("Label cannot be empty".to_string()));
179 }
180
181 if !self
183 .label
184 .chars()
185 .next()
186 .map(|c| c.is_alphabetic() || c == '_')
187 .unwrap_or(false)
188 {
189 return Err(AppError::InvalidLabel(format!(
190 "Label '{}' must start with a letter or underscore",
191 self.label
192 )));
193 }
194
195 if !self.label.chars().all(|c| c.is_alphanumeric() || c == '_') {
196 return Err(AppError::InvalidLabel(format!(
197 "Label '{}' must contain only alphanumeric characters and underscores",
198 self.label
199 )));
200 }
201
202 Ok(())
203 }
204
205 pub fn ready(&self) -> Result<(), Box<dyn Error>> {
220 Ok(())
223 }
224}
225
226pub trait StaticFilesProvider {
235 fn static_dir(&self) -> Option<std::path::PathBuf> {
239 None
240 }
241
242 fn static_url_prefix(&self) -> Option<String> {
246 None
247 }
248}
249
250pub trait LocaleProvider {
255 fn locale_dir(&self) -> Option<std::path::PathBuf> {
259 None
260 }
261}
262
263pub trait MediaProvider {
268 fn media_dir(&self) -> Option<std::path::PathBuf> {
272 None
273 }
274
275 fn media_url_prefix(&self) -> Option<String> {
279 None
280 }
281}
282
283impl StaticFilesProvider for AppConfig {
285 fn static_dir(&self) -> Option<std::path::PathBuf> {
286 if let Some(path) = &self.path {
288 let static_path = std::path::PathBuf::from(path).join("static");
289 if static_path.exists() && static_path.is_dir() {
290 return Some(static_path);
291 }
292 }
293 None
294 }
295
296 fn static_url_prefix(&self) -> Option<String> {
297 Some(format!("/static/{}/", self.label))
298 }
299}
300
301impl LocaleProvider for AppConfig {
302 fn locale_dir(&self) -> Option<std::path::PathBuf> {
303 if let Some(path) = &self.path {
305 let locale_path = std::path::PathBuf::from(path).join("locale");
306 if locale_path.exists() && locale_path.is_dir() {
307 return Some(locale_path);
308 }
309 }
310 None
311 }
312}
313
314impl MediaProvider for AppConfig {
315 fn media_dir(&self) -> Option<std::path::PathBuf> {
316 if let Some(path) = &self.path {
318 let media_path = std::path::PathBuf::from(path).join("media");
319 if media_path.exists() && media_path.is_dir() {
320 return Some(media_path);
321 }
322 }
323 None
324 }
325
326 fn media_url_prefix(&self) -> Option<String> {
327 Some(format!("/media/{}/", self.label))
328 }
329}
330
331#[derive(Clone)]
337pub struct Apps {
338 installed_apps: Vec<String>,
340
341 app_configs: Arc<Mutex<HashMap<String, AppConfig>>>,
343
344 app_names: Arc<Mutex<HashMap<String, String>>>,
346
347 ready: Arc<Mutex<bool>>,
349
350 apps_ready: Arc<Mutex<bool>>,
352
353 models_ready: Arc<Mutex<bool>>,
355}
356
357impl Apps {
358 pub fn new(installed_apps: Vec<String>) -> Self {
360 Self {
361 installed_apps,
362 app_configs: Arc::new(Mutex::new(HashMap::new())),
363 app_names: Arc::new(Mutex::new(HashMap::new())),
364 ready: Arc::new(Mutex::new(false)),
365 apps_ready: Arc::new(Mutex::new(false)),
366 models_ready: Arc::new(Mutex::new(false)),
367 }
368 }
369
370 pub fn is_ready(&self) -> bool {
372 *self.ready.lock().unwrap_or_else(PoisonError::into_inner)
373 }
374
375 pub fn is_apps_ready(&self) -> bool {
377 *self
378 .apps_ready
379 .lock()
380 .unwrap_or_else(PoisonError::into_inner)
381 }
382
383 pub fn is_models_ready(&self) -> bool {
385 *self
386 .models_ready
387 .lock()
388 .unwrap_or_else(PoisonError::into_inner)
389 }
390
391 pub fn register(&self, config: AppConfig) -> AppResult<()> {
393 config.validate_label()?;
395
396 let mut configs = self
397 .app_configs
398 .lock()
399 .unwrap_or_else(PoisonError::into_inner);
400 let mut names = self
401 .app_names
402 .lock()
403 .unwrap_or_else(PoisonError::into_inner);
404
405 if configs.contains_key(&config.label) {
407 return Err(AppError::DuplicateLabel(config.label.clone()));
408 }
409
410 if names.contains_key(&config.name) {
412 return Err(AppError::DuplicateName(config.name.clone()));
413 }
414
415 names.insert(config.name.clone(), config.label.clone());
417 configs.insert(config.label.clone(), config);
418
419 Ok(())
420 }
421
422 pub fn get_app_config(&self, label: &str) -> AppResult<AppConfig> {
424 self.app_configs
425 .lock()
426 .unwrap_or_else(PoisonError::into_inner)
427 .get(label)
428 .cloned()
429 .ok_or_else(|| AppError::NotFound(label.to_string()))
430 }
431
432 pub fn get_app_configs(&self) -> Vec<AppConfig> {
434 self.app_configs
435 .lock()
436 .unwrap_or_else(PoisonError::into_inner)
437 .values()
438 .cloned()
439 .collect()
440 }
441
442 pub fn is_installed(&self, name: &str) -> bool {
448 if self.installed_apps.contains(&name.to_string()) {
449 return true;
450 }
451
452 let names = self
454 .app_names
455 .lock()
456 .unwrap_or_else(PoisonError::into_inner);
457 let configs = self
458 .app_configs
459 .lock()
460 .unwrap_or_else(PoisonError::into_inner);
461
462 names.contains_key(name) || configs.contains_key(name)
463 }
464
465 pub fn populate(&self) -> AppResult<()> {
482 *self
484 .apps_ready
485 .lock()
486 .unwrap_or_else(PoisonError::into_inner) = true;
487
488 {
491 let mut seen = std::collections::HashSet::new();
492 for app_name in &self.installed_apps {
493 if !seen.insert(app_name) {
494 return Err(AppError::DuplicateLabel(app_name.clone()));
495 }
496 }
497 }
498
499 for app_name in &self.installed_apps {
500 let app_config = AppConfig::new(app_name.clone(), app_name.clone());
501
502 let mut configs = self
504 .app_configs
505 .lock()
506 .unwrap_or_else(PoisonError::into_inner);
507 if configs.contains_key(&app_config.label) {
508 continue;
509 }
510 configs.insert(app_config.label.clone(), app_config.clone());
511 drop(configs);
512
513 self.app_names
514 .lock()
515 .unwrap_or_else(PoisonError::into_inner)
516 .insert(app_name.clone(), app_config.label.clone());
517 }
518
519 let configs = self
521 .app_configs
522 .lock()
523 .unwrap_or_else(PoisonError::into_inner);
524 for app_config in configs.values() {
525 app_config.ready().map_err(|e| {
527 AppError::ConfigError(format!(
528 "Ready hook failed for app '{}': {}",
529 app_config.label, e
530 ))
531 })?;
532
533 signals::app_ready().send(app_config);
535 }
536 drop(configs); if !*self
544 .models_ready
545 .lock()
546 .unwrap_or_else(PoisonError::into_inner)
547 {
548 crate::discovery::build_reverse_relations()?;
549 crate::registry::finalize_reverse_relations();
551 }
552
553 *self
555 .models_ready
556 .lock()
557 .unwrap_or_else(PoisonError::into_inner) = true;
558 *self.ready.lock().unwrap_or_else(PoisonError::into_inner) = true;
559
560 Ok(())
561 }
562
563 pub fn clear_cache(&self) {
565 self.app_configs
566 .lock()
567 .unwrap_or_else(PoisonError::into_inner)
568 .clear();
569 self.app_names
570 .lock()
571 .unwrap_or_else(PoisonError::into_inner)
572 .clear();
573 *self.ready.lock().unwrap_or_else(PoisonError::into_inner) = false;
574 *self
575 .apps_ready
576 .lock()
577 .unwrap_or_else(PoisonError::into_inner) = false;
578 *self
579 .models_ready
580 .lock()
581 .unwrap_or_else(PoisonError::into_inner) = false;
582 }
583}
584
585#[cfg(feature = "di")]
587mod di_integration {
588 use super::*;
589 use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
590
591 #[async_trait::async_trait]
592 impl Injectable for Apps {
593 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
594 if let Some(apps) = ctx.get_singleton::<Apps>() {
596 return Ok((*apps).clone());
597 }
598
599 Err(DiError::NotFound(std::any::type_name::<Apps>().to_string()))
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use rstest::rstest;
608 use serial_test::serial;
609
610 #[rstest]
611 fn test_app_config_creation() {
612 let config = AppConfig::new("myapp", "myapp")
614 .with_verbose_name("My Application")
615 .with_default_auto_field("BigAutoField");
616
617 assert_eq!(config.name, "myapp");
619 assert_eq!(config.label, "myapp");
620 assert_eq!(config.verbose_name, Some("My Application".to_string()));
621 assert_eq!(config.default_auto_field, Some("BigAutoField".to_string()));
622 }
623
624 #[rstest]
625 fn test_app_config_validation() {
626 let valid = AppConfig::new("myapp", "myapp");
628 let invalid = AppConfig::new("myapp", "my-app");
629 let empty = AppConfig::new("myapp", "");
630
631 assert!(valid.validate_label().is_ok());
633 assert!(invalid.validate_label().is_err());
634 assert!(empty.validate_label().is_err());
635 }
636
637 #[rstest]
638 fn test_apps_registry() {
639 let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
641
642 assert!(apps.is_installed("myapp"));
644 assert!(apps.is_installed("anotherapp"));
645 assert!(!apps.is_installed("notinstalled"));
646 }
647
648 #[rstest]
649 fn test_register_app() {
650 let apps = Apps::new(vec![]);
652 let config = AppConfig::new("myapp", "myapp");
653
654 assert!(apps.register(config).is_ok());
656 assert!(apps.get_app_config("myapp").is_ok());
657 }
658
659 #[rstest]
660 fn test_duplicate_registration() {
661 let apps = Apps::new(vec![]);
663 let config1 = AppConfig::new("myapp", "myapp");
664 let config2 = AppConfig::new("myapp", "myapp");
665 apps.register(config1).unwrap();
666
667 let result = apps.register(config2);
669
670 assert!(result.is_err());
672 }
673
674 #[rstest]
675 fn test_get_app_configs() {
676 let apps = Apps::new(vec![]);
678 apps.register(AppConfig::new("app1", "app1")).unwrap();
679 apps.register(AppConfig::new("app2", "app2")).unwrap();
680
681 let configs = apps.get_app_configs();
683
684 assert_eq!(configs.len(), 2);
686 }
687
688 #[rstest]
689 #[serial(apps_registry)]
690 fn test_populate() {
691 crate::registry::reset_global_registry();
693
694 let apps = Apps::new(vec![]);
696 assert!(!apps.is_ready());
697
698 apps.populate().unwrap();
700
701 assert!(apps.is_ready());
703 assert!(apps.is_apps_ready());
704 assert!(apps.is_models_ready());
705 }
706
707 #[rstest]
708 #[serial(apps_registry)]
709 fn test_populate_with_installed_apps() {
710 crate::registry::reset_global_registry();
712
713 let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
715 assert!(!apps.is_ready());
716
717 let result = apps.populate();
719
720 assert!(result.is_ok());
722 assert!(apps.is_ready());
723 assert!(apps.is_apps_ready());
724 assert!(apps.is_models_ready());
725 assert!(apps.get_app_config("myapp").is_ok());
726 assert!(apps.get_app_config("anotherapp").is_ok());
727 let myapp_config = apps.get_app_config("myapp").unwrap();
728 assert_eq!(myapp_config.label, "myapp");
729 }
730
731 #[rstest]
736 #[case("apps/myapp")]
737 #[case("myapp")]
738 #[case("src/apps/myapp")]
739 #[case("my_app")]
740 #[case("my-app")]
741 fn test_with_path_accepts_valid_relative_paths(#[case] path: &str) {
742 let result = AppConfig::new("myapp", "myapp").with_path(path);
744
745 assert!(result.is_ok(), "expected valid path: {path}");
747 assert_eq!(result.unwrap().path, Some(path.to_string()));
748 }
749
750 #[rstest]
751 fn test_with_path_rejects_empty() {
752 let result = AppConfig::new("myapp", "myapp").with_path("");
754
755 let err = result.unwrap_err();
757 assert!(err.to_string().contains("cannot be empty"));
758 }
759
760 #[rstest]
761 #[case("../etc/passwd")]
762 #[case("apps/../../../etc/shadow")]
763 #[case("apps/..")]
764 fn test_with_path_rejects_traversal(#[case] path: &str) {
765 let result = AppConfig::new("myapp", "myapp").with_path(path);
767
768 let err = result.unwrap_err();
770 assert!(
771 err.to_string().contains("path traversal"),
772 "expected traversal error for '{path}', got: {err}"
773 );
774 }
775
776 #[rstest]
777 #[case("/etc/passwd")]
778 #[case("/absolute/path")]
779 #[case("\\windows\\path")]
780 #[case("C:\\Windows\\System32")]
781 #[case("D:/data")]
782 fn test_with_path_rejects_absolute(#[case] path: &str) {
783 let result = AppConfig::new("myapp", "myapp").with_path(path);
785
786 let err = result.unwrap_err();
788 assert!(
789 err.to_string().contains("relative, not absolute"),
790 "expected absolute path error for '{path}', got: {err}"
791 );
792 }
793
794 #[rstest]
795 fn test_with_path_rejects_null_bytes() {
796 let result = AppConfig::new("myapp", "myapp").with_path("apps/my\0app");
798
799 let err = result.unwrap_err();
801 assert!(err.to_string().contains("null bytes"));
802 }
803
804 #[rstest]
805 #[case("apps/my\napp")]
806 #[case("apps/my\rapp")]
807 fn test_with_path_rejects_control_chars(#[case] path: &str) {
808 let result = AppConfig::new("myapp", "myapp").with_path(path);
810
811 let err = result.unwrap_err();
813 assert!(
814 err.to_string().contains("control characters"),
815 "expected control char error for path, got: {err}"
816 );
817 }
818}
819
820pub trait AppLabel {
873 const LABEL: &'static str;
881
882 fn path(&self) -> &'static str {
888 Self::LABEL
889 }
890}
891
892impl Apps {
893 pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
913 self.get_app_config(A::LABEL)
914 }
915
916 pub fn is_installed_typed<A: AppLabel>(&self) -> bool {
932 self.is_installed(A::LABEL)
933 }
934}
935
936#[cfg(test)]
937mod typed_tests {
938 use super::*;
939
940 struct AuthApp;
942 impl AppLabel for AuthApp {
943 const LABEL: &'static str = "auth";
944 }
945
946 struct ContentTypesApp;
947 impl AppLabel for ContentTypesApp {
948 const LABEL: &'static str = "contenttypes";
949 }
950
951 struct SessionsApp;
952 impl AppLabel for SessionsApp {
953 const LABEL: &'static str = "sessions";
954 }
955
956 #[test]
957 fn test_typed_is_installed() {
958 let apps = Apps::new(vec!["auth".to_string(), "contenttypes".to_string()]);
959
960 assert!(apps.is_installed_typed::<AuthApp>());
961 assert!(apps.is_installed_typed::<ContentTypesApp>());
962 assert!(!apps.is_installed_typed::<SessionsApp>());
963 }
964
965 #[test]
966 fn test_typed_get_app_config() {
967 let apps = Apps::new(vec![]);
968 let config = AppConfig::new("auth", "auth");
969 apps.register(config).unwrap();
970
971 let retrieved = apps.get_app_config_typed::<AuthApp>();
972 assert!(retrieved.is_ok());
973 assert_eq!(retrieved.unwrap().label, "auth");
974 }
975
976 #[test]
977 fn test_typed_get_app_config_not_found() {
978 let apps = Apps::new(vec![]);
979
980 let result = apps.get_app_config_typed::<SessionsApp>();
981 assert!(result.is_err());
982
983 if let Err(AppError::NotFound(label)) = result {
984 assert_eq!(label, "sessions");
985 }
986 }
987
988 #[test]
989 fn test_apps_typed_and_regular_mixed() {
990 let apps = Apps::new(vec!["auth".to_string()]);
991 let config = AppConfig::new("auth", "auth");
992 apps.register(config).unwrap();
993
994 assert!(apps.is_installed_typed::<AuthApp>());
996 assert!(apps.is_installed("auth"));
997
998 let typed = apps.get_app_config_typed::<AuthApp>().unwrap();
999 let regular = apps.get_app_config("auth").unwrap();
1000
1001 assert_eq!(typed.label, regular.label);
1002 }
1003}
1004
1005pub trait BaseCommand: Send + Sync {
1014 fn name(&self) -> &str;
1016
1017 fn help(&self) -> &str;
1019
1020 fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
1022}
1023
1024pub struct AppStaticFilesConfig {
1030 pub app_label: &'static str,
1032 pub static_dir: &'static str,
1034 pub url_prefix: &'static str,
1036}
1037
1038inventory::collect!(AppStaticFilesConfig);
1039
1040pub struct AppLocaleConfig {
1046 pub app_label: &'static str,
1048 pub locale_dir: &'static str,
1050}
1051
1052inventory::collect!(AppLocaleConfig);
1053
1054pub struct AppCommandConfig {
1060 pub app_label: &'static str,
1062 pub command_name: &'static str,
1064 pub command_fn: fn() -> Box<dyn BaseCommand>,
1066}
1067
1068inventory::collect!(AppCommandConfig);
1069
1070pub struct AppMediaConfig {
1076 pub app_label: &'static str,
1078 pub media_dir: &'static str,
1080 pub url_prefix: &'static str,
1082}
1083
1084inventory::collect!(AppMediaConfig);
1085
1086#[macro_export]
1105macro_rules! register_app_static_files {
1106 ($app_label:expr, $static_dir:expr, $url_prefix:expr) => {
1107 $crate::inventory::submit! {
1108 $crate::AppStaticFilesConfig {
1109 app_label: $app_label,
1110 static_dir: $static_dir,
1111 url_prefix: $url_prefix,
1112 }
1113 }
1114 };
1115}
1116
1117#[macro_export]
1131macro_rules! register_app_locale {
1132 ($app_label:expr, $locale_dir:expr) => {
1133 $crate::inventory::submit! {
1134 $crate::AppLocaleConfig {
1135 app_label: $app_label,
1136 locale_dir: $locale_dir,
1137 }
1138 }
1139 };
1140}
1141
1142#[macro_export]
1165macro_rules! register_app_command {
1166 ($app_label:expr, $command_name:expr, $command_fn:expr) => {
1167 $crate::inventory::submit! {
1168 $crate::AppCommandConfig {
1169 app_label: $app_label,
1170 command_name: $command_name,
1171 command_fn: $command_fn,
1172 }
1173 }
1174 };
1175}
1176
1177#[macro_export]
1192macro_rules! register_app_media {
1193 ($app_label:expr, $media_dir:expr, $url_prefix:expr) => {
1194 $crate::inventory::submit! {
1195 $crate::AppMediaConfig {
1196 app_label: $app_label,
1197 media_dir: $media_dir,
1198 url_prefix: $url_prefix,
1199 }
1200 }
1201 };
1202}
1203
1204pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
1224 inventory::iter::<AppStaticFilesConfig>().collect()
1225}
1226
1227pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
1243 inventory::iter::<AppLocaleConfig>().collect()
1244}
1245
1246pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
1262 inventory::iter::<AppCommandConfig>().collect()
1263}
1264
1265pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
1281 inventory::iter::<AppMediaConfig>().collect()
1282}