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\" }".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 = ApiDefaults::production("__NIDUS_SERVICE_NAME__")
.without_metrics()
.apply(HelloController.into_router());
Nidus::bootstrap::<AppModule>()?
.with_router(app)
.listen(address)
.await?;
Ok(())
}
#[controller("/")]
struct HelloController;
#[routes]
impl HelloController {
#[get("/")]
async fn hello(&self) -> &'static str {
"hello from nidus"
}
}
#[module]
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
```
"#
),
)?;
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"),
}
}