Skip to main content

reinhardt_apps/
apps.rs

1//! # Application Registry
2//!
3//! Django-inspired application configuration and registry system.
4//! This module provides the infrastructure for managing Django-style apps
5//! in a Reinhardt project.
6//!
7//! This module provides both string-based (runtime) and type-safe (compile-time)
8//! application registry mechanisms.
9
10use crate::signals;
11use std::collections::HashMap;
12use std::error::Error;
13use std::sync::{Arc, Mutex, PoisonError};
14use thiserror::Error as ThisError;
15
16/// Errors that can occur when working with the application registry
17#[derive(Debug, ThisError)]
18pub enum AppError {
19	/// The requested application was not found in the registry.
20	#[error("Application not found: {0}")]
21	NotFound(String),
22
23	/// An application with the same label is already registered.
24	#[error("Application already registered: {0}")]
25	AlreadyRegistered(String),
26
27	/// The provided application label is invalid.
28	#[error("Invalid application label: {0}")]
29	InvalidLabel(String),
30
31	/// Two applications share the same label.
32	#[error("Duplicate application label: {0}")]
33	DuplicateLabel(String),
34
35	/// Two applications share the same name.
36	#[error("Duplicate application name: {0}")]
37	DuplicateName(String),
38
39	/// The application registry has not been initialized yet.
40	#[error("Application registry not ready")]
41	NotReady,
42
43	/// A configuration error occurred during application setup.
44	#[error("Application configuration error: {0}")]
45	ConfigError(String),
46
47	/// An error related to the internal state of the registry.
48	#[error("Registry state error: {0}")]
49	RegistryState(String),
50}
51
52/// A specialized `Result` type for application operations.
53pub type AppResult<T> = Result<T, AppError>;
54
55/// Configuration for a single application
56#[derive(Clone, Debug)]
57pub struct AppConfig {
58	/// The full Python-style name of the application (e.g., "myapp" or "myproject.apps.MyAppConfig")
59	pub name: String,
60
61	/// The short label for the application (e.g., "myapp")
62	pub label: String,
63
64	/// Human-readable name for the application
65	pub verbose_name: Option<String>,
66
67	/// Filesystem path to the application
68	pub path: Option<String>,
69
70	/// Default auto field type for models in this app
71	pub default_auto_field: Option<String>,
72
73	/// Whether the app has been populated with models
74	pub models_ready: bool,
75}
76
77impl AppConfig {
78	/// Create a new AppConfig with required fields
79	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	/// Set the verbose name for the application
91	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	/// Set the path for the application.
97	///
98	/// The path is validated to reject path traversal sequences (`..`),
99	/// absolute paths (starting with `/` or a Windows drive letter), and
100	/// null bytes. These restrictions prevent path traversal attacks when
101	/// the path is later used to locate application resources on disk.
102	///
103	/// # Errors
104	///
105	/// Returns [`AppError::ConfigError`] if the path contains disallowed
106	/// sequences.
107	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	/// Validates an application path to prevent path traversal and injection.
115	///
116	/// Rejects paths that contain:
117	/// - Path traversal sequences (`..`)
118	/// - Absolute paths (starting with `/` or a Windows drive letter like `C:\`)
119	/// - Null bytes (`\0`)
120	/// - Control characters
121	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		// Reject null bytes
129		if path.contains('\0') {
130			return Err(AppError::ConfigError(
131				"application path must not contain null bytes".to_string(),
132			));
133		}
134
135		// Reject control characters (prevents log injection)
136		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		// Reject absolute paths (Unix-style or Windows-style)
143		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		// Reject Windows drive letter paths (e.g., C:\, D:/)
150		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		// Reject path traversal sequences
158		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	/// Set the default auto field for the application
170	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	/// Validate the application label
176	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		// Check if label is a valid Rust identifier
182		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	/// Ready hook for the application
206	///
207	/// This method is called when the application is ready, after all configurations
208	/// have been loaded and models have been registered. Override this method in
209	/// custom application configurations to perform initialization tasks.
210	///
211	/// # Examples
212	///
213	/// ```rust
214	/// use reinhardt_apps::AppConfig;
215	///
216	/// let config = AppConfig::new("myapp", "myapp");
217	/// config.ready().expect("Ready hook should succeed");
218	/// ```
219	pub fn ready(&self) -> Result<(), Box<dyn Error>> {
220		// Default implementation does nothing
221		// Applications can override this by implementing custom AppConfig structs
222		Ok(())
223	}
224}
225
226// ============================================================================
227// Resource Provider Traits
228// ============================================================================
229
230/// Trait for providing static file directories
231///
232/// Applications can implement this trait to provide static files
233/// that will be automatically discovered by collectstatic.
234pub trait StaticFilesProvider {
235	/// Get the static files directory for this app
236	///
237	/// Returns None if the app does not provide static files
238	fn static_dir(&self) -> Option<std::path::PathBuf> {
239		None
240	}
241
242	/// Get the static URL prefix for this app
243	///
244	/// Default: "/static/{app_label}/"
245	fn static_url_prefix(&self) -> Option<String> {
246		None
247	}
248}
249
250/// Trait for providing locale directories
251///
252/// Applications can implement this trait to provide translation files
253/// that will be automatically discovered by makemessages.
254pub trait LocaleProvider {
255	/// Get the locale directory for this app
256	///
257	/// Returns None if the app does not provide translations
258	fn locale_dir(&self) -> Option<std::path::PathBuf> {
259		None
260	}
261}
262
263/// Trait for providing media directories
264///
265/// Applications can implement this trait to provide initial media files
266/// that will be automatically discovered by collectmedia.
267pub trait MediaProvider {
268	/// Get the media directory for this app
269	///
270	/// Returns None if the app does not provide media files
271	fn media_dir(&self) -> Option<std::path::PathBuf> {
272		None
273	}
274
275	/// Get the media URL prefix for this app
276	///
277	/// Default: "/media/{app_label}/"
278	fn media_url_prefix(&self) -> Option<String> {
279		None
280	}
281}
282
283/// Default implementations for AppConfig
284impl StaticFilesProvider for AppConfig {
285	fn static_dir(&self) -> Option<std::path::PathBuf> {
286		// Default: {app_path}/static/
287		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		// Default: {app_path}/locale/
304		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		// Default: {app_path}/media/
317		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/// Main application registry
332///
333/// This is the central registry for all installed applications in a Reinhardt project.
334/// It manages application configuration, initialization order, and provides
335/// methods to query installed applications.
336#[derive(Clone)]
337pub struct Apps {
338	/// List of installed application identifiers
339	installed_apps: Vec<String>,
340
341	/// Map of application labels to their configurations
342	app_configs: Arc<Mutex<HashMap<String, AppConfig>>>,
343
344	/// Map of application names to their labels
345	app_names: Arc<Mutex<HashMap<String, String>>>,
346
347	/// Whether the registry has been populated
348	ready: Arc<Mutex<bool>>,
349
350	/// Whether app configs have been populated
351	apps_ready: Arc<Mutex<bool>>,
352
353	/// Whether models have been populated
354	models_ready: Arc<Mutex<bool>>,
355}
356
357impl Apps {
358	/// Create a new application registry
359	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	/// Check if the registry is fully ready
371	pub fn is_ready(&self) -> bool {
372		*self.ready.lock().unwrap_or_else(PoisonError::into_inner)
373	}
374
375	/// Check if app configurations are ready
376	pub fn is_apps_ready(&self) -> bool {
377		*self
378			.apps_ready
379			.lock()
380			.unwrap_or_else(PoisonError::into_inner)
381	}
382
383	/// Check if models are ready
384	pub fn is_models_ready(&self) -> bool {
385		*self
386			.models_ready
387			.lock()
388			.unwrap_or_else(PoisonError::into_inner)
389	}
390
391	/// Register an application configuration
392	pub fn register(&self, config: AppConfig) -> AppResult<()> {
393		// Validate the configuration
394		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		// Check for duplicate label
406		if configs.contains_key(&config.label) {
407			return Err(AppError::DuplicateLabel(config.label.clone()));
408		}
409
410		// Check for duplicate name
411		if names.contains_key(&config.name) {
412			return Err(AppError::DuplicateName(config.name.clone()));
413		}
414
415		// Store the configuration
416		names.insert(config.name.clone(), config.label.clone());
417		configs.insert(config.label.clone(), config);
418
419		Ok(())
420	}
421
422	/// Get an application configuration by label
423	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	/// Get all registered application configurations
433	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	/// Check if an application is installed
443	///
444	/// Acquires locks on both `app_names` and `app_configs` before checking,
445	/// ensuring a consistent snapshot and avoiding TOCTOU race conditions
446	/// where state could change between individual lock acquisitions.
447	pub fn is_installed(&self, name: &str) -> bool {
448		if self.installed_apps.contains(&name.to_string()) {
449			return true;
450		}
451
452		// Hold both locks simultaneously for a consistent snapshot
453		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	/// Populate the registry with application configurations
466	///
467	/// This method initializes all registered applications by:
468	/// 1. Creating AppConfig instances for each installed app
469	/// 2. Calling the ready() method on each AppConfig
470	/// 3. Loading model definitions from the global registry
471	/// 4. Building reverse relations between models
472	///
473	/// # Examples
474	///
475	/// ```rust
476	/// use reinhardt_apps::Apps;
477	///
478	/// let apps = Apps::new(vec!["myapp".to_string()]);
479	/// apps.populate().expect("Failed to populate apps");
480	/// ```
481	pub fn populate(&self) -> AppResult<()> {
482		// Mark as apps_ready
483		*self
484			.apps_ready
485			.lock()
486			.unwrap_or_else(PoisonError::into_inner) = true;
487
488		// 1. Import and instantiate AppConfig for each installed app
489		// Detect duplicate entries in the installed_apps list itself
490		{
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			// Skip apps already registered via register() to avoid overwriting
503			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		// 2. Call ready() method on each AppConfig and send signals
520		let configs = self
521			.app_configs
522			.lock()
523			.unwrap_or_else(PoisonError::into_inner);
524		for app_config in configs.values() {
525			// Call the ready hook
526			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			// Send the app_ready signal
534			signals::app_ready().send(app_config);
535		}
536		drop(configs); // Release lock early
537
538		// 3. Load model definitions from global ModelRegistry
539		// The models are already registered via #[derive(Model)] macro
540		// which automatically registers them at construction time
541
542		// 4. Build reverse relations between models
543		if !*self
544			.models_ready
545			.lock()
546			.unwrap_or_else(PoisonError::into_inner)
547		{
548			crate::discovery::build_reverse_relations()?;
549			// Finalize reverse relations to make them immutable
550			crate::registry::finalize_reverse_relations();
551		}
552
553		// Mark as models_ready
554		*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	/// Clear all cached data (for testing)
564	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// DI integration (feature-gated)
586#[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			// Get from singleton scope
595			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		// Arrange & Act
613		let config = AppConfig::new("myapp", "myapp")
614			.with_verbose_name("My Application")
615			.with_default_auto_field("BigAutoField");
616
617		// Assert
618		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		// Arrange
627		let valid = AppConfig::new("myapp", "myapp");
628		let invalid = AppConfig::new("myapp", "my-app");
629		let empty = AppConfig::new("myapp", "");
630
631		// Act & Assert
632		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		// Arrange
640		let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
641
642		// Act & Assert
643		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		// Arrange
651		let apps = Apps::new(vec![]);
652		let config = AppConfig::new("myapp", "myapp");
653
654		// Act & Assert
655		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		// Arrange
662		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		// Act
668		let result = apps.register(config2);
669
670		// Assert
671		assert!(result.is_err());
672	}
673
674	#[rstest]
675	fn test_get_app_configs() {
676		// Arrange
677		let apps = Apps::new(vec![]);
678		apps.register(AppConfig::new("app1", "app1")).unwrap();
679		apps.register(AppConfig::new("app2", "app2")).unwrap();
680
681		// Act
682		let configs = apps.get_app_configs();
683
684		// Assert
685		assert_eq!(configs.len(), 2);
686	}
687
688	#[rstest]
689	#[serial(apps_registry)]
690	fn test_populate() {
691		// Arrange - Reset global state before test
692		crate::registry::reset_global_registry();
693
694		// Arrange
695		let apps = Apps::new(vec![]);
696		assert!(!apps.is_ready());
697
698		// Act
699		apps.populate().unwrap();
700
701		// Assert
702		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		// Arrange - Reset global state before test
711		crate::registry::reset_global_registry();
712
713		// Arrange
714		let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
715		assert!(!apps.is_ready());
716
717		// Act
718		let result = apps.populate();
719
720		// Assert
721		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	// ==========================================================================
732	// Path Validation Tests
733	// ==========================================================================
734
735	#[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		// Act
743		let result = AppConfig::new("myapp", "myapp").with_path(path);
744
745		// Assert
746		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		// Act
753		let result = AppConfig::new("myapp", "myapp").with_path("");
754
755		// Assert
756		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		// Act
766		let result = AppConfig::new("myapp", "myapp").with_path(path);
767
768		// Assert
769		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		// Act
784		let result = AppConfig::new("myapp", "myapp").with_path(path);
785
786		// Assert
787		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		// Act
797		let result = AppConfig::new("myapp", "myapp").with_path("apps/my\0app");
798
799		// Assert
800		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		// Act
809		let result = AppConfig::new("myapp", "myapp").with_path(path);
810
811		// Assert
812		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
820// ============================================================================
821// Type-safe application registry (compile-time checked)
822// ============================================================================
823
824/// Trait for applications that can be accessed at compile time
825///
826/// Implement this trait for each application in your project.
827/// The compiler will ensure that only valid application labels can be used.
828///
829/// # Example
830///
831/// ```rust
832/// use reinhardt_apps::apps::AppLabel;
833///
834/// pub struct AuthApp;
835/// impl AppLabel for AuthApp {
836///     const LABEL: &'static str = "auth";
837/// }
838/// ```
839pub trait AppLabel {
840	/// The unique label for this application
841	const LABEL: &'static str;
842}
843
844impl Apps {
845	/// Type-safe get_app_config method
846	///
847	/// This method ensures at compile time that only valid application types
848	/// can be used.
849	///
850	/// # Example
851	///
852	/// ```rust
853	/// use reinhardt_apps::apps::{Apps, AppLabel};
854	///
855	/// pub struct AuthApp;
856	/// impl AppLabel for AuthApp {
857	///     const LABEL: &'static str = "auth";
858	/// }
859	///
860	/// let apps = Apps::new(vec!["auth".to_string()]);
861	/// // This will compile because AuthApp implements AppLabel
862	/// let result = apps.get_app_config_typed::<AuthApp>();
863	/// ```
864	pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
865		self.get_app_config(A::LABEL)
866	}
867
868	/// Type-safe check if an application is installed
869	///
870	/// # Example
871	///
872	/// ```rust
873	/// use reinhardt_apps::apps::{Apps, AppLabel};
874	///
875	/// pub struct AuthApp;
876	/// impl AppLabel for AuthApp {
877	///     const LABEL: &'static str = "auth";
878	/// }
879	///
880	/// let apps = Apps::new(vec!["auth".to_string()]);
881	/// assert!(apps.is_installed_typed::<AuthApp>());
882	/// ```
883	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	// Test application types
893	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		// Can use both typed and regular methods
947		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
957// ============================================================================
958// Global Registry (inventory-based)
959// ============================================================================
960
961/// Base trait for custom management commands
962///
963/// Applications can implement this trait to provide custom commands
964/// that will be automatically discovered by the manage.py CLI.
965pub trait BaseCommand: Send + Sync {
966	/// Command name (e.g., "createsuperuser")
967	fn name(&self) -> &str;
968
969	/// Command help text
970	fn help(&self) -> &str;
971
972	/// Execute the command
973	fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
974}
975
976/// Static files configuration from an app
977///
978/// Applications can register their static files directories using this struct.
979/// Registered configurations will be automatically discovered by collectstatic.
980/// Uses static string references for compile-time registration.
981pub struct AppStaticFilesConfig {
982	/// Application label that owns these static files.
983	pub app_label: &'static str,
984	/// Filesystem path to the static files directory.
985	pub static_dir: &'static str,
986	/// URL prefix under which the static files are served.
987	pub url_prefix: &'static str,
988}
989
990inventory::collect!(AppStaticFilesConfig);
991
992/// Locale configuration from an app
993///
994/// Applications can register their locale directories using this struct.
995/// Registered configurations will be automatically discovered by makemessages.
996/// Uses static string references for compile-time registration.
997pub struct AppLocaleConfig {
998	/// Application label that owns these locale files.
999	pub app_label: &'static str,
1000	/// Filesystem path to the locale directory.
1001	pub locale_dir: &'static str,
1002}
1003
1004inventory::collect!(AppLocaleConfig);
1005
1006/// Command configuration from an app
1007///
1008/// Applications can register their custom management commands using this struct.
1009/// Registered commands will be automatically discovered by the manage.py CLI.
1010/// Uses static string references for compile-time registration.
1011pub struct AppCommandConfig {
1012	/// Application label that owns this command.
1013	pub app_label: &'static str,
1014	/// Name of the management command.
1015	pub command_name: &'static str,
1016	/// Factory function that creates the command instance.
1017	pub command_fn: fn() -> Box<dyn BaseCommand>,
1018}
1019
1020inventory::collect!(AppCommandConfig);
1021
1022/// Media files configuration from an app
1023///
1024/// Applications can register their media files directories using this struct.
1025/// Registered configurations will be automatically discovered by collectmedia.
1026/// Uses static string references for compile-time registration.
1027pub struct AppMediaConfig {
1028	/// Application label that owns these media files.
1029	pub app_label: &'static str,
1030	/// Filesystem path to the media files directory.
1031	pub media_dir: &'static str,
1032	/// URL prefix under which the media files are served.
1033	pub url_prefix: &'static str,
1034}
1035
1036inventory::collect!(AppMediaConfig);
1037
1038// ============================================================================
1039// Registration Macros
1040// ============================================================================
1041
1042/// Register static files for an application
1043///
1044/// # Example
1045///
1046/// ```rust,ignore
1047/// use reinhardt_apps::register_app_static_files;
1048/// use std::path::PathBuf;
1049///
1050/// register_app_static_files!(
1051///     "myapp",
1052///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"),
1053///     "/static/myapp/"
1054/// );
1055/// ```
1056#[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/// Register locale directory for an application
1070///
1071/// # Example
1072///
1073/// ```rust,ignore
1074/// use reinhardt_apps::register_app_locale;
1075/// use std::path::PathBuf;
1076///
1077/// register_app_locale!(
1078///     "myapp",
1079///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("locale")
1080/// );
1081/// ```
1082#[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/// Register a custom management command
1095///
1096/// # Example
1097///
1098/// ```rust,ignore
1099/// use reinhardt_apps::{register_app_command, BaseCommand};
1100///
1101/// struct MyCommand;
1102/// impl BaseCommand for MyCommand {
1103///     fn name(&self) -> &str { "mycommand" }
1104///     fn help(&self) -> &str { "My custom command" }
1105///     fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
1106///         Ok(())
1107///     }
1108/// }
1109///
1110/// register_app_command!(
1111///     "myapp",
1112///     "mycommand",
1113///     || Box::new(MyCommand)
1114/// );
1115/// ```
1116#[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/// Register media files directory for an application
1130///
1131/// # Example
1132///
1133/// ```rust,ignore
1134/// use reinhardt_apps::register_app_media;
1135/// use std::path::PathBuf;
1136///
1137/// register_app_media!(
1138///     "myapp",
1139///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("media"),
1140///     "/media/myapp/"
1141/// );
1142/// ```
1143#[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
1156// ============================================================================
1157// Getter Functions
1158// ============================================================================
1159
1160/// Get all registered static files configurations
1161///
1162/// Returns all static files configurations that have been registered via
1163/// `register_app_static_files!` macro.
1164///
1165/// # Example
1166///
1167/// ```rust
1168/// use reinhardt_apps::get_app_static_files;
1169///
1170/// let configs = get_app_static_files();
1171/// for config in configs {
1172///     println!("App: {}, Dir: {}", config.app_label, config.static_dir);
1173/// }
1174/// ```
1175pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
1176	inventory::iter::<AppStaticFilesConfig>().collect()
1177}
1178
1179/// Get all registered locale configurations
1180///
1181/// Returns all locale configurations that have been registered via
1182/// `register_app_locale!` macro.
1183///
1184/// # Example
1185///
1186/// ```rust
1187/// use reinhardt_apps::get_app_locales;
1188///
1189/// let configs = get_app_locales();
1190/// for config in configs {
1191///     println!("App: {}, Dir: {}", config.app_label, config.locale_dir);
1192/// }
1193/// ```
1194pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
1195	inventory::iter::<AppLocaleConfig>().collect()
1196}
1197
1198/// Get all registered command configurations
1199///
1200/// Returns all command configurations that have been registered via
1201/// `register_app_command!` macro.
1202///
1203/// # Example
1204///
1205/// ```rust
1206/// use reinhardt_apps::get_app_commands;
1207///
1208/// let configs = get_app_commands();
1209/// for config in configs {
1210///     println!("App: {}, Command: {}", config.app_label, config.command_name);
1211/// }
1212/// ```
1213pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
1214	inventory::iter::<AppCommandConfig>().collect()
1215}
1216
1217/// Get all registered media configurations
1218///
1219/// Returns all media configurations that have been registered via
1220/// `register_app_media!` macro.
1221///
1222/// # Example
1223///
1224/// ```rust
1225/// use reinhardt_apps::get_app_media;
1226///
1227/// let configs = get_app_media();
1228/// for config in configs {
1229///     println!("App: {}, Dir: {}", config.app_label, config.media_dir);
1230/// }
1231/// ```
1232pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
1233	inventory::iter::<AppMediaConfig>().collect()
1234}