arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Name and path validation for the `arc make` generators.
//!
//! Generators must be safe: no path traversal, no overwrite of existing
//! files, no shell injection. This module centralizes the validation so each
//! generator stays small and one-responsibility.

use std::path::{Component, Path, PathBuf};

/// Validate a module directory name (e.g. `links`, `accounts`). Lowercase
/// ASCII identifier; must not be a path. Returns the validated name or an
/// error string.
pub(crate) fn validate_module_name(name: &str) -> Result<String, String> {
    if name.is_empty() {
        return Err("module name must not be empty".to_owned());
    }
    if !is_valid_ident(name) {
        return Err(format!(
            "module name `{name}` must be a lowercase ASCII identifier (a-z, 0-9, _)"
        ));
    }
    Ok(name.to_owned())
}

/// Validate a type/item name (e.g. `Links`, `SessionsController`,
/// `send_welcome`). ASCII identifier starting with a letter or underscore.
pub(crate) fn validate_type_name(name: &str) -> Result<String, String> {
    if name.is_empty() {
        return Err("name must not be empty".to_owned());
    }
    if !is_valid_type_ident(name) {
        return Err(format!(
            "name `{name}` must be an ASCII identifier (a-z, A-Z, 0-9, _), starting with a letter or underscore"
        ));
    }
    Ok(name.to_owned())
}

/// Convert a type name to a snake_case file stem (e.g. `Links` -> `links`,
/// `SessionsController` -> `sessions_controller`).
pub(crate) fn snake_case(name: &str) -> String {
    let mut out = String::new();
    for (i, ch) in name.chars().enumerate() {
        if ch.is_ascii_uppercase() {
            if i > 0 {
                out.push('_');
            }
            out.push(ch.to_ascii_lowercase());
        } else {
            out.push(ch);
        }
    }
    out
}

/// Convert a lowercase module name to a PascalCase identifier (e.g. `links` ->
/// `Links`, `user_accounts` -> `UserAccounts`).
pub(crate) fn pascal_case(name: &str) -> String {
    let mut out = String::new();
    let mut capitalize_next = true;
    for ch in name.chars() {
        if ch == '_' || ch == '-' {
            capitalize_next = true;
        } else if capitalize_next {
            out.push(ch.to_ascii_uppercase());
            capitalize_next = false;
        } else {
            out.push(ch);
        }
    }
    out
}

/// Resolve `<src_root>/<module>/<file>` and ensure the module directory does not
/// allow escaping the project root. `src_root` is the application source
/// root (ADR-0008: `backend_src_dir`, e.g. `app/` or `src/`).
pub(crate) fn module_file_path(
    src_root: &Path,
    module: &str,
    file_stem: &str,
) -> Result<PathBuf, String> {
    let path = src_root.join(module).join(file_stem);
    ensure_within(src_root, &path)?;
    Ok(path)
}

/// Resolve `tests/<file>` for test generators.
pub(crate) fn tests_file_path(root: &Path, file_stem: &str) -> Result<PathBuf, String> {
    let path = root.join("tests").join(file_stem);
    ensure_within(root, &path)?;
    Ok(path)
}

/// Ensure a resolved path stays within the project root (no `..`, no
/// absolute escapes). Checks the path components lexically — does NOT require
/// the target directory to exist (generators create new files in new
/// directories). The root must exist (the project root always does).
fn ensure_within(root: &Path, path: &Path) -> Result<(), String> {
    if path.is_absolute() {
        // The path is absolute only if root is absolute. Check it starts with
        // root's canonicalized form.
        let canonical_root = root
            .canonicalize()
            .map_err(|e| format!("cannot canonicalize project root {}: {e}", root.display()))?;
        let mut current = path.to_path_buf();
        // Walk up to the first existing ancestor, then canonicalize and compare.
        while !current.exists() {
            match current.parent() {
                Some(p) if p != current => current = p.to_path_buf(),
                _ => break,
            }
        }
        let canonical = current
            .canonicalize()
            .map_err(|e| format!("cannot canonicalize {}: {e}", current.display()))?;
        if !canonical.starts_with(&canonical_root) {
            return Err(format!(
                "resolved path {} escapes the project root",
                path.display()
            ));
        }
        return Ok(());
    }
    // Relative path: every component must be Normal (no `..`, no root, no
    // prefix). The caller built the path by joining validated names, so this
    // is defense-in-depth.
    let escapes = path
        .components()
        .any(|c| !matches!(c, Component::Normal(_)));
    if escapes {
        return Err(format!(
            "resolved path {} contains a non-normal component",
            path.display()
        ));
    }
    Ok(())
}

