use crate::signals;
use std::collections::HashMap;
use std::error::Error;
use std::sync::{Arc, Mutex, PoisonError};
use thiserror::Error as ThisError;
#[derive(Debug, ThisError)]
pub enum AppError {
#[error("Application not found: {0}")]
NotFound(String),
#[error("Application already registered: {0}")]
AlreadyRegistered(String),
#[error("Invalid application label: {0}")]
InvalidLabel(String),
#[error("Duplicate application label: {0}")]
DuplicateLabel(String),
#[error("Duplicate application name: {0}")]
DuplicateName(String),
#[error("Application registry not ready")]
NotReady,
#[error("Application configuration error: {0}")]
ConfigError(String),
#[error("Registry state error: {0}")]
RegistryState(String),
}
pub type AppResult<T> = Result<T, AppError>;
#[derive(Clone, Debug)]
pub struct AppConfig {
pub name: String,
pub label: String,
pub verbose_name: Option<String>,
pub path: Option<String>,
pub default_auto_field: Option<String>,
pub models_ready: bool,
}
#[cfg(native)]
pub use reinhardt_utils::staticfiles::vendor::AppVendorAsset;
impl AppConfig {
pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
Self {
name: name.into(),
label: label.into(),
verbose_name: None,
path: None,
default_auto_field: None,
models_ready: false,
}
}
pub fn with_verbose_name(mut self, verbose_name: impl Into<String>) -> Self {
self.verbose_name = Some(verbose_name.into());
self
}
pub fn with_path(mut self, path: impl Into<String>) -> AppResult<Self> {
let path = path.into();
Self::validate_path(&path)?;
self.path = Some(path);
Ok(self)
}
fn validate_path(path: &str) -> AppResult<()> {
if path.is_empty() {
return Err(AppError::ConfigError(
"application path cannot be empty".to_string(),
));
}
if path.contains('\0') {
return Err(AppError::ConfigError(
"application path must not contain null bytes".to_string(),
));
}
if path.chars().any(|c| c.is_control()) {
return Err(AppError::ConfigError(
"application path must not contain control characters".to_string(),
));
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(AppError::ConfigError(
"application path must be relative, not absolute".to_string(),
));
}
if path.len() >= 2 && path.as_bytes()[0].is_ascii_alphabetic() && path.as_bytes()[1] == b':'
{
return Err(AppError::ConfigError(
"application path must be relative, not absolute".to_string(),
));
}
for component in path.split(['/', '\\']) {
if component == ".." {
return Err(AppError::ConfigError(
"application path must not contain path traversal sequences".to_string(),
));
}
}
Ok(())
}
pub fn with_default_auto_field(mut self, field: impl Into<String>) -> Self {
self.default_auto_field = Some(field.into());
self
}
pub fn validate_label(&self) -> AppResult<()> {
if self.label.is_empty() {
return Err(AppError::InvalidLabel("Label cannot be empty".to_string()));
}
if !self
.label
.chars()
.next()
.map(|c| c.is_alphabetic() || c == '_')
.unwrap_or(false)
{
return Err(AppError::InvalidLabel(format!(
"Label '{}' must start with a letter or underscore",
self.label
)));
}
if !self.label.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(AppError::InvalidLabel(format!(
"Label '{}' must contain only alphanumeric characters and underscores",
self.label
)));
}
Ok(())
}
pub fn ready(&self) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
pub trait StaticFilesProvider {
fn static_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn static_url_prefix(&self) -> Option<String> {
None
}
}
pub trait LocaleProvider {
fn locale_dir(&self) -> Option<std::path::PathBuf> {
None
}
}
pub trait MediaProvider {
fn media_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn media_url_prefix(&self) -> Option<String> {
None
}
}
impl StaticFilesProvider for AppConfig {
fn static_dir(&self) -> Option<std::path::PathBuf> {
if let Some(path) = &self.path {
let static_path = std::path::PathBuf::from(path).join("static");
if static_path.exists() && static_path.is_dir() {
return Some(static_path);
}
}
None
}
fn static_url_prefix(&self) -> Option<String> {
Some(format!("/static/{}/", self.label))
}
}
impl LocaleProvider for AppConfig {
fn locale_dir(&self) -> Option<std::path::PathBuf> {
if let Some(path) = &self.path {
let locale_path = std::path::PathBuf::from(path).join("locale");
if locale_path.exists() && locale_path.is_dir() {
return Some(locale_path);
}
}
None
}
}
impl MediaProvider for AppConfig {
fn media_dir(&self) -> Option<std::path::PathBuf> {
if let Some(path) = &self.path {
let media_path = std::path::PathBuf::from(path).join("media");
if media_path.exists() && media_path.is_dir() {
return Some(media_path);
}
}
None
}
fn media_url_prefix(&self) -> Option<String> {
Some(format!("/media/{}/", self.label))
}
}
#[derive(Clone)]
pub struct Apps {
installed_apps: Vec<String>,
app_configs: Arc<Mutex<HashMap<String, AppConfig>>>,
app_names: Arc<Mutex<HashMap<String, String>>>,
ready: Arc<Mutex<bool>>,
apps_ready: Arc<Mutex<bool>>,
models_ready: Arc<Mutex<bool>>,
}
impl Apps {
pub fn new(installed_apps: Vec<String>) -> Self {
Self {
installed_apps,
app_configs: Arc::new(Mutex::new(HashMap::new())),
app_names: Arc::new(Mutex::new(HashMap::new())),
ready: Arc::new(Mutex::new(false)),
apps_ready: Arc::new(Mutex::new(false)),
models_ready: Arc::new(Mutex::new(false)),
}
}
pub fn is_ready(&self) -> bool {
*self.ready.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn is_apps_ready(&self) -> bool {
*self
.apps_ready
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub fn is_models_ready(&self) -> bool {
*self
.models_ready
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub fn register(&self, config: AppConfig) -> AppResult<()> {
config.validate_label()?;
let mut configs = self
.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner);
let mut names = self
.app_names
.lock()
.unwrap_or_else(PoisonError::into_inner);
if configs.contains_key(&config.label) {
return Err(AppError::DuplicateLabel(config.label.clone()));
}
if names.contains_key(&config.name) {
return Err(AppError::DuplicateName(config.name.clone()));
}
names.insert(config.name.clone(), config.label.clone());
configs.insert(config.label.clone(), config);
Ok(())
}
pub fn get_app_config(&self, label: &str) -> AppResult<AppConfig> {
self.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(label)
.cloned()
.ok_or_else(|| AppError::NotFound(label.to_string()))
}
pub fn get_app_configs(&self) -> Vec<AppConfig> {
self.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner)
.values()
.cloned()
.collect()
}
pub fn is_installed(&self, name: &str) -> bool {
if self.installed_apps.contains(&name.to_string()) {
return true;
}
let names = self
.app_names
.lock()
.unwrap_or_else(PoisonError::into_inner);
let configs = self
.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner);
names.contains_key(name) || configs.contains_key(name)
}
pub fn populate(&self) -> AppResult<()> {
*self
.apps_ready
.lock()
.unwrap_or_else(PoisonError::into_inner) = true;
{
let mut seen = std::collections::HashSet::new();
for app_name in &self.installed_apps {
if !seen.insert(app_name) {
return Err(AppError::DuplicateLabel(app_name.clone()));
}
}
}
for app_name in &self.installed_apps {
let app_config = AppConfig::new(app_name.clone(), app_name.clone());
let mut configs = self
.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner);
if configs.contains_key(&app_config.label) {
continue;
}
configs.insert(app_config.label.clone(), app_config.clone());
drop(configs);
self.app_names
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(app_name.clone(), app_config.label.clone());
}
let configs = self
.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner);
for app_config in configs.values() {
app_config.ready().map_err(|e| {
AppError::ConfigError(format!(
"Ready hook failed for app '{}': {}",
app_config.label, e
))
})?;
signals::app_ready().send(app_config);
}
drop(configs);
#[cfg(native)]
if !*self
.models_ready
.lock()
.unwrap_or_else(PoisonError::into_inner)
{
crate::discovery::build_reverse_relations()?;
crate::registry::finalize_reverse_relations();
}
*self
.models_ready
.lock()
.unwrap_or_else(PoisonError::into_inner) = true;
*self.ready.lock().unwrap_or_else(PoisonError::into_inner) = true;
Ok(())
}
pub fn clear_cache(&self) {
self.app_configs
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
self.app_names
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
*self.ready.lock().unwrap_or_else(PoisonError::into_inner) = false;
*self
.apps_ready
.lock()
.unwrap_or_else(PoisonError::into_inner) = false;
*self
.models_ready
.lock()
.unwrap_or_else(PoisonError::into_inner) = false;
}
}
#[cfg(feature = "di")]
mod di_integration {
use super::*;
use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
#[async_trait::async_trait]
impl Injectable for Apps {
async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
if let Some(apps) = ctx.get_singleton::<Apps>() {
return Ok((*apps).clone());
}
Err(DiError::NotFound(std::any::type_name::<Apps>().to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use serial_test::serial;
#[rstest]
fn test_app_config_creation() {
let config = AppConfig::new("myapp", "myapp")
.with_verbose_name("My Application")
.with_default_auto_field("BigAutoField");
assert_eq!(config.name, "myapp");
assert_eq!(config.label, "myapp");
assert_eq!(config.verbose_name, Some("My Application".to_string()));
assert_eq!(config.default_auto_field, Some("BigAutoField".to_string()));
}
#[rstest]
fn test_app_config_validation() {
let valid = AppConfig::new("myapp", "myapp");
let invalid = AppConfig::new("myapp", "my-app");
let empty = AppConfig::new("myapp", "");
assert!(valid.validate_label().is_ok());
assert!(invalid.validate_label().is_err());
assert!(empty.validate_label().is_err());
}
#[rstest]
fn test_apps_registry() {
let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
assert!(apps.is_installed("myapp"));
assert!(apps.is_installed("anotherapp"));
assert!(!apps.is_installed("notinstalled"));
}
#[rstest]
fn test_register_app() {
let apps = Apps::new(vec![]);
let config = AppConfig::new("myapp", "myapp");
assert!(apps.register(config).is_ok());
assert!(apps.get_app_config("myapp").is_ok());
}
#[rstest]
fn test_duplicate_registration() {
let apps = Apps::new(vec![]);
let config1 = AppConfig::new("myapp", "myapp");
let config2 = AppConfig::new("myapp", "myapp");
apps.register(config1).unwrap();
let result = apps.register(config2);
assert!(result.is_err());
}
#[rstest]
fn test_get_app_configs() {
let apps = Apps::new(vec![]);
apps.register(AppConfig::new("app1", "app1")).unwrap();
apps.register(AppConfig::new("app2", "app2")).unwrap();
let configs = apps.get_app_configs();
assert_eq!(configs.len(), 2);
}
#[rstest]
#[serial(apps_registry)]
fn test_populate() {
crate::registry::reset_global_registry();
let apps = Apps::new(vec![]);
assert!(!apps.is_ready());
apps.populate().unwrap();
assert!(apps.is_ready());
assert!(apps.is_apps_ready());
assert!(apps.is_models_ready());
}
#[rstest]
#[serial(apps_registry)]
fn test_populate_with_installed_apps() {
crate::registry::reset_global_registry();
let apps = Apps::new(vec!["myapp".to_string(), "anotherapp".to_string()]);
assert!(!apps.is_ready());
let result = apps.populate();
assert!(result.is_ok());
assert!(apps.is_ready());
assert!(apps.is_apps_ready());
assert!(apps.is_models_ready());
assert!(apps.get_app_config("myapp").is_ok());
assert!(apps.get_app_config("anotherapp").is_ok());
let myapp_config = apps.get_app_config("myapp").unwrap();
assert_eq!(myapp_config.label, "myapp");
}
#[rstest]
#[case("apps/myapp")]
#[case("myapp")]
#[case("src/apps/myapp")]
#[case("my_app")]
#[case("my-app")]
fn test_with_path_accepts_valid_relative_paths(#[case] path: &str) {
let result = AppConfig::new("myapp", "myapp").with_path(path);
assert!(result.is_ok(), "expected valid path: {path}");
assert_eq!(result.unwrap().path, Some(path.to_string()));
}
#[rstest]
fn test_with_path_rejects_empty() {
let result = AppConfig::new("myapp", "myapp").with_path("");
let err = result.unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[rstest]
#[case("../etc/passwd")]
#[case("apps/../../../etc/shadow")]
#[case("apps/..")]
fn test_with_path_rejects_traversal(#[case] path: &str) {
let result = AppConfig::new("myapp", "myapp").with_path(path);
let err = result.unwrap_err();
assert!(
err.to_string().contains("path traversal"),
"expected traversal error for '{path}', got: {err}"
);
}
#[rstest]
#[case("/etc/passwd")]
#[case("/absolute/path")]
#[case("\\windows\\path")]
#[case("C:\\Windows\\System32")]
#[case("D:/data")]
fn test_with_path_rejects_absolute(#[case] path: &str) {
let result = AppConfig::new("myapp", "myapp").with_path(path);
let err = result.unwrap_err();
assert!(
err.to_string().contains("relative, not absolute"),
"expected absolute path error for '{path}', got: {err}"
);
}
#[rstest]
fn test_with_path_rejects_null_bytes() {
let result = AppConfig::new("myapp", "myapp").with_path("apps/my\0app");
let err = result.unwrap_err();
assert!(err.to_string().contains("null bytes"));
}
#[rstest]
#[case("apps/my\napp")]
#[case("apps/my\rapp")]
fn test_with_path_rejects_control_chars(#[case] path: &str) {
let result = AppConfig::new("myapp", "myapp").with_path(path);
let err = result.unwrap_err();
assert!(
err.to_string().contains("control characters"),
"expected control char error for path, got: {err}"
);
}
}
pub trait AppLabel {
const LABEL: &'static str;
fn path(&self) -> &'static str {
Self::LABEL
}
}
impl Apps {
pub fn get_app_config_typed<A: AppLabel>(&self) -> AppResult<AppConfig> {
self.get_app_config(A::LABEL)
}
pub fn is_installed_typed<A: AppLabel>(&self) -> bool {
self.is_installed(A::LABEL)
}
}
#[cfg(test)]
mod typed_tests {
use super::*;
struct AuthApp;
impl AppLabel for AuthApp {
const LABEL: &'static str = "auth";
}
struct ContentTypesApp;
impl AppLabel for ContentTypesApp {
const LABEL: &'static str = "contenttypes";
}
struct SessionsApp;
impl AppLabel for SessionsApp {
const LABEL: &'static str = "sessions";
}
#[test]
fn test_typed_is_installed() {
let apps = Apps::new(vec!["auth".to_string(), "contenttypes".to_string()]);
assert!(apps.is_installed_typed::<AuthApp>());
assert!(apps.is_installed_typed::<ContentTypesApp>());
assert!(!apps.is_installed_typed::<SessionsApp>());
}
#[test]
fn test_typed_get_app_config() {
let apps = Apps::new(vec![]);
let config = AppConfig::new("auth", "auth");
apps.register(config).unwrap();
let retrieved = apps.get_app_config_typed::<AuthApp>();
assert!(retrieved.is_ok());
assert_eq!(retrieved.unwrap().label, "auth");
}
#[test]
fn test_typed_get_app_config_not_found() {
let apps = Apps::new(vec![]);
let result = apps.get_app_config_typed::<SessionsApp>();
assert!(result.is_err());
if let Err(AppError::NotFound(label)) = result {
assert_eq!(label, "sessions");
}
}
#[test]
fn test_apps_typed_and_regular_mixed() {
let apps = Apps::new(vec!["auth".to_string()]);
let config = AppConfig::new("auth", "auth");
apps.register(config).unwrap();
assert!(apps.is_installed_typed::<AuthApp>());
assert!(apps.is_installed("auth"));
let typed = apps.get_app_config_typed::<AuthApp>().unwrap();
let regular = apps.get_app_config("auth").unwrap();
assert_eq!(typed.label, regular.label);
}
}
#[cfg(native)]
pub trait BaseCommand: Send + Sync {
fn name(&self) -> &str;
fn help(&self) -> &str;
fn execute(&mut self, args: Vec<String>) -> Result<(), Box<dyn std::error::Error>>;
}
#[cfg(native)]
pub struct AppStaticFilesConfig {
pub app_label: &'static str,
pub static_dir: &'static str,
pub url_prefix: &'static str,
}
#[cfg(native)]
inventory::collect!(AppStaticFilesConfig);
#[cfg(native)]
pub struct AppLocaleConfig {
pub app_label: &'static str,
pub locale_dir: &'static str,
}
#[cfg(native)]
inventory::collect!(AppLocaleConfig);
#[cfg(native)]
pub struct AppCommandConfig {
pub app_label: &'static str,
pub command_name: &'static str,
pub command_fn: fn() -> Box<dyn BaseCommand>,
}
#[cfg(native)]
inventory::collect!(AppCommandConfig);
#[cfg(native)]
pub struct AppMediaConfig {
pub app_label: &'static str,
pub media_dir: &'static str,
pub url_prefix: &'static str,
}
#[cfg(native)]
inventory::collect!(AppMediaConfig);
#[cfg(native)]
#[macro_export]
macro_rules! register_app_static_files {
($app_label:expr, $static_dir:expr, $url_prefix:expr) => {
$crate::inventory::submit! {
$crate::AppStaticFilesConfig {
app_label: $app_label,
static_dir: $static_dir,
url_prefix: $url_prefix,
}
}
};
}
#[cfg(native)]
#[macro_export]
macro_rules! register_app_locale {
($app_label:expr, $locale_dir:expr) => {
$crate::inventory::submit! {
$crate::AppLocaleConfig {
app_label: $app_label,
locale_dir: $locale_dir,
}
}
};
}
#[cfg(native)]
#[macro_export]
macro_rules! register_app_command {
($app_label:expr, $command_name:expr, $command_fn:expr) => {
$crate::inventory::submit! {
$crate::AppCommandConfig {
app_label: $app_label,
command_name: $command_name,
command_fn: $command_fn,
}
}
};
}
#[cfg(native)]
#[macro_export]
macro_rules! register_app_media {
($app_label:expr, $media_dir:expr, $url_prefix:expr) => {
$crate::inventory::submit! {
$crate::AppMediaConfig {
app_label: $app_label,
media_dir: $media_dir,
url_prefix: $url_prefix,
}
}
};
}
#[cfg(native)]
pub fn get_app_static_files() -> Vec<&'static AppStaticFilesConfig> {
inventory::iter::<AppStaticFilesConfig>().collect()
}
#[cfg(native)]
pub fn get_app_locales() -> Vec<&'static AppLocaleConfig> {
inventory::iter::<AppLocaleConfig>().collect()
}
#[cfg(native)]
pub fn get_app_commands() -> Vec<&'static AppCommandConfig> {
inventory::iter::<AppCommandConfig>().collect()
}
#[cfg(native)]
pub fn get_app_media() -> Vec<&'static AppMediaConfig> {
inventory::iter::<AppMediaConfig>().collect()
}