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
77#[cfg(native)]
85pub use reinhardt_utils::staticfiles::vendor::AppVendorAsset;
86
87impl AppConfig {
88 pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
90 Self {
91 name: name.into(),
92 label: label.into(),
93 verbose_name: None,
94 path: None,
95 default_auto_field: None,
96 models_ready: false,
97 }
98 }
99
100 pub fn with_verbose_name(mut self, verbose_name: impl Into<String>) -> Self {
102 self.verbose_name = Some(verbose_name.into());
103 self
104 }
105
106 pub fn with_path(mut self, path: impl Into<String>) -> AppResult<Self> {
118 let path = path.into();
119 Self::validate_path(&path)?;
120 self.path = Some(path);
121 Ok(self)
122 }
123
124 fn validate_path(path: &str) -> AppResult<()> {
132 if path.is_empty() {
133 return Err(AppError::ConfigError(
134 "application path cannot be empty".to_string(),
135 ));
136 }
137
138 if path.contains('\0') {
140 return Err(AppError::ConfigError(
141 "application path must not contain null bytes".to_string(),
142 ));
143 }
144
145 if path.chars().any(|c| c.is_control()) {
147 return Err(AppError::ConfigError(
148 "application path must not contain control characters".to_string(),
149 ));
150 }
151
152 if path.starts_with('/') || path.starts_with('\\') {
154 return Err(AppError::ConfigError(
155 "application path must be relative, not absolute".to_string(),
156 ));
157 }
158
159 if path.len() >= 2 && path.as_bytes()[0].is_ascii_alphabetic() && path.as_bytes()[1] == b':'
161 {
162 return Err(AppError::ConfigError(
163 "application path must be relative, not absolute".to_string(),
164 ));
165 }
166
167 for component in path.split(['/', '\\']) {
169 if component == ".." {
170 return Err(AppError::ConfigError(
171 "application path must not contain path traversal sequences".to_string(),
172 ));
173 }
174 }
175
176 Ok(())
177 }
178
179 pub fn with_default_auto_field(mut self, field: impl Into<String>) -> Self {
181 self.default_auto_field = Some(field.into());
182 self
183 }
184
185 pub fn validate_label(&self) -> AppResult<()> {
187 if self.label.is_empty() {
188 return Err(AppError::InvalidLabel("Label cannot be empty".to_string()));
189 }
190
191 if !self
193 .label
194 .chars()
195 .next()
196 .map(|c| c.is_alphabetic() || c == '_')
197 .unwrap_or(false)
198 {
199 return Err(AppError::InvalidLabel(format!(
200 "Label '{}' must start with a letter or underscore",
201 self.label
202 )));
203 }
204
205 if !self.label.chars().all(|c| c.is_alphanumeric() || c == '_') {
206 return Err(AppError::InvalidLabel(format!(
207 "Label '{}' must contain only alphanumeric characters and underscores",
208 self.label
209 )));
210 }
211
212 Ok(())
213 }
214
215 pub fn ready(&self) -> Result<(), Box<dyn Error>> {
230 Ok(())
233 }
234}
235
236pub trait StaticFilesProvider {
245 fn static_dir(&self) -> Option<std::path::PathBuf> {
249 None
250 }
251
252 fn static_url_prefix(&self) -> Option<String> {
256 None
257 }
258}
259
260pub trait LocaleProvider {
265 fn locale_dir(&self) -> Option<std::path::PathBuf> {
269 None
270 }
271}
272
273pub trait MediaProvider {
278 fn media_dir(&self) -> Option<std::path::PathBuf> {
282 None
283 }
284
285 fn media_url_prefix(&self) -> Option<String> {
289 None
290 }
291}
292
293impl StaticFilesProvider for AppConfig {
295 fn static_dir(&self) -> Option<std::path::PathBuf> {
296 if let Some(path) = &self.path {
298 let static_path = std::path::PathBuf::from(path).join("static");
299 if static_path.exists() && static_path.is_dir() {
300 return Some(static_path);
301 }
302 }
303 None
304 }
305
306 fn static_url_prefix(&self) -> Option<String> {
307 Some(format!("/static/{}/", self.label))
308 }
309}
310
311impl LocaleProvider for AppConfig {
312 fn locale_dir(&self) -> Option<std::path::PathBuf> {
313 if let Some(path) = &self.path {
315 let locale_path = std::path::PathBuf::from(path).join("locale");
316 if locale_path.exists() && locale_path.is_dir() {
317 return Some(locale_path);
318 }
319 }
320 None
321 }
322}
323
324impl MediaProvider for AppConfig {
325 fn media_dir(&self) -> Option<std::path::PathBuf> {
326 if let Some(path) = &self.path {
328 let media_path = std::path::PathBuf::from(path).join("media");
329 if media_path.exists() && media_path.is_dir() {
330 return Some(media_path);
331 }
332 }
333 None
334 }
335
336 fn media_url_prefix(&self) -> Option<String> {
337 Some(format!("/media/{}/", self.label))
338 }
339}
340
341#[derive(Clone)]
347pub struct Apps {
348 installed_apps: Vec<String>,
350
351 app_configs: Arc<Mutex<HashMap<String, AppConfig>>>,
353
354 app_names: Arc<Mutex<HashMap<String, String>>>,
356
357 ready: Arc<Mutex<bool>>,
359
360 apps_ready: Arc<Mutex<bool>>,
362
363 models_ready: Arc<Mutex<bool>>,
365}
366
367impl Apps {
368 pub fn new(installed_apps: Vec<String>) -> Self {
370 Self {
371 installed_apps,
372 app_configs: Arc::new(Mutex::new(HashMap::new())),
373 app_names: Arc::new(Mutex::new(HashMap::new())),
374 ready: Arc::new(Mutex::new(false)),
375 apps_ready: Arc::new(Mutex::new(false)),
376 models_ready: Arc::new(Mutex::new(false)),
377 }
378 }
379
380 pub fn is_ready(&self) -> bool {
382 *self.ready.lock().unwrap_or_else(PoisonError::into_inner)
383 }
384
385 pub fn is_apps_ready(&self) -> bool {
387 *self
388 .apps_ready
389 .lock()
390 .unwrap_or_else(PoisonError::into_inner)
391 }
392
393 pub fn is_models_ready(&self) -> bool {
395 *self
396 .models_ready
397 .lock()
398 .unwrap_or_else(PoisonError::into_inner)
399 }
400
401 pub fn register(&self, config: AppConfig) -> AppResult<()> {
403 config.validate_label()?;
405
406 let mut configs = self
407 .app_configs
408 .lock()
409 .unwrap_or_else(PoisonError::into_inner);
410 let mut names = self
411 .app_names
412 .lock()
413 .unwrap_or_else(PoisonError::into_inner);
414
415 if configs.contains_key(&config.label) {
417 return Err(AppError::DuplicateLabel(config.label.clone()));
418 }
419
420 if names.contains_key(&config.name) {
422 return Err(AppError::DuplicateName(config.name.clone()));
423 }
424
425 names.insert(config.name.clone(), config.label.clone());
427 configs.insert(config.label.clone(), config);
428
429 Ok(())
430 }
431
432 pub fn get_app_config(&self, label: &str) -> AppResult<AppConfig> {
434 self.app_configs
435 .lock()
436 .unwrap_or_else(PoisonError::into_inner)
437 .get(label)
438 .cloned()
439 .ok_or_else(|| AppError::NotFound(label.to_string()))
440 }
441
442 pub fn get_app_configs(&self) -> Vec<AppConfig> {
444 self.app_configs
445 .lock()
446 .unwrap_or_else(PoisonError::into_inner)
447 .values()
448 .cloned()
449 .collect()
450 }
451
452 pub fn is_installed(&self, name: &str) -> bool {
458 if self.installed_apps.contains(&name.to_string()) {
459 return true;
460 }
461
462 let names = self
464 .app_names
465 .lock()
466 .unwrap_or_else(PoisonError::into_inner);
467 let configs = self
468 .app_configs
469 .lock()
470 .unwrap_or_else(PoisonError::into_inner);
471
472 names.contains_key(name) || configs.contains_key(name)
473 }
474
475 pub fn populate(&self) -> AppResult<()> {
492 *self
494 .apps_ready
495 .lock()
496 .unwrap_or_else(PoisonError::into_inner) = true;
497
498 {
501 let mut seen = std::collections::HashSet::new();
502 for app_name in &self.installed_apps {
503 if !seen.insert(app_name) {
504 return Err(AppError::DuplicateLabel(app_name.clone()));
505 }
506 }
507 }
508
509 for app_name in &self.installed_apps {
510 let app_config = AppConfig::new(app_name.clone(), app_name.clone());
511
512 let mut configs = self
514 .app_configs
515 .lock()
516 .unwrap_or_else(PoisonError::into_inner);
517 if configs.contains_key(&app_config.label) {
518 continue;
519 }
520 configs.insert(app_config.label.clone(), app_config.clone());
521 drop(configs);
522
523 self.app_names
524 .lock()
525 .unwrap_or_else(PoisonError::into_inner)
526 .insert(app_name.clone(), app_config.label.clone());
527 }
528
529 let configs = self
531 .app_configs
532 .lock()
533 .unwrap_or_else(PoisonError::into_inner);
534 for app_config in configs.values() {
535 app_config.ready().map_err(|e| {
537 AppError::ConfigError(format!(
538 "Ready hook failed for app '{}': {}",
539 app_config.label, e
540 ))
541 })?;
542
543 signals::app_ready().send(app_config);
545 }
546 drop(configs); #[cfg(native)]
558 if !*self
559 .models_ready
560 .lock()
561 .unwrap_or_else(PoisonError::into_inner)
562 {
563 crate::discovery::build_reverse_relations()?;
564 crate::registry::finalize_reverse_relations();
566 }
567
568 *self
570 .models_ready
571 .lock()
572 .unwrap_or_else(PoisonError::into_inner) = true;
573 *self.ready.lock().unwrap_or_else(PoisonError::into_inner) = true;
574
575 Ok(())
576 }
577
578 pub fn clear_cache(&self) {
580 self.app_configs
581 .lock()
582 .unwrap_or_else(PoisonError::into_inner)
583 .clear();
584 self.app_names
585 .lock()
586 .unwrap_or_else(PoisonError::into_inner)
587 .clear();
588 *self.ready.lock().unwrap_or_else(PoisonError::into_inner) = false;
589 *self
590 .apps_ready
591 .lock()
592 .unwrap_or_else(PoisonError::into_inner) = false;
593 *self
594 .models_ready
595 .lock()
596 .unwrap_or_else(PoisonError::into_inner) = false;
597 }
598}
599
600#[cfg(feature = "di")]
602mod di_integration {
603 use super::*;
604 use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
605
606 #[async_trait::async_trait]
607 impl Injectable for Apps {
608 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
609 if let Some(apps) = ctx.get_singleton::<Apps>() {
611 return Ok((*apps).clone());
612 }
613
614 Err(DiError::NotFound(std::any::type_name::<Apps>().to_string()))
615 }
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use rstest::rstest;
623 use serial_test::serial;
624
625 #[rstest]
626 fn test_app_config_creation() {
627 let config = AppConfig::new("myapp", "myapp")
629 .with_verbose_name("My Application")
630 .with_default_auto_field("BigAutoField");
631
632 assert_eq!(config.name, "myapp");
634 assert_eq!(config.label, "myapp");
635 assert_eq!(config.verbose_name, Some("My Application".to_string()));
636 assert_eq!(config.default_auto_field, Some("BigAutoField".to_string()));
637 }
638
639 #[rstest]
640 fn test_app_config_validation() {
641 let valid = AppConfig::new("myapp", "myapp");
643 let invalid = AppConfig::new("myapp", "my-app");
644 let empty = AppConfig::new("myapp", "");
645
646 assert!(valid.validate_label().is_ok());
648 assert!(invalid.validate_label().is_err());
649 assert!(empty.validate_label().is_err());
650 }
651
652 #[rstest]
653 fn test_apps_registry() {
654 let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
656
657 assert!(apps.is_installed("myapp"));
659 assert!(apps.is_installed("anotherapp"));
660 assert!(!apps.is_installed("notinstalled"));
661 }
662
663 #[rstest]
664 fn test_register_app() {
665 let apps = Apps::new(vec![]);
667 let config = AppConfig::new("myapp", "myapp");
668
669 assert!(apps.register(config).is_ok());
671 assert!(apps.get_app_config("myapp").is_ok());
672 }
673
674 #[rstest]
675 fn test_duplicate_registration() {
676 let apps = Apps::new(vec![]);
678 let config1 = AppConfig::new("myapp", "myapp");
679 let config2 = AppConfig::new("myapp", "myapp");
680 apps.register(config1).unwrap();
681
682 let result = apps.register(config2);
684
685 assert!(result.is_err());
687 }
688
689 #[rstest]
690 fn test_get_app_configs() {
691 let apps = Apps::new(vec![]);
693 apps.register(AppConfig::new("app1", "app1")).unwrap();
694 apps.register(AppConfig::new("app2", "app2")).unwrap();
695
696 let configs = apps.get_app_configs();
698
699 assert_eq!(configs.len(), 2);
701 }
702
703 #[rstest]
704 #[serial(apps_registry)]
705 fn test_populate() {
706 crate::registry::reset_global_registry();
708
709 let apps = Apps::new(vec![]);
711 assert!(!apps.is_ready());
712
713 apps.populate().unwrap();
715
716 assert!(apps.is_ready());
718 assert!(apps.is_apps_ready());
719 assert!(apps.is_models_ready());
720 }
721
722 #[rstest]
723 #[serial(apps_registry)]
724 fn test_populate_with_installed_apps() {
725 crate::registry::reset_global_registry();
727
728 let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
730 assert!(!apps.is_ready());
731
732 let result = apps.populate();
734
735 assert!(result.is_ok());
737 assert!(apps.is_ready());
738 assert!(apps.is_apps_ready());
739 assert!(apps.is_models_ready());
740 assert!(apps.get_app_config("myapp").is_ok());
741 assert!(apps.get_app_config("anotherapp").is_ok());
742 let myapp_config = apps.get_app_config("myapp").unwrap();
743 assert_eq!(myapp_config.label, "myapp");
744 }
745
746 #[rstest]
751 #[case("apps/myapp")]
752 #[case("myapp")]
753 #[case("src/apps/myapp")]
754 #[case("my_app")]
755 #[case("my-app")]
756 fn test_with_path_accepts_valid_relative_paths(#[case] path: &str) {
757 let result = AppConfig::new("myapp", "myapp").with_path(path);
759
760 assert!(result.is_ok(), "expected valid path: {path}");
762 assert_eq!(result.unwrap().path, Some(path.to_string()));
763 }
764
765 #[rstest]
766 fn test_with_path_rejects_empty() {
767 let result = AppConfig::new("myapp", "myapp").with_path("");
769
770 let err = result.unwrap_err();
772 assert!(err.to_string().contains("cannot be empty"));
773 }
774
775 #[rstest]
776 #[case("../etc/passwd")]
777 #[case("apps/../../../etc/shadow")]
778 #[case("apps/..")]
779 fn test_with_path_rejects_traversal(#[case] path: &str) {
780 let result = AppConfig::new("myapp", "myapp").with_path(path);
782
783 let err = result.unwrap_err();
785 assert!(
786 err.to_string().contains("path traversal"),
787 "expected traversal error for '{path}', got: {err}"
788 );
789 }
790
791 #[rstest]
792 #[case("/etc/passwd")]
793 #[case("/absolute/path")]
794 #[case("\\windows\\path")]
795 #[case("C:\\Windows\\System32")]
796 #[case("D:/data")]
797 fn test_with_path_rejects_absolute(#[case] path: &str) {
798 let result = AppConfig::new("myapp", "myapp").with_path(path);
800
801 let err = result.unwrap_err();
803 assert!(
804 err.to_string().contains("relative, not absolute"),
805 "expected absolute path error for '{path}', got: {err}"
806 );
807 }
808
809 #[rstest]
810 fn test_with_path_rejects_null_bytes() {
811 let result = AppConfig::new("myapp", "myapp").with_path("apps/my\0app");
813
814 let err = result.unwrap_err();
816 assert!(err.to_string().contains("null bytes"));
817 }
818
819 #[rstest]
820 #[case("apps/my\napp")]
821 #[case("apps/my\rapp")]
822 fn test_with_path_rejects_control_chars(#[case] path: &str) {
823 let result = AppConfig::new("myapp", "myapp").with_path(path);
825
826 let err = result.unwrap_err();
828 assert!(
829 err.to_string().contains("control characters"),
830 "expected control char error for path, got: {err}"
831 );
832 }
833}
834
835pub trait AppLabel {
888 const LABEL: &'static str;
896
897 fn path(&self) -> &'static str {
903 Self::LABEL
904 }
905}
906
907impl Apps {
908 pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
928 self.get_app_config(A::LABEL)
929 }
930
931 pub fn is_installed_typed<A: AppLabel>(&self) -> bool {
947 self.is_installed(A::LABEL)
948 }
949}
950
951#[cfg(test)]
952mod typed_tests {
953 use super::*;
954
955 struct AuthApp;
957 impl AppLabel for AuthApp {
958 const LABEL: &'static str = "auth";
959 }
960
961 struct ContentTypesApp;
962 impl AppLabel for ContentTypesApp {
963 const LABEL: &'static str = "contenttypes";
964 }
965
966 struct SessionsApp;
967 impl AppLabel for SessionsApp {
968 const LABEL: &'static str = "sessions";
969 }
970
971 #[test]
972 fn test_typed_is_installed() {
973 let apps = Apps::new(vec!["auth".to_string(), "contenttypes".to_string()]);
974
975 assert!(apps.is_installed_typed::<AuthApp>());
976 assert!(apps.is_installed_typed::<ContentTypesApp>());
977 assert!(!apps.is_installed_typed::<SessionsApp>());
978 }
979
980 #[test]
981 fn test_typed_get_app_config() {
982 let apps = Apps::new(vec![]);
983 let config = AppConfig::new("auth", "auth");
984 apps.register(config).unwrap();
985
986 let retrieved = apps.get_app_config_typed::<AuthApp>();
987 assert!(retrieved.is_ok());
988 assert_eq!(retrieved.unwrap().label, "auth");
989 }
990
991 #[test]
992 fn test_typed_get_app_config_not_found() {
993 let apps = Apps::new(vec![]);
994
995 let result = apps.get_app_config_typed::<SessionsApp>();
996 assert!(result.is_err());
997
998 if let Err(AppError::NotFound(label)) = result {
999 assert_eq!(label, "sessions");
1000 }
1001 }
1002
1003 #[test]
1004 fn test_apps_typed_and_regular_mixed() {
1005 let apps = Apps::new(vec!["auth".to_string()]);
1006 let config = AppConfig::new("auth", "auth");
1007 apps.register(config).unwrap();
1008
1009 assert!(apps.is_installed_typed::<AuthApp>());
1011 assert!(apps.is_installed("auth"));
1012
1013 let typed = apps.get_app_config_typed::<AuthApp>().unwrap();
1014 let regular = apps.get_app_config("auth").unwrap();
1015
1016 assert_eq!(typed.label, regular.label);
1017 }
1018}
1019
1020#[cfg(native)]
1035pub trait BaseCommand: Send + Sync {
1036 fn name(&self) -> &str;
1038
1039 fn help(&self) -> &str;
1041
1042 fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
1044}
1045
1046#[cfg(native)]
1052pub struct AppStaticFilesConfig {
1053 pub app_label: &'static str,
1055 pub static_dir: &'static str,
1057 pub url_prefix: &'static str,
1059}
1060
1061#[cfg(native)]
1062inventory::collect!(AppStaticFilesConfig);
1063
1064#[cfg(native)]
1070pub struct AppLocaleConfig {
1071 pub app_label: &'static str,
1073 pub locale_dir: &'static str,
1075}
1076
1077#[cfg(native)]
1078inventory::collect!(AppLocaleConfig);
1079
1080#[cfg(native)]
1086pub struct AppCommandConfig {
1087 pub app_label: &'static str,
1089 pub command_name: &'static str,
1091 pub command_fn: fn() -> Box<dyn BaseCommand>,
1093}
1094
1095#[cfg(native)]
1096inventory::collect!(AppCommandConfig);
1097
1098#[cfg(native)]
1104pub struct AppMediaConfig {
1105 pub app_label: &'static str,
1107 pub media_dir: &'static str,
1109 pub url_prefix: &'static str,
1111}
1112
1113#[cfg(native)]
1114inventory::collect!(AppMediaConfig);
1115
1116#[cfg(native)]
1140#[macro_export]
1141macro_rules! register_app_static_files {
1142 ($app_label:expr, $static_dir:expr, $url_prefix:expr) => {
1143 $crate::inventory::submit! {
1144 $crate::AppStaticFilesConfig {
1145 app_label: $app_label,
1146 static_dir: $static_dir,
1147 url_prefix: $url_prefix,
1148 }
1149 }
1150 };
1151}
1152
1153#[cfg(native)]
1167#[macro_export]
1168macro_rules! register_app_locale {
1169 ($app_label:expr, $locale_dir:expr) => {
1170 $crate::inventory::submit! {
1171 $crate::AppLocaleConfig {
1172 app_label: $app_label,
1173 locale_dir: $locale_dir,
1174 }
1175 }
1176 };
1177}
1178
1179#[cfg(native)]
1202#[macro_export]
1203macro_rules! register_app_command {
1204 ($app_label:expr, $command_name:expr, $command_fn:expr) => {
1205 $crate::inventory::submit! {
1206 $crate::AppCommandConfig {
1207 app_label: $app_label,
1208 command_name: $command_name,
1209 command_fn: $command_fn,
1210 }
1211 }
1212 };
1213}
1214
1215#[cfg(native)]
1230#[macro_export]
1231macro_rules! register_app_media {
1232 ($app_label:expr, $media_dir:expr, $url_prefix:expr) => {
1233 $crate::inventory::submit! {
1234 $crate::AppMediaConfig {
1235 app_label: $app_label,
1236 media_dir: $media_dir,
1237 url_prefix: $url_prefix,
1238 }
1239 }
1240 };
1241}
1242
1243#[cfg(native)]
1263pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
1264 inventory::iter::<AppStaticFilesConfig>().collect()
1265}
1266
1267#[cfg(native)]
1283pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
1284 inventory::iter::<AppLocaleConfig>().collect()
1285}
1286
1287#[cfg(native)]
1303pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
1304 inventory::iter::<AppCommandConfig>().collect()
1305}
1306
1307#[cfg(native)]
1323pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
1324 inventory::iter::<AppMediaConfig>().collect()
1325}