rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::collections::HashSet;

use indexmap::IndexMap;

use super::{AppConfig, AppError};

pub type AppRegistry = Apps;

/// Central registry for all installed applications.
#[derive(Debug, Default)]
pub struct Apps {
    app_configs: IndexMap<String, AppConfig>,
    ready: bool,
}

impl Apps {
    /// Create an empty application registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Populate the registry with application configurations.
    pub fn populate<I>(&mut self, installed_apps: I) -> Result<(), AppError>
    where
        I: IntoIterator<Item = AppConfig>,
    {
        if self.ready {
            return Ok(());
        }

        let app_configs = installed_apps.into_iter().collect::<Vec<_>>();
        validate_unique_labels(&app_configs)?;
        validate_unique_names(&app_configs)?;

        self.app_configs.clear();
        for app_config in app_configs {
            self.app_configs
                .insert(app_config.label.clone(), app_config);
        }

        for app_config in self.app_configs.values() {
            app_config.ready();
        }

        self.ready = true;
        Ok(())
    }

    /// Return whether the registry has been populated.
    #[must_use]
    pub fn is_ready(&self) -> bool {
        self.ready
    }

    /// Return all registered application configurations.
    #[must_use]
    pub fn get_app_configs(&self) -> Vec<&AppConfig> {
        self.app_configs.values().collect()
    }

    /// Get an application configuration by label.
    #[must_use]
    pub fn get_app_config(&self, label: &str) -> Option<&AppConfig> {
        self.app_configs.get(label)
    }

    /// Check whether an application label is installed.
    #[must_use]
    pub fn is_installed(&self, label: &str) -> bool {
        self.app_configs.contains_key(label)
    }

    /// Return the registered models cache.
    #[must_use]
    pub fn get_models(&self) -> Vec<String> {
        vec![]
    }

    /// Get a model name from a registered app.
    pub fn get_model(&self, app_label: &str, model_name: &str) -> Result<&str, AppError> {
        self.check_apps_ready()?;
        let app_config = self
            .get_app_config(app_label)
            .ok_or_else(|| AppError::NoSuchApp(app_label.to_owned()))?;
        app_config.get_model(model_name)
    }

    /// Check whether a fully qualified application name is installed.
    pub fn has_app_name(&self, app_name: &str) -> Result<bool, AppError> {
        self.check_apps_ready()?;
        Ok(self
            .app_configs
            .values()
            .any(|config| config.name == app_name))
    }

    /// Ensure the registry is ready for application access.
    pub fn check_apps_ready(&self) -> Result<(), AppError> {
        if self.ready {
            Ok(())
        } else {
            Err(AppError::NotReady)
        }
    }

    /// Return the number of registered applications.
    #[must_use]
    pub fn app_count(&self) -> usize {
        self.app_configs.len()
    }
}

fn validate_unique_labels(app_configs: &[AppConfig]) -> Result<(), AppError> {
    let mut seen_labels = HashSet::with_capacity(app_configs.len());
    for app_config in app_configs {
        if !seen_labels.insert(app_config.label.as_str()) {
            return Err(AppError::DuplicateApp {
                kind: "label",
                identifier: app_config.label.clone(),
            });
        }
    }

    Ok(())
}

