cargo-nidus 1.0.3

Command-line project generator and inspection tooling for Nidus applications.
use std::{fs, path::Path};

use anyhow::{Context, Result};

use crate::generate_name::to_pascal_case;

pub(crate) fn artifact(kind: &str, name: &str, module_name: &str, root: &Path) -> String {
    let type_name = to_pascal_case(module_name);
    match kind {
        "module" => module_artifact(root, module_name),
        "controller" => format!(
            r#"use nidus::prelude::*;

#[controller("/{name}")]
#[allow(dead_code)]
pub struct {type_name}Controller;

#[routes]
#[allow(dead_code)]
impl {type_name}Controller {{
    #[get("/")]
    pub async fn index(&self) {{}}
}}
"#
        ),
        "service" => format!(
            r#"use nidus::prelude::*;

#[injectable]
#[allow(dead_code)]
pub struct {type_name}Service;
"#
        ),
        "repository" => format!(
            r#"use nidus::prelude::*;

#[injectable]
#[allow(dead_code)]
pub struct {type_name}Repository;
"#
        ),
        _ => unreachable!("artifact kind should be validated before rendering"),
    }
}

pub(crate) fn sync_generated_feature_module(root: &Path, module_name: &str) -> Result<()> {
    let path = root
        .join("src")
        .join("modules")
        .join(format!("{module_name}.rs"));
    if !path.exists() {
        return Ok(());
    }

    let type_name = to_pascal_case(module_name);
    let contents =
        fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
    if !is_generated_module_artifact(&contents, &type_name) {
        return Ok(());
    }

    write(&path, &module_artifact(root, module_name))
}

fn is_generated_module_artifact(contents: &str, type_name: &str) -> bool {
    contents.contains("use nidus::prelude::*;")
        && contents.contains("#[module")
        && contents.contains("#[allow(dead_code)]")
        && contents.contains(&format!("pub struct {type_name}Module;"))
        && !contents.contains('{')
}

fn module_artifact(root: &Path, module_name: &str) -> String {
    let type_name = to_pascal_case(module_name);
    let metadata = feature_module_metadata(root, module_name);
    let module_attr = metadata.render_module_attr();
    format!(
        r#"use nidus::prelude::*;

{module_attr}
#[allow(dead_code)]
pub struct {type_name}Module;
"#
    )
}

#[derive(Default)]
struct FeatureModuleMetadata {
    providers: Vec<String>,
    controllers: Vec<String>,
    exports: Vec<String>,
}

impl FeatureModuleMetadata {
    fn render_module_attr(&self) -> String {
        let mut sections = Vec::new();
        if !self.providers.is_empty() {
            sections.push(format!("providers({})", self.providers.join(", ")));
        }
        if !self.controllers.is_empty() {
            sections.push(format!("controllers({})", self.controllers.join(", ")));
        }
        if !self.exports.is_empty() {
            sections.push(format!("exports({})", self.exports.join(", ")));
        }

        if sections.is_empty() {
            "#[module]".to_owned()
        } else {
            format!("#[module(\n    {}\n)]", sections.join(",\n    "))
        }
    }
}

fn feature_module_metadata(root: &Path, module_name: &str) -> FeatureModuleMetadata {
    let mut metadata = FeatureModuleMetadata::default();

    if artifact_exists(root, "repositories", module_name) {
        metadata
            .providers
            .push(feature_type_path("repositories", module_name, "Repository"));
    }

    if artifact_exists(root, "services", module_name) {
        let service = feature_type_path("services", module_name, "Service");
        metadata.providers.push(service.clone());
        metadata.exports.push(service);
    }

    if artifact_exists(root, "controllers", module_name) {
        metadata
            .controllers
            .push(feature_type_path("controllers", module_name, "Controller"));
    }

    metadata
}

fn artifact_exists(root: &Path, directory: &str, module_name: &str) -> bool {
    root.join("src")
        .join(directory)
        .join(format!("{module_name}.rs"))
        .exists()
}

fn feature_type_path(directory: &str, module_name: &str, suffix: &str) -> String {
    format!(
        "crate::{directory}::{module_name}::{}{suffix}",
        to_pascal_case(module_name)
    )
}

fn write(path: &Path, contents: &str) -> Result<()> {
    fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::FeatureModuleMetadata;

    #[test]
    fn module_metadata_renders_empty_module_attribute() {
        assert_eq!(
            FeatureModuleMetadata::default().render_module_attr(),
            "#[module]"
        );
    }

    #[test]
    fn module_metadata_renders_populated_sections() {
        let metadata = FeatureModuleMetadata {
            providers: vec!["UsersRepository".to_owned(), "UsersService".to_owned()],
            controllers: vec!["UsersController".to_owned()],
            exports: vec!["UsersService".to_owned()],
        };

        assert_eq!(
            metadata.render_module_attr(),
            "#[module(\n    providers(UsersRepository, UsersService),\n    controllers(UsersController),\n    exports(UsersService)\n)]"
        );
    }
}