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
77/// Re-export of the vendor asset descriptor from `reinhardt-utils` so that the
78/// `#[app_config]` attribute macro can refer to it via
79/// `reinhardt_apps::AppVendorAsset` without forcing user crates to depend on
80/// `reinhardt-utils` directly.
81///
82/// Native-only: `reinhardt-utils` currently pulls in tokio's `net` feature
83/// (mio) and does not compile on `wasm32-unknown-unknown`.
84#[cfg(native)]
85pub use reinhardt_utils::staticfiles::vendor::AppVendorAsset;
86
87impl AppConfig {
88	/// Create a new AppConfig with required fields
89	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	/// Set the verbose name for the application
101	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	/// Set the path for the application.
107	///
108	/// The path is validated to reject path traversal sequences (`..`),
109	/// absolute paths (starting with `/` or a Windows drive letter), and
110	/// null bytes. These restrictions prevent path traversal attacks when
111	/// the path is later used to locate application resources on disk.
112	///
113	/// # Errors
114	///
115	/// Returns [`AppError::ConfigError`] if the path contains disallowed
116	/// sequences.
117	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	/// Validates an application path to prevent path traversal and injection.
125	///
126	/// Rejects paths that contain:
127	/// - Path traversal sequences (`..`)
128	/// - Absolute paths (starting with `/` or a Windows drive letter like `C:\`)
129	/// - Null bytes (`\0`)
130	/// - Control characters
131	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		// Reject null bytes
139		if path.contains('\0') {
140			return Err(AppError::ConfigError(
141				"application path must not contain null bytes".to_string(),
142			));
143		}
144
145		// Reject control characters (prevents log injection)
146		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		// Reject absolute paths (Unix-style or Windows-style)
153		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		// Reject Windows drive letter paths (e.g., C:\, D:/)
160		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		// Reject path traversal sequences
168		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	/// Set the default auto field for the application
180	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	/// Validate the application label
186	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		// Check if label is a valid Rust identifier
192		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	/// Ready hook for the application
216	///
217	/// This method is called when the application is ready, after all configurations
218	/// have been loaded and models have been registered. Override this method in
219	/// custom application configurations to perform initialization tasks.
220	///
221	/// # Examples
222	///
223	/// ```rust
224	/// use reinhardt_apps::AppConfig;
225	///
226	/// let config = AppConfig::new("myapp", "myapp");
227	/// config.ready().expect("Ready hook should succeed");
228	/// ```
229	pub fn ready(&self) -> Result<(), Box<dyn Error>> {
230		// Default implementation does nothing
231		// Applications can override this by implementing custom AppConfig structs
232		Ok(())
233	}
234}
235
236// ============================================================================
237// Resource Provider Traits
238// ============================================================================
239
240/// Trait for providing static file directories
241///
242/// Applications can implement this trait to provide static files
243/// that will be automatically discovered by collectstatic.
244pub trait StaticFilesProvider {
245	/// Get the static files directory for this app
246	///
247	/// Returns None if the app does not provide static files
248	fn static_dir(&self) -> Option<std::path::PathBuf> {
249		None
250	}
251
252	/// Get the static URL prefix for this app
253	///
254	/// Default: "/static/{app_label}/"
255	fn static_url_prefix(&self) -> Option<String> {
256		None
257	}
258}
259
260/// Trait for providing locale directories
261///
262/// Applications can implement this trait to provide translation files
263/// that will be automatically discovered by makemessages.
264pub trait LocaleProvider {
265	/// Get the locale directory for this app
266	///
267	/// Returns None if the app does not provide translations
268	fn locale_dir(&self) -> Option<std::path::PathBuf> {
269		None
270	}
271}
272
273/// Trait for providing media directories
274///
275/// Applications can implement this trait to provide initial media files
276/// that will be automatically discovered by collectmedia.
277pub trait MediaProvider {
278	/// Get the media directory for this app
279	///
280	/// Returns None if the app does not provide media files
281	fn media_dir(&self) -> Option<std::path::PathBuf> {
282		None
283	}
284
285	/// Get the media URL prefix for this app
286	///
287	/// Default: "/media/{app_label}/"
288	fn media_url_prefix(&self) -> Option<String> {
289		None
290	}
291}
292
293/// Default implementations for AppConfig
294impl StaticFilesProvider for AppConfig {
295	fn static_dir(&self) -> Option<std::path::PathBuf> {
296		// Default: {app_path}/static/
297		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		// Default: {app_path}/locale/
314		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		// Default: {app_path}/media/
327		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/// Main application registry
342///
343/// This is the central registry for all installed applications in a Reinhardt project.
344/// It manages application configuration, initialization order, and provides
345/// methods to query installed applications.
346#[derive(Clone)]
347pub struct Apps {
348	/// List of installed application identifiers
349	installed_apps: Vec<String>,
350
351	/// Map of application labels to their configurations
352	app_configs: Arc<Mutex<HashMap<String, AppConfig>>>,
353
354	/// Map of application names to their labels
355	app_names: Arc<Mutex<HashMap<String, String>>>,
356
357	/// Whether the registry has been populated
358	ready: Arc<Mutex<bool>>,
359
360	/// Whether app configs have been populated
361	apps_ready: Arc<Mutex<bool>>,
362
363	/// Whether models have been populated
364	models_ready: Arc<Mutex<bool>>,
365}
366
367impl Apps {
368	/// Create a new application registry
369	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	/// Check if the registry is fully ready
381	pub fn is_ready(&self) -> bool {
382		*self.ready.lock().unwrap_or_else(PoisonError::into_inner)
383	}
384
385	/// Check if app configurations are ready
386	pub fn is_apps_ready(&self) -> bool {
387		*self
388			.apps_ready
389			.lock()
390			.unwrap_or_else(PoisonError::into_inner)
391	}
392
393	/// Check if models are ready
394	pub fn is_models_ready(&self) -> bool {
395		*self
396			.models_ready
397			.lock()
398			.unwrap_or_else(PoisonError::into_inner)
399	}
400
401	/// Register an application configuration
402	pub fn register(&self, config: AppConfig) -> AppResult<()> {
403		// Validate the configuration
404		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		// Check for duplicate label
416		if configs.contains_key(&config.label) {
417			return Err(AppError::DuplicateLabel(config.label.clone()));
418		}
419
420		// Check for duplicate name
421		if names.contains_key(&config.name) {
422			return Err(AppError::DuplicateName(config.name.clone()));
423		}
424
425		// Store the configuration
426		names.insert(config.name.clone(), config.label.clone());
427		configs.insert(config.label.clone(), config);
428
429		Ok(())
430	}
431
432	/// Get an application configuration by label
433	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	/// Get all registered application configurations
443	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	/// Check if an application is installed
453	///
454	/// Acquires locks on both `app_names` and `app_configs` before checking,
455	/// ensuring a consistent snapshot and avoiding TOCTOU race conditions
456	/// where state could change between individual lock acquisitions.
457	pub fn is_installed(&self, name: &str) -> bool {
458		if self.installed_apps.contains(&name.to_string()) {
459			return true;
460		}
461
462		// Hold both locks simultaneously for a consistent snapshot
463		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	/// Populate the registry with application configurations
476	///
477	/// This method initializes all registered applications by:
478	/// 1. Creating AppConfig instances for each installed app
479	/// 2. Calling the ready() method on each AppConfig
480	/// 3. Loading model definitions from the global registry
481	/// 4. Building reverse relations between models
482	///
483	/// # Examples
484	///
485	/// ```rust
486	/// use reinhardt_apps::Apps;
487	///
488	/// let apps = Apps::new(vec!["myapp".to_string()]);
489	/// apps.populate().expect("Failed to populate apps");
490	/// ```
491	pub fn populate(&self) -> AppResult<()> {
492		// Mark as apps_ready
493		*self
494			.apps_ready
495			.lock()
496			.unwrap_or_else(PoisonError::into_inner) = true;
497
498		// 1. Import and instantiate AppConfig for each installed app
499		// Detect duplicate entries in the installed_apps list itself
500		{
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			// Skip apps already registered via register() to avoid overwriting
513			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		// 2. Call ready() method on each AppConfig and send signals
530		let configs = self
531			.app_configs
532			.lock()
533			.unwrap_or_else(PoisonError::into_inner);
534		for app_config in configs.values() {
535			// Call the ready hook
536			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			// Send the app_ready signal
544			signals::app_ready().send(app_config);
545		}
546		drop(configs); // Release lock early
547
548		// 3. Load model definitions from global ModelRegistry
549		// The models are already registered via #[derive(Model)] macro
550		// which automatically registers them at construction time
551
552		// 4. Build reverse relations between models.
553		//    The discovery + registry layers depend on `linkme` distributed
554		//    slices and on server-only crates, so this step only runs on
555		//    native targets. On `wasm32-unknown-unknown`, model registration
556		//    is a no-op (the client never owns the model graph).
557		#[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			// Finalize reverse relations to make them immutable
565			crate::registry::finalize_reverse_relations();
566		}
567
568		// Mark as models_ready
569		*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	/// Clear all cached data (for testing)
579	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// DI integration (feature-gated)
601#[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			// Get from singleton scope
610			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		// Arrange & Act
628		let config = AppConfig::new("myapp", "myapp")
629			.with_verbose_name("My Application")
630			.with_default_auto_field("BigAutoField");
631
632		// Assert
633		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		// Arrange
642		let valid = AppConfig::new("myapp", "myapp");
643		let invalid = AppConfig::new("myapp", "my-app");
644		let empty = AppConfig::new("myapp", "");
645
646		// Act & Assert
647		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		// Arrange
655		let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
656
657		// Act & Assert
658		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		// Arrange
666		let apps = Apps::new(vec![]);
667		let config = AppConfig::new("myapp", "myapp");
668
669		// Act & Assert
670		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		// Arrange
677		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		// Act
683		let result = apps.register(config2);
684
685		// Assert
686		assert!(result.is_err());
687	}
688
689	#[rstest]
690	fn test_get_app_configs() {
691		// Arrange
692		let apps = Apps::new(vec![]);
693		apps.register(AppConfig::new("app1", "app1")).unwrap();
694		apps.register(AppConfig::new("app2", "app2")).unwrap();
695
696		// Act
697		let configs = apps.get_app_configs();
698
699		// Assert
700		assert_eq!(configs.len(), 2);
701	}
702
703	#[rstest]
704	#[serial(apps_registry)]
705	fn test_populate() {
706		// Arrange - Reset global state before test
707		crate::registry::reset_global_registry();
708
709		// Arrange
710		let apps = Apps::new(vec![]);
711		assert!(!apps.is_ready());
712
713		// Act
714		apps.populate().unwrap();
715
716		// Assert
717		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		// Arrange - Reset global state before test
726		crate::registry::reset_global_registry();
727
728		// Arrange
729		let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
730		assert!(!apps.is_ready());
731
732		// Act
733		let result = apps.populate();
734
735		// Assert
736		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	// ==========================================================================
747	// Path Validation Tests
748	// ==========================================================================
749
750	#[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		// Act
758		let result = AppConfig::new("myapp", "myapp").with_path(path);
759
760		// Assert
761		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		// Act
768		let result = AppConfig::new("myapp", "myapp").with_path("");
769
770		// Assert
771		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		// Act
781		let result = AppConfig::new("myapp", "myapp").with_path(path);
782
783		// Assert
784		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		// Act
799		let result = AppConfig::new("myapp", "myapp").with_path(path);
800
801		// Assert
802		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		// Act
812		let result = AppConfig::new("myapp", "myapp").with_path("apps/my\0app");
813
814		// Assert
815		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		// Act
824		let result = AppConfig::new("myapp", "myapp").with_path(path);
825
826		// Assert
827		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
835// ============================================================================
836// Type-safe application registry (compile-time checked)
837// ============================================================================
838
839/// Trait for applications that can be accessed at compile time
840///
841/// Implement this trait for each application in your project.
842/// The compiler will ensure that only valid application labels can be used.
843///
844/// # Example
845///
846/// ```rust
847/// use reinhardt_apps::apps::AppLabel;
848///
849/// pub struct AuthApp;
850/// impl AppLabel for AuthApp {
851///     const LABEL: &'static str = "auth";
852/// }
853/// ```
854///
855/// # Enum-Style Implementors
856///
857/// `AppLabel` can also be implemented on enums where each variant maps
858/// to a different label. In that case, declare [`LABEL`](AppLabel::LABEL)
859/// as `""` explicitly (the trait intentionally has no default, so the
860/// compiler enforces that you make a choice) and override
861/// [`path`](AppLabel::path) to dispatch on `self`. The `installed_apps!`
862/// macro uses this pattern for the generated `InstalledApp` enum.
863///
864/// ```
865/// use reinhardt_apps::apps::AppLabel;
866///
867/// #[derive(Clone, Copy)]
868/// enum MyApps {
869///     Auth,
870///     Blog,
871/// }
872///
873/// impl AppLabel for MyApps {
874///     const LABEL: &'static str = "";
875///
876///     fn path(&self) -> &'static str {
877///         match self {
878///             MyApps::Auth => "auth",
879///             MyApps::Blog => "blog",
880///         }
881///     }
882/// }
883///
884/// assert_eq!(MyApps::Auth.path(), "auth");
885/// assert_eq!(MyApps::Blog.path(), "blog");
886/// ```
887pub trait AppLabel {
888	/// The unique label for this application when the implementor is a
889	/// type-level marker (unit struct). Enum-style implementors that
890	/// dispatch on `self` via [`path`](AppLabel::path) should still
891	/// declare `const LABEL: &'static str = "";` explicitly; the trait
892	/// intentionally has no default so that forgetting both `LABEL` *and*
893	/// a `path()` override fails at compile time rather than silently
894	/// producing an empty label at runtime.
895	const LABEL: &'static str;
896
897	/// Returns the registered path/label string for this specific value.
898	///
899	/// Default implementation returns [`LABEL`](AppLabel::LABEL), which
900	/// is the correct behavior for type-level marker implementors. Enum
901	/// implementors must override this method to dispatch on `self`.
902	fn path(&self) -> &'static str {
903		Self::LABEL
904	}
905}
906
907impl Apps {
908	/// Type-safe get_app_config method
909	///
910	/// This method ensures at compile time that only valid application types
911	/// can be used.
912	///
913	/// # Example
914	///
915	/// ```rust
916	/// use reinhardt_apps::apps::{Apps, AppLabel};
917	///
918	/// pub struct AuthApp;
919	/// impl AppLabel for AuthApp {
920	///     const LABEL: &'static str = "auth";
921	/// }
922	///
923	/// let apps = Apps::new(vec!["auth".to_string()]);
924	/// // This will compile because AuthApp implements AppLabel
925	/// let result = apps.get_app_config_typed::<AuthApp>();
926	/// ```
927	pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
928		self.get_app_config(A::LABEL)
929	}
930
931	/// Type-safe check if an application is installed
932	///
933	/// # Example
934	///
935	/// ```rust
936	/// use reinhardt_apps::apps::{Apps, AppLabel};
937	///
938	/// pub struct AuthApp;
939	/// impl AppLabel for AuthApp {
940	///     const LABEL: &'static str = "auth";
941	/// }
942	///
943	/// let apps = Apps::new(vec!["auth".to_string()]);
944	/// assert!(apps.is_installed_typed::<AuthApp>());
945	/// ```
946	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	// Test application types
956	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		// Can use both typed and regular methods
1010		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// ============================================================================
1021// Global Registry (inventory-based)
1022//
1023// The items below use `inventory::collect!`, which relies on link-section
1024// constructors that are not portable to `wasm32-unknown-unknown`. They model
1025// server-side discovery of static files / locales / commands / media files,
1026// none of which are meaningful on the wasm client target, so they are
1027// `#[cfg(native)]`-gated individually.
1028// ============================================================================
1029
1030/// Base trait for custom management commands
1031///
1032/// Applications can implement this trait to provide custom commands
1033/// that will be automatically discovered by the manage.py CLI.
1034#[cfg(native)]
1035pub trait BaseCommand: Send + Sync {
1036	/// Command name (e.g., "createsuperuser")
1037	fn name(&self) -> &str;
1038
1039	/// Command help text
1040	fn help(&self) -> &str;
1041
1042	/// Execute the command
1043	fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
1044}
1045
1046/// Static files configuration from an app
1047///
1048/// Applications can register their static files directories using this struct.
1049/// Registered configurations will be automatically discovered by collectstatic.
1050/// Uses static string references for compile-time registration.
1051#[cfg(native)]
1052pub struct AppStaticFilesConfig {
1053	/// Application label that owns these static files.
1054	pub app_label: &'static str,
1055	/// Filesystem path to the static files directory.
1056	pub static_dir: &'static str,
1057	/// URL prefix under which the static files are served.
1058	pub url_prefix: &'static str,
1059}
1060
1061#[cfg(native)]
1062inventory::collect!(AppStaticFilesConfig);
1063
1064/// Locale configuration from an app
1065///
1066/// Applications can register their locale directories using this struct.
1067/// Registered configurations will be automatically discovered by makemessages.
1068/// Uses static string references for compile-time registration.
1069#[cfg(native)]
1070pub struct AppLocaleConfig {
1071	/// Application label that owns these locale files.
1072	pub app_label: &'static str,
1073	/// Filesystem path to the locale directory.
1074	pub locale_dir: &'static str,
1075}
1076
1077#[cfg(native)]
1078inventory::collect!(AppLocaleConfig);
1079
1080/// Command configuration from an app
1081///
1082/// Applications can register their custom management commands using this struct.
1083/// Registered commands will be automatically discovered by the manage.py CLI.
1084/// Uses static string references for compile-time registration.
1085#[cfg(native)]
1086pub struct AppCommandConfig {
1087	/// Application label that owns this command.
1088	pub app_label: &'static str,
1089	/// Name of the management command.
1090	pub command_name: &'static str,
1091	/// Factory function that creates the command instance.
1092	pub command_fn: fn() -> Box<dyn BaseCommand>,
1093}
1094
1095#[cfg(native)]
1096inventory::collect!(AppCommandConfig);
1097
1098/// Media files configuration from an app
1099///
1100/// Applications can register their media files directories using this struct.
1101/// Registered configurations will be automatically discovered by collectmedia.
1102/// Uses static string references for compile-time registration.
1103#[cfg(native)]
1104pub struct AppMediaConfig {
1105	/// Application label that owns these media files.
1106	pub app_label: &'static str,
1107	/// Filesystem path to the media files directory.
1108	pub media_dir: &'static str,
1109	/// URL prefix under which the media files are served.
1110	pub url_prefix: &'static str,
1111}
1112
1113#[cfg(native)]
1114inventory::collect!(AppMediaConfig);
1115
1116// ============================================================================
1117// Registration Macros
1118//
1119// These macros expand to `$crate::inventory::submit!` blocks that reference
1120// native-only types (`AppStaticFilesConfig`, `AppLocaleConfig`, etc.) and the
1121// native-only `inventory` re-export. They are therefore `#[cfg(native)]`-gated
1122// individually and not exported on `wasm32-unknown-unknown`.
1123// ============================================================================
1124
1125/// Register static files for an application
1126///
1127/// # Example
1128///
1129/// ```rust,ignore
1130/// use reinhardt_apps::register_app_static_files;
1131/// use std::path::PathBuf;
1132///
1133/// register_app_static_files!(
1134///     "myapp",
1135///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"),
1136///     "/static/myapp/"
1137/// );
1138/// ```
1139#[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/// Register locale directory for an application
1154///
1155/// # Example
1156///
1157/// ```rust,ignore
1158/// use reinhardt_apps::register_app_locale;
1159/// use std::path::PathBuf;
1160///
1161/// register_app_locale!(
1162///     "myapp",
1163///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("locale")
1164/// );
1165/// ```
1166#[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/// Register a custom management command
1180///
1181/// # Example
1182///
1183/// ```rust,ignore
1184/// use reinhardt_apps::{register_app_command, BaseCommand};
1185///
1186/// struct MyCommand;
1187/// impl BaseCommand for MyCommand {
1188///     fn name(&self) -> &str { "mycommand" }
1189///     fn help(&self) -> &str { "My custom command" }
1190///     fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
1191///         Ok(())
1192///     }
1193/// }
1194///
1195/// register_app_command!(
1196///     "myapp",
1197///     "mycommand",
1198///     || Box::new(MyCommand)
1199/// );
1200/// ```
1201#[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/// Register media files directory for an application
1216///
1217/// # Example
1218///
1219/// ```rust,ignore
1220/// use reinhardt_apps::register_app_media;
1221/// use std::path::PathBuf;
1222///
1223/// register_app_media!(
1224///     "myapp",
1225///     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("media"),
1226///     "/media/myapp/"
1227/// );
1228/// ```
1229#[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// ============================================================================
1244// Getter Functions
1245// ============================================================================
1246
1247/// Get all registered static files configurations
1248///
1249/// Returns all static files configurations that have been registered via
1250/// `register_app_static_files!` macro.
1251///
1252/// # Example
1253///
1254/// ```rust
1255/// use reinhardt_apps::get_app_static_files;
1256///
1257/// let configs = get_app_static_files();
1258/// for config in configs {
1259///     println!("App: {}, Dir: {}", config.app_label, config.static_dir);
1260/// }
1261/// ```
1262#[cfg(native)]
1263pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
1264	inventory::iter::<AppStaticFilesConfig>().collect()
1265}
1266
1267/// Get all registered locale configurations
1268///
1269/// Returns all locale configurations that have been registered via
1270/// `register_app_locale!` macro.
1271///
1272/// # Example
1273///
1274/// ```rust
1275/// use reinhardt_apps::get_app_locales;
1276///
1277/// let configs = get_app_locales();
1278/// for config in configs {
1279///     println!("App: {}, Dir: {}", config.app_label, config.locale_dir);
1280/// }
1281/// ```
1282#[cfg(native)]
1283pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
1284	inventory::iter::<AppLocaleConfig>().collect()
1285}
1286
1287/// Get all registered command configurations
1288///
1289/// Returns all command configurations that have been registered via
1290/// `register_app_command!` macro.
1291///
1292/// # Example
1293///
1294/// ```rust
1295/// use reinhardt_apps::get_app_commands;
1296///
1297/// let configs = get_app_commands();
1298/// for config in configs {
1299///     println!("App: {}, Command: {}", config.app_label, config.command_name);
1300/// }
1301/// ```
1302#[cfg(native)]
1303pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
1304	inventory::iter::<AppCommandConfig>().collect()
1305}
1306
1307/// Get all registered media configurations
1308///
1309/// Returns all media configurations that have been registered via
1310/// `register_app_media!` macro.
1311///
1312/// # Example
1313///
1314/// ```rust
1315/// use reinhardt_apps::get_app_media;
1316///
1317/// let configs = get_app_media();
1318/// for config in configs {
1319///     println!("App: {}, Dir: {}", config.app_label, config.media_dir);
1320/// }
1321/// ```
1322#[cfg(native)]
1323pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
1324	inventory::iter::<AppMediaConfig>().collect()
1325}