fn validate_unique_names(app_configs: &[AppConfig]) -> Result<(), AppError> {
    let mut seen_names = HashSet::with_capacity(app_configs.len());
    for app_config in app_configs {
        if !seen_names.insert(app_config.name.as_str()) {
            return Err(AppError::DuplicateApp {
                kind: "name",
                identifier: app_config.name.clone(),
            });
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicUsize, Ordering};

    use super::{AppConfig, AppError, AppRegistry, Apps};

    static READY_CALLS: AtomicUsize = AtomicUsize::new(0);

    fn ready_hook() {
        READY_CALLS.fetch_add(1, Ordering::SeqCst);
    }

    fn config(name: &str) -> AppConfig {
        AppConfig::new(name)
    }

    #[test]
    fn app_registry_alias_matches_apps_type() {
        let registry = AppRegistry::new();

        assert_eq!(registry.app_count(), 0);
    }

    #[test]
    fn apps_new_starts_empty_and_not_ready() {
        let apps = Apps::new();

        assert!(!apps.is_ready());
        assert!(matches!(apps.check_apps_ready(), Err(AppError::NotReady)));
        assert!(apps.get_app_configs().is_empty());
        assert!(apps.get_app_config("blog").is_none());
        assert!(!apps.is_installed("blog"));
        assert!(apps.get_models().is_empty());
        assert_eq!(apps.app_count(), 0);
    }

    #[test]
    fn apps_populate_registers_configs_and_marks_ready() {
        READY_CALLS.store(0, Ordering::SeqCst);
        let mut apps = Apps::new();

        apps.populate([
            config("project.blog")
                .with_models(["Post", "Comment"])
                .with_ready(ready_hook),
            config("project.shop").with_models(["Product"]),
        ])
        .expect("unique apps should populate successfully");

        assert!(apps.is_ready());
        assert_eq!(READY_CALLS.load(Ordering::SeqCst), 1);
        assert_eq!(
            apps.get_model("blog", "post")
                .expect("blog.Post should exist after populate"),
            "Post"
        );

        let labels = apps
            .get_app_configs()
            .into_iter()
            .map(|config| config.label.as_str())
            .collect::<Vec<_>>();

        assert_eq!(labels, vec!["blog", "shop"]);
        assert!(apps.is_installed("blog"));
        assert_eq!(apps.app_count(), 2);
    }

    #[test]
    fn apps_get_app_config_returns_matching_label() {
        let mut apps = Apps::new();
        apps.populate([config("project.blog")])
            .expect("blog app should populate");

        let app = apps
            .get_app_config("blog")
            .expect("blog app should be registered by label");

        assert_eq!(app.name, "project.blog");
    }

    #[test]
    fn apps_get_app_config_returns_none_for_unknown_label() {
        let mut apps = Apps::new();
        apps.populate([config("project.blog")])
            .expect("blog app should populate");

        assert!(apps.get_app_config("missing").is_none());
    }

    #[test]
    fn apps_get_models_returns_empty_stub() {
        let mut apps = Apps::new();
        apps.populate([config("project.blog")])
            .expect("blog app should populate");

        assert!(apps.get_models().is_empty());
    }

    #[test]
    fn apps_populate_rejects_duplicate_labels() {
        let mut apps = Apps::new();

        let error = apps
            .populate([config("project.blog"), config("another.blog")])
            .expect_err("duplicate labels should fail");

        assert!(matches!(
            error,
            AppError::DuplicateApp { kind, identifier }
                if kind == "label" && identifier == "blog"
        ));
        assert!(!apps.is_ready());
    }

    #[test]
    fn apps_populate_rejects_duplicate_names() {
        let mut apps = Apps::new();

        let error = apps
            .populate([
                config("project.blog"),
                config("project.blog").with_label("publishing"),
            ])
            .expect_err("duplicate names should fail");

        assert!(matches!(
            error,
            AppError::DuplicateApp { kind, identifier }
                if kind == "name" && identifier == "project.blog"
        ));
    }

    #[test]
    fn apps_has_app_name_checks_full_app_name_once_ready() {
        let mut apps = Apps::new();
        apps.populate([config("project.blog"), config("project.shop")])
            .expect("apps should populate");

        assert!(
            apps.has_app_name("project.blog")
                .expect("registry should be ready")
        );
        assert!(
            !apps
                .has_app_name("blog")
                .expect("label should not match a full app name")
        );
        assert!(apps.is_installed("blog"));
        assert!(!apps.is_installed("project.blog"));
        assert!(
            !apps
                .has_app_name("project.accounts")
                .expect("missing app should not be installed")
        );
    }
}