romance-core 0.1.0

Core library for Romance CLI code generation
Documentation
use anyhow::Result;
use std::fs;
use std::path::Path;

/// Write content to a file, creating parent directories as needed.
pub fn write_file(path: &Path, content: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)?;
    Ok(())
}

/// Read a file and split at the ROMANCE:CUSTOM marker.
/// Returns (before_marker, custom_block) where custom_block includes the marker line.
pub fn read_with_custom_block(path: &Path) -> Option<(String, String)> {
    let content = fs::read_to_string(path).ok()?;
    let marker = "// === ROMANCE:CUSTOM ===";
    if let Some(pos) = content.find(marker) {
        Some((content[..pos].to_string(), content[pos..].to_string()))
    } else {
        None
    }
}

/// Write generated content, preserving custom block if file already exists.
pub fn write_generated(path: &Path, generated: &str) -> Result<()> {
    let content = if let Some((_, custom_block)) = read_with_custom_block(path) {
        format!("{}{}", generated, custom_block)
    } else {
        generated.to_string()
    };
    write_file(path, &content)
}

/// Insert a line before a named marker in a file.
pub fn insert_at_marker(path: &Path, marker: &str, line: &str) -> Result<()> {
    let content = fs::read_to_string(path)?;
    if content.contains(line) {
        return Ok(());
    }
    let new_content = content.replace(marker, &format!("{}\n{}", line, marker));
    fs::write(path, new_content)?;
    Ok(())
}

/// Pluralize an English word (same rules as the Tera `plural` filter).
pub fn pluralize(s: &str) -> String {
    if s.ends_with('s') || s.ends_with('x') || s.ends_with("ch") || s.ends_with("sh") {
        format!("{}es", s)
    } else if s.ends_with('y')
        && !s.ends_with("ay")
        && !s.ends_with("ey")
        && !s.ends_with("oy")
        && !s.ends_with("uy")
    {
        format!("{}ies", &s[..s.len() - 1])
    } else {
        format!("{}s", s)
    }
}

/// Pretty CLI output helpers using the `colored` crate.
pub mod ui {
    use colored::Colorize;

    /// Print a "create" action (green)
    pub fn created(path: &str) {
        println!("  {} {}", "create".green(), path);
    }

    /// Print an "update" action (cyan)
    pub fn updated(path: &str) {
        println!("  {} {}", "update".cyan(), path);
    }

    /// Print a "skip" action (yellow)
    pub fn skipped(path: &str, reason: &str) {
        println!("  {} {} ({})", "skip".yellow(), path, reason);
    }

    /// Print a "remove" action (red)
    pub fn removed(path: &str) {
        println!("  {} {}", "remove".red(), path);
    }

    /// Print an "inject" action (magenta)
    pub fn injected(target: &str, what: &str) {
        println!("  {} {}{}", "inject".magenta(), what, target);
    }

    /// Print a section header (bold)
    pub fn section(title: &str) {
        println!("\n{}", title.bold());
    }

    /// Print a success message (green bold)
    pub fn success(msg: &str) {
        println!("\n{}", msg.green().bold());
    }

    /// Print a warning (yellow)
    pub fn warn(msg: &str) {
        println!("  {} {}", "warn".yellow(), msg);
    }

    /// Print an error (red)
    pub fn error(msg: &str) {
        eprintln!("  {} {}", "error".red(), msg);
    }

    /// Print a check result (pass)
    pub fn check_pass(msg: &str) {
        println!("  {} {}", "".green(), msg);
    }

    /// Print a check result (fail)
    pub fn check_fail(msg: &str) {
        println!("  {} {}", "".red(), msg);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    // ── pluralize ─────────────────────────────────────────────────────

    #[test]
    fn pluralize_regular_word() {
        assert_eq!(pluralize("post"), "posts");
        assert_eq!(pluralize("user"), "users");
        assert_eq!(pluralize("product"), "products");
    }

    #[test]
    fn pluralize_ending_in_s() {
        assert_eq!(pluralize("bus"), "buses");
        assert_eq!(pluralize("class"), "classes");
    }

    #[test]
    fn pluralize_ending_in_x() {
        assert_eq!(pluralize("box"), "boxes");
        assert_eq!(pluralize("tax"), "taxes");
    }

    #[test]
    fn pluralize_ending_in_ch() {
        assert_eq!(pluralize("match"), "matches");
        assert_eq!(pluralize("church"), "churches");
    }

    #[test]
    fn pluralize_ending_in_sh() {
        assert_eq!(pluralize("dish"), "dishes");
        assert_eq!(pluralize("wish"), "wishes");
    }

    #[test]
    fn pluralize_consonant_y() {
        assert_eq!(pluralize("category"), "categories");
        assert_eq!(pluralize("city"), "cities");
        assert_eq!(pluralize("company"), "companies");
    }

    #[test]
    fn pluralize_vowel_y_preserved() {
        assert_eq!(pluralize("day"), "days");
        assert_eq!(pluralize("key"), "keys");
        assert_eq!(pluralize("boy"), "boys");
        assert_eq!(pluralize("guy"), "guys");
    }

    // ── write_file ────────────────────────────────────────────────────

    #[test]
    fn write_file_creates_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("a/b/c/test.txt");
        write_file(&path, "hello").unwrap();
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
    }

