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 {
840 const LABEL: &'static str;
842}
843
844impl Apps {
845 pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
865 self.get_app_config(A::LABEL)
866 }
867
868 pub fn is_installed_typed<A: AppLabel>(&self) -> bool {
884 self.is_installed(A::LABEL)
885 }
886}
887
888#[cfg(test)]
889mod typed_tests {
890 use super::*;
891
892 struct AuthApp;
894 impl AppLabel for AuthApp {
895 const LABEL: &'static str = "auth";
896 }
897
898 struct ContentTypesApp;
899 impl AppLabel for ContentTypesApp {
900 const LABEL: &'static str = "contenttypes";
901 }
902
903 struct SessionsApp;
904 impl AppLabel for SessionsApp {
905 const LABEL: &'static str = "sessions";
906 }
907
908 #[test]
909 fn test_typed_is_installed() {
910 let apps = Apps::new(vec!["auth".to_string(), "contenttypes".to_string()]);
911
912 assert!(apps.is_installed_typed::<AuthApp>());
913 assert!(apps.is_installed_typed::<ContentTypesApp>());
914 assert!(!apps.is_installed_typed::<SessionsApp>());
915 }
916
917 #[test]
918 fn test_typed_get_app_config() {
919 let apps = Apps::new(vec![]);
920 let config = AppConfig::new("auth", "auth");
921 apps.register(config).unwrap();
922
923 let retrieved = apps.get_app_config_typed::<AuthApp>();
924 assert!(retrieved.is_ok());
925 assert_eq!(retrieved.unwrap().label, "auth");
926 }
927
928 #[test]
929 fn test_typed_get_app_config_not_found() {
930 let apps = Apps::new(vec![]);
931
932 let result = apps.get_app_config_typed::<SessionsApp>();
933 assert!(result.is_err());
934
935 if let Err(AppError::NotFound(label)) = result {
936 assert_eq!(label, "sessions");
937 }
938 }
939
940 #[test]
941 fn test_apps_typed_and_regular_mixed() {
942 let apps = Apps::new(vec!["auth".to_string()]);
943 let config = AppConfig::new("auth", "auth");
944 apps.register(config).unwrap();
945
946 assert!(apps.is_installed_typed::<AuthApp>());
948 assert!(apps.is_installed("auth"));
949
950 let typed = apps.get_app_config_typed::<AuthApp>().unwrap();
951 let regular = apps.get_app_config("auth").unwrap();
952
953 assert_eq!(typed.label, regular.label);
954 }
955}
956
957pub trait BaseCommand: Send + Sync {
966 fn name(&self) -> &str;
968
969 fn help(&self) -> &str;
971
972 fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
974}
975
976pub struct AppStaticFilesConfig {
982 pub app_label: &'static str,
984 pub static_dir: &'static str,
986 pub url_prefix: &'static str,
988}
989
990inventory::collect!(AppStaticFilesConfig);
991
992pub struct AppLocaleConfig {
998 pub app_label: &'static str,
1000 pub locale_dir: &'static str,
1002}
1003
1004inventory::collect!(AppLocaleConfig);
1005
1006pub struct AppCommandConfig {
1012 pub app_label: &'static str,
1014 pub command_name: &'static str,
1016 pub command_fn: fn() -> Box<dyn BaseCommand>,
1018}
1019
1020inventory::collect!(AppCommandConfig);
1021
1022pub struct AppMediaConfig {
1028 pub app_label: &'static str,
1030 pub media_dir: &'static str,
1032 pub url_prefix: &'static str,
1034}
1035
1036inventory::collect!(AppMediaConfig);
1037
1038#[macro_export]
1057macro_rules! register_app_static_files {
1058 ($app_label:expr, $static_dir:expr, $url_prefix:expr) => {
1059 $crate::inventory::submit! {
1060 $crate::AppStaticFilesConfig {
1061 app_label: $app_label,
1062 static_dir: $static_dir,
1063 url_prefix: $url_prefix,
1064 }
1065 }
1066 };
1067}
1068
1069#[macro_export]
1083macro_rules! register_app_locale {
1084 ($app_label:expr, $locale_dir:expr) => {
1085 $crate::inventory::submit! {
1086 $crate::AppLocaleConfig {
1087 app_label: $app_label,
1088 locale_dir: $locale_dir,
1089 }
1090 }
1091 };
1092}
1093
1094#[macro_export]
1117macro_rules! register_app_command {
1118 ($app_label:expr, $command_name:expr, $command_fn:expr) => {
1119 $crate::inventory::submit! {
1120 $crate::AppCommandConfig {
1121 app_label: $app_label,
1122 command_name: $command_name,
1123 command_fn: $command_fn,
1124 }
1125 }
1126 };
1127}
1128
1129#[macro_export]
1144macro_rules! register_app_media {
1145 ($app_label:expr, $media_dir:expr, $url_prefix:expr) => {
1146 $crate::inventory::submit! {
1147 $crate::AppMediaConfig {
1148 app_label: $app_label,
1149 media_dir: $media_dir,
1150 url_prefix: $url_prefix,
1151 }
1152 }
1153 };
1154}
1155
1156pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
1176 inventory::iter::<AppStaticFilesConfig>().collect()
1177}
1178
1179pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
1195 inventory::iter::<AppLocaleConfig>().collect()
1196}
1197
1198pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
1214 inventory::iter::<AppCommandConfig>().collect()
1215}
1216
1217pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
1233 inventory::iter::<AppMediaConfig>().collect()
1234}