ferro-cli 0.2.4

CLI for scaffolding Ferro web applications
Documentation
//! make:notification command - Generate a new notification

use console::style;
use std::fs;
use std::path::Path;

use crate::templates;

pub fn run(name: String) {
    // Convert to PascalCase for struct name
    let struct_name = to_pascal_case(&name);

    // Append "Notification" suffix if not already present
    let struct_name = if struct_name.ends_with("Notification") {
        struct_name
    } else {
        format!("{struct_name}Notification")
    };

    // Convert to snake_case for file name
    let file_name = to_snake_case(&struct_name);

    // Validate the resulting name is a valid Rust identifier
    if !is_valid_identifier(&file_name) {
        eprintln!(
            "{} '{}' is not a valid notification name",
            style("Error:").red().bold(),
            name
        );
        std::process::exit(1);
    }

    let notifications_dir = Path::new("src/notifications");
    let notification_file = notifications_dir.join(format!("{file_name}.rs"));
    let mod_file = notifications_dir.join("mod.rs");

    // Ensure we're in a Ferro project (check for src directory)
    if !Path::new("src").exists() {
        eprintln!(
            "{} Not in a Ferro project root directory",
            style("Error:").red().bold()
        );
        eprintln!(
            "{}",
            style("Make sure you're in a Ferro project directory with a src/ folder.").dim()
        );
        std::process::exit(1);
    }

    // Create notifications directory if it doesn't exist
    if !notifications_dir.exists() {
        if let Err(e) = fs::create_dir_all(notifications_dir) {
            eprintln!(
                "{} Failed to create notifications directory: {}",
                style("Error:").red().bold(),
                e
            );
            std::process::exit(1);
        }
        println!("{} Created src/notifications/", style("✓").green());

        // Create mod.rs
        let mod_content = templates::notifications_mod();
        if let Err(e) = fs::write(&mod_file, mod_content) {
            eprintln!(
                "{} Failed to create mod.rs: {}",
                style("Error:").red().bold(),
                e
            );
            std::process::exit(1);
        }
        println!("{} Created src/notifications/mod.rs", style("✓").green());
    }

    // Check if notification file already exists
    if notification_file.exists() {
        eprintln!(
            "{} Notification '{}' already exists at {}",
            style("Info:").yellow().bold(),
            struct_name,
            notification_file.display()
        );
        std::process::exit(0);
    }

    // Check if module is already declared in mod.rs
    if mod_file.exists() {
        let mod_content = fs::read_to_string(&mod_file).unwrap_or_default();
        let mod_decl = format!("mod {file_name};");
        let pub_mod_decl = format!("pub mod {file_name};");
        if mod_content.contains(&mod_decl) || mod_content.contains(&pub_mod_decl) {
            eprintln!(
                "{} Module '{}' is already declared in src/notifications/mod.rs",
                style("Info:").yellow().bold(),
                file_name
            );
            std::process::exit(0);
        }
    }

    // Generate notification file content
    let notification_content = templates::notification_template(&file_name, &struct_name);

    // Write notification file
    if let Err(e) = fs::write(&notification_file, notification_content) {
        eprintln!(
            "{} Failed to write notification file: {}",
            style("Error:").red().bold(),
            e
        );
        std::process::exit(1);
    }
    println!(
        "{} Created {}",
        style("✓").green(),
        notification_file.display()
    );

    // Update mod.rs
    if let Err(e) = update_mod_file(&mod_file, &file_name, &struct_name) {
        eprintln!(
            "{} Failed to update mod.rs: {}",
            style("Error:").red().bold(),
            e
        );
        std::process::exit(1);
    }
    println!("{} Updated src/notifications/mod.rs", style("✓").green());

    println!();
    println!(
        "Notification {} created successfully!",
        style(&struct_name).cyan().bold()
    );
    println!();
    println!("Next steps:");
    println!(
        "  {} Add notification data fields in {}",
        style("1.").dim(),
        notification_file.display()
    );
    println!();
    println!(
        "  {} Add the notifications module to src/lib.rs or src/main.rs:",
        style("2.").dim()
    );
    println!("     {}", style("mod notifications;").cyan());
    println!();
    println!(
        "  {} Send the notification in your code:",
        style("3.").dim()
    );
    println!(
        "     {}",
        style(format!(
            "use crate::notifications::{file_name}::{struct_name};"
        ))
        .cyan()
    );
    println!(
        "     {}",
        style(format!(
            "user.notify({struct_name} {{ /* fields */ }}).await?;"
        ))
        .cyan()
    );
    println!();
}