fn is_valid_ident(name: &str) -> bool {
    name.chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
        && name
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
}

fn is_valid_type_ident(name: &str) -> bool {
    name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
        && name
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}

/// Check that a path does not already exist (generators must not overwrite).
pub(crate) fn ensure_missing(path: &Path) -> Result<(), String> {
    if path.exists() {
        Err(format!(
            "refusing to overwrite existing file: {}",
            path.display()
        ))
    } else {
        Ok(())
    }
}

/// Compute the `mod <name>;` declaration line for a file stem. Returns the
/// declaration string (without trailing newline).
pub(crate) fn mod_declaration(file_stem: &str) -> Result<String, String> {
    if !is_valid_ident(file_stem) {
        return Err(format!(
            "cannot derive mod declaration from non-identifier file stem `{file_stem}`"
        ));
    }
    Ok(format!("pub mod {file_stem};"))
}

/// Append a `mod` declaration to a `mod.rs` if it is not already present.
/// Creates the `mod.rs` if missing. Returns a description of what changed.
pub(crate) fn append_mod_declaration(mod_rs: &Path, declaration: &str) -> Result<String, String> {
    let existing = std::fs::read_to_string(mod_rs).unwrap_or_default();
    if existing.contains(declaration) {
        return Ok("already declared".to_owned());
    }
    let mut content = existing;
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str(declaration);
    content.push('\n');
    if let Some(parent) = mod_rs.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("cannot create directory {}: {e}", parent.display()))?;
    }
    std::fs::write(mod_rs, content)
        .map_err(|e| format!("cannot write {}: {e}", mod_rs.display()))?;
    Ok(format!("declared `{declaration}` in {}", mod_rs.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn snake_case_converts_pascal() {
        assert_eq!(snake_case("Links"), "links");
        assert_eq!(snake_case("SessionsController"), "sessions_controller");
        assert_eq!(snake_case("SendEmail"), "send_email");
    }

    #[test]
    fn pascal_case_converts_snake() {
        assert_eq!(pascal_case("links"), "Links");
        assert_eq!(pascal_case("user_accounts"), "UserAccounts");
        assert_eq!(pascal_case("send_email"), "SendEmail");
    }

    #[test]
    fn validate_module_name_rejects_uppercase_and_paths() {
        assert!(validate_module_name("Links").is_err());
        assert!(validate_module_name("").is_err());
        assert!(validate_module_name("../etc").is_err());
        assert!(validate_module_name("links").is_ok());
        assert!(validate_module_name("user_accounts").is_ok());
    }

    #[test]
    fn validate_type_name_rejects_non_identifiers() {
        assert!(validate_type_name("").is_err());
        assert!(validate_type_name("123abc").is_err());
        assert!(validate_type_name("a/b").is_err());
        assert!(validate_type_name("Links").is_ok());
        assert!(validate_type_name("send_welcome").is_ok());
        assert!(validate_type_name("_private").is_ok());
    }

    #[test]
    fn mod_declaration_validates_stem() {
        assert_eq!(
            mod_declaration("links_controller").unwrap(),
            "pub mod links_controller;"
        );
        assert!(mod_declaration("Links-Controller").is_err());
        assert!(mod_declaration("").is_err());
    }
}