cargo-nidus 1.0.1

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

use anyhow::{Context, Result, bail};

pub(crate) fn check_project(root: &Path) -> Result<()> {
    if !root.join("Cargo.toml").exists() {
        bail!(
            "Nidus project check failed for {}. Missing required files: {}",
            root.display(),
            "Cargo.toml"
        );
    }

    if !root.join("src/main.rs").exists() && !root.join("src/lib.rs").exists() {
        bail!(
            "Nidus project check failed for {}. Missing required crate root: src/main.rs or src/lib.rs",
            root.display()
        );
    }

    validate_module_indexes(root)?;
    validate_crate_root_modules(root)?;

    println!("Nidus project check passed for {}", root.display());
    Ok(())
}

fn validate_module_indexes(root: &Path) -> Result<()> {
    for directory in ["modules", "controllers", "services", "repositories"] {
        let directory = root.join("src").join(directory);
        if !directory.exists() {
            continue;
        }

        let source_modules = source_module_names(&directory)?;
        let mod_rs = directory.join("mod.rs");
        if !mod_rs.exists() {
            if let Some(module) = source_modules.first() {
                bail!(
                    "missing module index file {}: add pub mod {}; for {}",
                    mod_rs.display(),
                    module,
                    directory.join(format!("{module}.rs")).display()
                );
            }
            continue;
        }
        let contents =
            fs::read_to_string(&mod_rs).with_context(|| format!("reading {}", mod_rs.display()))?;
        let indexed_modules = contents
            .lines()
            .filter_map(extract_module_index_entry)
            .collect::<BTreeSet<_>>();

        for module in &indexed_modules {
            let expected = mod_rs
                .parent()
                .expect("mod.rs has a parent directory")
                .join(format!("{module}.rs"));
            if !expected.exists() {
                bail!(
                    "stale module index entry in {}: missing {}",
                    mod_rs.display(),
                    expected.display()
                );
            }
        }

        for module in source_modules {
            if !indexed_modules.contains(&module) {
                bail!(
                    "missing module index entry in {}: add pub mod {};",
                    mod_rs.display(),
                    module
                );
            }
        }
    }
    Ok(())
}

fn source_module_names(directory: &Path) -> Result<Vec<String>> {
    let mut modules = Vec::new();
    for entry in
        fs::read_dir(directory).with_context(|| format!("reading {}", directory.display()))?
    {
        let path = entry?.path();
        if path.file_name().and_then(|name| name.to_str()) == Some("mod.rs") {
            continue;
        }
        if path.extension().and_then(|extension| extension.to_str()) == Some("rs")
            && let Some(module) = path.file_stem().and_then(|stem| stem.to_str())
        {
            modules.push(module.to_owned());
        }
    }
    modules.sort();
    Ok(modules)
}

fn validate_crate_root_modules(root: &Path) -> Result<()> {
    let crate_roots = crate_root_contents(root)?;
    for module in ["modules", "controllers", "services", "repositories"] {
        let directory = root.join("src").join(module);
        if !directory.exists() || source_module_names(&directory)?.is_empty() {
            continue;
        }

        if !crate_roots
            .iter()
            .any(|contents| contains_module_declaration(contents, module))
        {
            bail!(
                "missing crate root module declaration for {}: add mod {}; to src/main.rs or pub mod {}; to src/lib.rs",
                directory.display(),
                module,
                module
            );
        }
    }
    Ok(())
}

fn crate_root_contents(root: &Path) -> Result<Vec<String>> {
    let mut roots = Vec::new();
    for path in [root.join("src/main.rs"), root.join("src/lib.rs")] {
        if path.exists() {
            roots.push(
                fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?,
            );
        }
    }
    Ok(roots)
}

fn contains_module_declaration(contents: &str, module: &str) -> bool {
    let private = format!("mod {module};");
    let public = format!("pub mod {module};");
    let crate_public = format!("pub(crate) mod {module};");
    contents
        .lines()
        .map(str::trim)
        .any(|line| line == private || line == public || line == crate_public)
}

fn extract_module_index_entry(line: &str) -> Option<String> {
    let line = line.trim();
    let module = line.strip_prefix("pub mod ")?;
    let module = module.strip_suffix(';')?;
    let module = module.trim();
    if module.is_empty() {
        None
    } else {
        Some(module.to_owned())
    }
}