fn is_valid_identifier(name: &str) -> bool {
    if name.is_empty() {
        return false;
    }

    let mut chars = name.chars();

    // First character must be letter or underscore
    match chars.next() {
        Some(c) if c.is_alphabetic() || c == '_' => {}
        _ => return false,
    }

    // Rest must be alphanumeric or underscore
    chars.all(|c| c.is_alphanumeric() || c == '_')
}

fn to_snake_case(s: &str) -> String {
    let mut result = String::new();
    for (i, c) in s.chars().enumerate() {
        if c.is_uppercase() {
            if i > 0 {
                result.push('_');
            }
            result.push(c.to_lowercase().next().unwrap());
        } else {
            result.push(c);
        }
    }
    result
}

fn to_pascal_case(s: &str) -> String {
    let mut result = String::new();
    let mut capitalize_next = true;

    for c in s.chars() {
        if c == '_' || c == '-' || c == ' ' {
            capitalize_next = true;
        } else if capitalize_next {
            result.push(c.to_uppercase().next().unwrap());
            capitalize_next = false;
        } else {
            result.push(c);
        }
    }
    result
}

fn update_mod_file(mod_file: &Path, file_name: &str, struct_name: &str) -> Result<(), String> {
    let content =
        fs::read_to_string(mod_file).map_err(|e| format!("Failed to read mod.rs: {e}"))?;

    let pub_mod_decl = format!("pub mod {file_name};");
    let pub_use_decl = format!("pub use {file_name}::{struct_name};");

    // Find position to insert declarations
    let lines: Vec<&str> = content.lines().collect();

    // Find the last pub mod declaration line
    let mut last_pub_mod_idx = None;
    let mut last_pub_use_idx = None;

    for (i, line) in lines.iter().enumerate() {
        if line.trim().starts_with("pub mod ") {
            last_pub_mod_idx = Some(i);
        }
        if line.trim().starts_with("pub use ") {
            last_pub_use_idx = Some(i);
        }
    }

    // Build new content
    let mut new_lines: Vec<String> = Vec::new();

    // If we found existing pub mod declarations, insert after them
    if let Some(idx) = last_pub_mod_idx {
        for (i, line) in lines.iter().enumerate() {
            new_lines.push(line.to_string());
            if i == idx {
                new_lines.push(pub_mod_decl.clone());
            }
        }
    } else {
        // No existing pub mod declarations, add at the end (before empty lines)
        let mut content_end = lines.len();
        while content_end > 0 && lines[content_end - 1].trim().is_empty() {
            content_end -= 1;
        }

        for (i, line) in lines.iter().enumerate() {
            new_lines.push(line.to_string());
            if i == content_end.saturating_sub(1) || (content_end == 0 && i == 0) {
                new_lines.push(pub_mod_decl.clone());
            }
        }

        // If file was empty
        if lines.is_empty() {
            new_lines.push(pub_mod_decl.clone());
        }
    }

    // Now add pub use declaration if there are existing pub use declarations
    if last_pub_use_idx.is_some() {
        // Find the new position of the last pub use after our modification
        let mut insert_idx = None;
        for (i, line) in new_lines.iter().enumerate() {
            if line.trim().starts_with("pub use ") {
                insert_idx = Some(i);
            }
        }
        if let Some(idx) = insert_idx {
            new_lines.insert(idx + 1, pub_use_decl);
        }
    }

    let new_content = new_lines.join("\n");

    // Ensure file ends with newline
    let new_content = if new_content.ends_with('\n') {
        new_content
    } else {
        format!("{new_content}\n")
    };

    fs::write(mod_file, new_content).map_err(|e| format!("Failed to write mod.rs: {e}"))?;

    Ok(())
}