nest-rs-cli 3.0.0

Scaffolding CLI for NestRS — new projects, feature generators, and project health checks.
//! The `nestrs new` command: infer the layout from the tree and scaffold it
//! through one of the [`standalone`] / [`workspace`] strategies.

//! **One starter, no template flag.** Every layout writes the shared
//! [`hello`](crate::templates::hello) module — a service with a greeting and a
//! `#[public] GET /`. A freshly created project has to prove it started, and a
//! `404` proves nothing to the developer looking at a browser, so there is no
//! routeless variant to pick.

use std::path::{Path, PathBuf};
use std::process::Command;

use super::{standalone, workspace};
use crate::context::{DEFAULT_ENV_PREFIX, NestrsWorkspace};
use crate::error::{CliError, CliResult};
use crate::naming::Names;
use crate::scaffold::{Renderer, Scaffold};
use crate::templates::shared;

#[derive(Debug, Clone)]
pub struct NewOptions {
    pub name: String,
    pub output: PathBuf,
    pub standalone: bool,
    /// `None` ⇒ the framework default (`NESTRS`).
    pub env_prefix: Option<String>,
    pub dry_run: bool,
}

pub fn run(opts: NewOptions) -> CliResult<()> {
    // Reject a name that would derive an invalid crate identifier (e.g.
    // `"Bad Name!"` → `bad-name!`) before scaffolding a project that won't
    // compile (CLI-I6).
    crate::naming::validate_feature_name(&opts.name).map_err(CliError::InvalidFeatureName)?;
    let names = Names::parse(&opts.name);

    if let Some(prefix) = &opts.env_prefix {
        crate::context::validate_env_prefix(prefix)
            .map_err(|e| CliError::Anyhow(anyhow::anyhow!(e)))?;
    }
    let env_prefix = opts.env_prefix.as_deref().unwrap_or(DEFAULT_ENV_PREFIX);

    if opts.standalone {
        return standalone::scaffold(&opts.output, &names, env_prefix, opts.dry_run);
    }

    if let Some(ws) = NestrsWorkspace::discover(&opts.output)? {
        // The prefix belongs to the deployment, not to a crate: an app added to
        // an existing project inherits whatever its environment names, and
        // silently ignoring the flag would leave the caller believing it took.
        if opts.env_prefix.is_some() {
            return Err(CliError::Anyhow(anyhow::anyhow!(
                "`--env-prefix` applies to project creation only — an app added to an \
                 existing workspace inherits the project's prefix. Set `{}` in the \
                 environment that runs it (the `Justfile`, your container, your shell).",
                crate::context::ENV_PREFIX_VAR,
            )));
        }
        return workspace::scaffold_app(&ws, &names, opts.dry_run);
    }

    workspace::scaffold_root(&opts.output, &names, env_prefix, opts.dry_run)
}

/// Seed every prefix placeholder — the value templates interpolate into
/// variable names, and the two lines that *set* it for the processes this
/// project starts.
///
/// Both setters are empty on the default, so an ordinary project carries no
/// noise about a prefix it never changed. There is no third site and no file in
/// the source tree: the runtime reads the prefix from the environment, so
/// anything a crate said about it would be decoration.
///
/// The `.env` cascade deliberately does **not** carry it. It is read *after* the
/// prefix has already selected which cascade to read, so a value placed there
/// would rename nothing — the framework aborts on it rather than let that pass.
pub(crate) fn with_env_prefix(r: Renderer, env_prefix: &str) -> Renderer {
    let r = r.with("env_prefix", env_prefix);
    if env_prefix == DEFAULT_ENV_PREFIX {
        // `Renderer::new` already seeds both setters empty, which is what a
        // project on the default wants.
        return r;
    }
    let export = r.render(shared::ENV_PREFIX_JUSTFILE);
    let env = r.render(shared::ENV_PREFIX_DOCKERFILE);
    r.with("env_prefix_export", export)
        .with("env_prefix_env", env)
}

pub fn project_dir_for_check(opts: &NewOptions, names: &Names) -> CliResult<PathBuf> {
    if opts.standalone {
        return Ok(opts.output.join(&names.kebab));
    }
    if let Some(ws) = NestrsWorkspace::discover(&opts.output)? {
        return Ok(ws.apps_root().join(&names.kebab));
    }
    Ok(opts.output.join(&names.kebab))
}

/// Queue the committed `.env` cascade (`.env`, `.env.development`, `.env.example`).
///
/// Every key in those files is written through `{{env_prefix}}`, so a project
/// created with `--env-prefix` gets a cascade its app actually reads.
pub(crate) fn queue_env_files(
    s: &mut Scaffold,
    base: &Path,
    r: &Renderer,
    env_label: &str,
    env_template: &str,
) {
    // The caller's renderer, which already carries the prefix — building a
    // second one here is how the two could be seeded from different values.
    let r = r.clone().with("env_label", env_label);
    s.create_if_missing(base.join(".env"), r.render(env_template));
    s.create_if_missing(
        base.join(".env.development"),
        r.render(shared::ENV_DEVELOPMENT),
    );
    s.create_if_missing(base.join(".env.example"), r.render(shared::ENV_EXAMPLE));
}

pub fn run_cargo_check(project_dir: &Path) -> CliResult<()> {
    let status = Command::new("cargo")
        .arg("check")
        .current_dir(project_dir)
        .status()
        .map_err(CliError::Io)?;
    if !status.success() {
        return Err(CliError::Anyhow(anyhow::anyhow!(
            "cargo check failed in {}",
            project_dir.display()
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::templates::{hello, standalone, workspace};

    /// The starter's whole promise: whichever layout renders it, the controller
    /// mounts `/` and declares its posture. A template that stopped emitting
    /// either would ship a project answering 404 on its first page.
    #[test]
    fn the_shared_hello_controller_mounts_root_as_public() {
        assert!(hello::CONTROLLER.contains(r#"#[controller(path = "/")]"#));
        assert!(hello::CONTROLLER.contains(r#"#[get("/")]"#));
        assert!(hello::CONTROLLER.contains("#[public]"));
    }

    /// Both layouts must actually reach it — the standalone crate through its
    /// `providers` list, a workspace app through the feature's HTTP module.
    #[test]
    fn both_layouts_wire_the_hello_controller_in() {
        assert!(standalone::MODULE.contains("providers = [{{service}}, {{controller}}]"));
        assert!(workspace::APP_MODULE.contains("{{http_module}},"));
        assert!(hello::FEATURE_HTTP_MODULE.contains("providers = [{{controller}}]"));
    }
}