cargo-nidus 1.0.1

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

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

use crate::artifact_template::{artifact, sync_generated_feature_module};
use crate::generate_name::to_snake_case;

pub(crate) fn create_project(name: &str, root: &Path, nidus_path: Option<&Path>) -> Result<()> {
    let project = root.join(name);
    if project.exists() {
        bail!("project already exists: {}", project.display());
    }
    let src = project.join("src");
    fs::create_dir_all(&src).with_context(|| format!("creating {}", src.display()))?;
    let nidus_dependency = nidus_path
        .map(|path| {
            format!(
                "{{ package = \"nidus-rs\", path = {:?} }}",
                path.display().to_string()
            )
        })
        .unwrap_or_else(|| "{ package = \"nidus-rs\", version = \"1.0.1\" }".to_owned());
    write(
        &project.join("Cargo.toml"),
        &format!(
            r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"

[dependencies]
nidus = {nidus_dependency}
"#
        ),
    )?;
    let main_rs = r#"use nidus::prelude::*;

#[nidus::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    let address = std::env::var("NIDUS_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".to_owned());
    let app = Nidus::create::<AppModule>()
        .build()
        .await?
        .map_router(|router| {
            ApiDefaults::production("__NIDUS_SERVICE_NAME__")
                .without_metrics()
                .apply(router)
        });

    app.listen(address)
        .await?;
    Ok(())
}

#[injectable]
struct GreetingService;

impl GreetingService {
    fn greeting(&self) -> &'static str {
        "hello from nidus"
    }
}

#[controller("/")]
struct HelloController {
    greeting: Inject<GreetingService>,
}

#[routes]
impl HelloController {
    #[get("/")]
    async fn hello(&self) -> &'static str {
        self.greeting.greeting()
    }
}

#[module(
    providers(GreetingService),
    controllers(HelloController)
)]
struct AppModule;
"#
    .replace("__NIDUS_SERVICE_NAME__", name);
    write(&src.join("main.rs"), &main_rs)?;
    write(
        &project.join("README.md"),
        &format!(
            r#"# {name}

Generated by `cargo nidus new`.

## Run

```bash
cargo run
```

The server listens on `127.0.0.1:3000` by default. Set `NIDUS_ADDR` to bind a
different address:

```bash
NIDUS_ADDR=127.0.0.1:4000 cargo run
```

Check the HTTP route:

```bash
curl http://127.0.0.1:3000/
```

## Test

```bash
cargo test
```

## Generate

Add a feature controller when the application grows:

```bash
cargo nidus generate controller users
```

## Inspect

```bash
cargo nidus routes
cargo nidus graph
cargo nidus openapi
cargo nidus check
```

## Next steps

- Move domain behavior into services registered on a module.
- Add `config`, `openapi`, or `validation` facade features when the app needs them.
- Install adapter crates such as `nidus-sqlx` or `nidus-cache` only when choosing those backends.
"#
        ),
    )?;
    Ok(())
}

pub(crate) fn generate_artifact(kind: &str, name: &str, root: &Path) -> Result<()> {
    ensure_supported_artifact(kind)?;
    let module_name = to_snake_case(name);
    if module_name.is_empty() {
        bail!("artifact name must contain at least one ASCII letter or digit");
    }
    if !module_name.starts_with(|character: char| character.is_ascii_alphabetic()) {
        bail!("artifact name must start with an ASCII letter after normalization");
    }
    let directory_name = pluralize(kind);
    let directory = root.join("src").join(&directory_name);
    fs::create_dir_all(&directory).with_context(|| format!("creating {}", directory.display()))?;
    let path = directory.join(format!("{module_name}.rs"));
    if path.exists() {
        bail!("artifact already exists: {}", path.display());
    }
    write(&path, &artifact(kind, name, &module_name, root))?;
    update_module_index(&directory, &module_name)?;
    update_crate_root_module(root, &directory_name)?;
    if kind != "module" {
        sync_generated_feature_module(root, &module_name)?;
    }
    Ok(())
}

fn ensure_supported_artifact(kind: &str) -> Result<()> {
    if matches!(kind, "module" | "controller" | "service" | "repository") {
        Ok(())
    } else {
        bail!(
            "unsupported artifact kind `{kind}`. Expected one of: module, controller, service, repository"
        );
    }
}

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

fn update_module_index(directory: &Path, name: &str) -> Result<()> {
    let path = directory.join("mod.rs");
    let entry = format!("pub mod {name};");
    let mut entries = if path.exists() {
        fs::read_to_string(&path)
            .with_context(|| format!("reading {}", path.display()))?
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(str::to_owned)
            .collect::<Vec<_>>()
    } else {
        Vec::new()
    };

    if !entries.iter().any(|existing| existing == &entry) {
        entries.push(entry);
        entries.sort();
    }

    let contents = if entries.is_empty() {
        String::new()
    } else {
        format!("{}\n", entries.join("\n"))
    };
    write(&path, &contents)
}

fn update_crate_root_module(root: &Path, module: &str) -> Result<()> {
    let src = root.join("src");
    let (path, visibility) = if src.join("lib.rs").exists() {
        (src.join("lib.rs"), "pub ")
    } else if src.join("main.rs").exists() {
        (src.join("main.rs"), "")
    } else {
        return Ok(());
    };
    let contents =
        fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
    let private_entry = format!("mod {module};");
    let public_entry = format!("pub mod {module};");
    if contents
        .lines()
        .map(str::trim)
        .any(|line| line == private_entry || line == public_entry)
    {
        return Ok(());
    }

    let mut updated = contents;
    if !updated.ends_with('\n') {
        updated.push('\n');
    }
    updated.push_str(&format!("{visibility}mod {module};\n"));
    write(&path, &updated)
}

fn pluralize(kind: &str) -> String {
    match kind {
        "module" => "modules".to_owned(),
        "controller" => "controllers".to_owned(),
        "service" => "services".to_owned(),
        "repository" => "repositories".to_owned(),
        other => format!("{other}s"),
    }
}