ironic 0.2.4

A batteries-included, type-safe application framework for Rust
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
};

use super::{naming::Names, source::write_generated};
use crate::CliError;

/// Result of creating a new project.
#[derive(Debug)]
pub struct ProjectReport {
    /// Created project directory.
    pub destination: PathBuf,
}

/// Returns the normalized destination directory for a project name.
///
/// # Errors
///
/// Returns [`CliError`] when `name` contains no usable identifier characters.
pub fn directory_name(name: &str) -> Result<String, CliError> {
    Ok(Names::parse(name)?.kebab)
}

/// Derives a normalized project name from an existing directory.
///
/// # Errors
///
/// Returns [`CliError`] when the directory has no file name or its name cannot form a safe Rust
/// identifier.
pub fn name_from_directory(directory: &Path) -> Result<String, CliError> {
    let name = directory
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| CliError::InvalidName {
            name: directory.display().to_string(),
        })?;
    directory_name(name)
}

/// Creates a complete application scaffold.
///
/// `framework_workspace` selects local path dependencies for framework development and tests.
/// Published CLI use defaults to the CLI's matching framework version.
///
/// # Errors
///
/// Returns [`CliError`] when the destination is occupied or files cannot be created.
pub fn create(
    destination: &Path,
    name: &str,
    framework_workspace: Option<&Path>,
) -> Result<ProjectReport, CliError> {
    let names = Names::parse(name)?;
    let manifest = manifest(&names.kebab, framework_workspace);
    let files = [
        (destination.join("Cargo.toml"), manifest),
        (
            destination.join("ironic.toml"),
            project_config(&names.kebab),
        ),
        (destination.join("src/main.rs"), main_source(&names.kebab)),
        (destination.join("src/app.rs"), app_source()),
        (
            destination.join("src/welcome.rs"),
            welcome_source(&names.kebab),
        ),
        (destination.join("src/modules/mod.rs"), String::new()),
    ];
    // Validate all owned paths before writing. This allows unrelated files in an existing
    // directory without leaving a partially generated project when one target conflicts.
    for (path, contents) in &files {
        if path.exists() {
            let existing =
                fs::read_to_string(path).map_err(|error| CliError::io("read", path, error))?;
            if existing != *contents {
                return Err(CliError::FileConflict {
                    path: path.to_owned(),
                });
            }
        }
    }

    fs::create_dir_all(destination)
        .map_err(|error| CliError::io("create directory", destination, error))?;
    for (path, contents) in files {
        write_generated(&path, &contents)?;
    }
    Ok(ProjectReport {
        destination: destination.to_owned(),
    })
}

fn manifest(name: &str, workspace: Option<&Path>) -> String {
    let ironic_dep = workspace.map_or_else(
        || format!("ironic = \"{}\"", env!("CARGO_PKG_VERSION")),
        |workspace| {
            format!(
                "ironic = {{ path = \"{}\", default-features = false }}",
                toml_path(workspace)
            )
        },
    );
    format!(
        "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.97\"\npublish = false\n\n[dependencies]\n{ironic_dep}\nserde = {{ version = \"1\", features = [\"derive\"] }}\nserde_json = \"1\"\n\n[dev-dependencies]\ntokio = {{ version = \"1\", features = [\"macros\", \"rt\"] }}\n\n# Available features (uncomment to enable):\n# irony = {{ features = [\"validation\"], {dep_spec} }}\n#\n# validation      — ParseIntPipe, ParseFloatPipe, ValidationPipe\n# security        — CORS, rate limiting, security headers, CSRF\n# compression     — gzip, brotli, zstd response compression\n# versioning      — URI, header, and media-type API versioning\n# serialization   — role-based field exposure\n# cache           — CacheInterceptor with InMemoryCache\n# scheduling      — Fixed-interval and cron background tasks\n# cron            — Cron expression scheduling\n# realtime        — WebSocket gateways with rooms/broadcasting\n# custom-decorators — create_param_decorator! macro\n# database        — SQLx, SeaORM, Diesel, MongoDB, Redis\n# authentication  — JWT, OAuth2, sessions, argon2 hashing\n# distributed     — Queues, microservices, CQRS, sagas, gRPC, GraphQL\n# uuid            — UUID generation utilities\n# transport-redis    — Redis pub/sub transport\n# transport-rabbitmq — AMQP transport\n# transport-kafka    — Kafka topic transport\n# application-services — cache + scheduling + events + realtime\n\n[workspace]\n",
        dep_spec = if workspace.is_some() {
            "path = \"...\""
        } else {
            "version = \"...\""
        }
    )
}

fn main_source(name: &str) -> String {
    let version = env!("CARGO_PKG_VERSION");
    let banner = format!("🚀 {name} → http://127.0.0.1:3000 (ironic v{version})");
    format!(
        "mod app;\nmod modules;\nmod welcome;\n\nuse ironic::{{AxumAdapter, prelude::*}};\n\nuse app::AppModule;\n\n#[ironic::main]\nasync fn main() {{\n    let application = FrameworkApplication::builder()\n        .module(AppModule::definition())\n        .platform(AxumAdapter::new())\n        .build()\n        .await\n        .expect(\"application must initialise\");\n\n    println!(\"{banner}\");\n\n    application\n        .listen(\"127.0.0.1:3000\")\n        .await\n        .expect(\"application server failed\");\n}}\n"
    )
}

fn app_source() -> String {
    "use ironic::prelude::*;\nuse crate::welcome::WelcomeModule;\n\n#[derive(Module)]\n#[module(imports = [HealthModule, WelcomeModule])]\npub struct AppModule;\n".to_owned()
}

fn welcome_source(name: &str) -> String {
    let version = env!("CARGO_PKG_VERSION");
    format!(
        "use ironic::prelude::*;\n\n#[controller(\"/\")]\n#[derive(Injectable)]\nstruct WelcomeController;\n\n#[routes]\nimpl WelcomeController {{\n    #[get]\n    async fn index(&self) -> Result<Json<serde_json::Value>, HttpError> {{\n        Ok(Json(serde_json::json!({{\n            \"name\": \"{name}\",\n            \"framework\": \"Ironic\",\n            \"version\": \"{version}\",\n            \"status\": \"running\",\n            \"docs\": \"/docs\",\n            \"health\": \"/health\"\n        }})))\n    }}\n}}\n\n#[derive(Module)]\n#[module(controllers = [WelcomeController])]\npub struct WelcomeModule;\n"
    )
}

fn project_config(name: &str) -> String {
    format!(
        "[project]\nname = \"{name}\"\nsource_root = \"src\"\ndefault_module = \"src/app.rs\"\n\n[generate]\nmodule_path = \"src/modules\"\n"
    )
}

fn toml_path(path: &Path) -> String {
    path.display()
        .to_string()
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
}