    // ── insert_at_marker ──────────────────────────────────────────────

    #[test]
    fn insert_at_marker_basic() {
        let mut tmp = NamedTempFile::new().unwrap();
        writeln!(tmp, "// header").unwrap();
        writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
        writeln!(tmp, "// footer").unwrap();
        tmp.flush().unwrap();

        insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();

        let content = std::fs::read_to_string(tmp.path()).unwrap();
        assert!(content.contains("pub mod post;\n// === ROMANCE:MODS ==="));
    }

    #[test]
    fn insert_at_marker_idempotent() {
        let mut tmp = NamedTempFile::new().unwrap();
        writeln!(tmp, "// header").unwrap();
        writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
        tmp.flush().unwrap();

        insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
        insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();

        let content = std::fs::read_to_string(tmp.path()).unwrap();
        // Should appear exactly once
        assert_eq!(content.matches("pub mod post;").count(), 1);
    }

    #[test]
    fn insert_at_marker_multiple_lines() {
        let mut tmp = NamedTempFile::new().unwrap();
        writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
        tmp.flush().unwrap();

        insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
        insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod user;").unwrap();

        let content = std::fs::read_to_string(tmp.path()).unwrap();
        assert!(content.contains("pub mod post;"));
        assert!(content.contains("pub mod user;"));
        // Both should be before the marker
        let marker_pos = content.find("// === ROMANCE:MODS ===").unwrap();
        let post_pos = content.find("pub mod post;").unwrap();
        let user_pos = content.find("pub mod user;").unwrap();
        assert!(post_pos < marker_pos);
        assert!(user_pos < marker_pos);
    }

    // ── read_with_custom_block ────────────────────────────────────────

    #[test]
    fn read_with_custom_block_splits_correctly() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(tmp, "generated code\n// === ROMANCE:CUSTOM ===\nuser code\n").unwrap();
        tmp.flush().unwrap();

        let (generated, custom) = read_with_custom_block(tmp.path()).unwrap();
        assert_eq!(generated, "generated code\n");
        assert!(custom.starts_with("// === ROMANCE:CUSTOM ==="));
        assert!(custom.contains("user code"));
    }

    #[test]
    fn read_with_custom_block_no_marker() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(tmp, "just some code without marker\n").unwrap();
        tmp.flush().unwrap();

        assert!(read_with_custom_block(tmp.path()).is_none());
    }

    #[test]
    fn read_with_custom_block_nonexistent_file() {
        let path = Path::new("/tmp/romance_test_nonexistent_file_12345.rs");
        assert!(read_with_custom_block(path).is_none());
    }

    // ── write_generated ───────────────────────────────────────────────

    #[test]
    fn write_generated_new_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("new.rs");

        write_generated(&path, "generated content\n").unwrap();
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "generated content\n");
    }

    #[test]
    fn write_generated_preserves_custom_block() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(tmp, "old generated\n// === ROMANCE:CUSTOM ===\nmy custom code\n").unwrap();
        tmp.flush().unwrap();

        write_generated(tmp.path(), "new generated\n").unwrap();

        let content = std::fs::read_to_string(tmp.path()).unwrap();
        assert!(content.starts_with("new generated\n"));
        assert!(content.contains("// === ROMANCE:CUSTOM ==="));
        assert!(content.contains("my custom code"));
        // Old generated content should be gone
        assert!(!content.contains("old generated"));
    }

    #[test]
    fn write_generated_no_custom_block_replaces_entirely() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(tmp, "old content without custom marker\n").unwrap();
        tmp.flush().unwrap();

        write_generated(tmp.path(), "new content\n").unwrap();

        let content = std::fs::read_to_string(tmp.path()).unwrap();
        assert_eq!(content, "new content\n");
    }
}