start_project.rs 923 lignes · 29740 octets
use std::fs;
use std::path::{Path, PathBuf};

use console::style;
use dialoguer::{Confirm, Input, Select};
use rand::Rng;

use crate::templates::{
    self, FediverseMode, OAuthProviderConfig, OAuthWizardConfig, ProjectConfig, SmtpConfig,
};
use crate::version;

pub fn run() -> Result<(), Box<dyn std::error::Error>> {
    println!(
        "\n{}",
        style("  Rustwarden - Nouveau projet  ")
            .bold()
            .on_cyan()
            .black(),
    );
    println!();

    let rustwarden_root = find_rustwarden_root()?;

    let name = ask_project_name()?;
    let app_display_name = ask_app_display_name(&name)?;
    let description = ask_description(&name)?;
    let project_dir = ask_target_dir(&name)?;

    handle_existing_dir(&project_dir)?;

    let with_ui = ask_ui_mode()?;
    let (fediverse_mode, fediverse_domain) = ask_fediverse_mode()?;
    let auto_migrate = ask_auto_migrate()?;
    let port = ask_port()?;

    println!("\n{}", style("── Base de donnees ──").cyan().bold());
    let (db_name, db_user, db_password) = ask_database_config(&name)?;

    println!("\n{}", style("── Administrateur ──").cyan().bold());
    let (admin_username, admin_email, admin_password) = ask_admin_config()?;

    println!("\n{}", style("── Email SMTP ──").cyan().bold());
    let smtp = ask_smtp_config(&app_display_name)?;

    println!("\n{}", style("── OAuth / SSO ──").cyan().bold());
    let oauth = ask_oauth_config(port)?;

    let jwt_secret = generate_jwt_secret();

    let cfg = ProjectConfig {
        name: name.clone(),
        app_display_name: app_display_name.clone(),
        description: description.clone(),
        with_ui,
        fediverse_mode,
        fediverse_domain,
        auto_migrate,
        port,
        db_name,
        db_user,
        db_password,
        admin_username,
        admin_email,
        admin_password,
        jwt_secret,
        smtp,
        oauth,
    };

    println!("\n{}", style("Generation du projet...").cyan().bold());

    fs::create_dir_all(project_dir.join("src"))?;
    fs::create_dir_all(project_dir.join("crates"))?;

    copy_crate(&rustwarden_root, &project_dir, "rustwarden-core")?;
    if with_ui {
        copy_crate(&rustwarden_root, &project_dir, "rustwarden-ui")?;
    }
    match cfg.fediverse_mode {
        FediverseMode::None => {}
        FediverseMode::Headless => {
            copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse")?;
        }
        FediverseMode::WithReferenceUi => {
            copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse")?;
            copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse-ui")?;
        }
    }

    copy_resources(&rustwarden_root, &project_dir, with_ui)?;
    let fediverse_active = !matches!(cfg.fediverse_mode, FediverseMode::None);
    copy_docs(&rustwarden_root, &project_dir, fediverse_active)?;
    generate_files(&project_dir, &cfg)?;
    rebrand_templates(&project_dir, &app_display_name)?;

    // Ecrire le fichier de version du framework
    if let Ok(v) = version::compute_version(&rustwarden_root) {
        let _ = version::write_version(&project_dir, &v);
    }

    print_summary(&cfg, &project_dir);

    Ok(())
}

// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------

fn ask_project_name() -> Result<String, Box<dyn std::error::Error>> {
    let name: String = Input::new()
        .with_prompt(format!("{}", style("Nom du projet").cyan()))
        .validate_with(|input: &String| -> Result<(), String> {
            if input.is_empty() {
                return Err("Le nom ne peut pas etre vide".into());
            }
            if input.contains(' ') {
                return Err("Le nom ne peut pas contenir d'espaces".into());
            }
            if !input
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
            {
                return Err(
                    "Seuls les caracteres alphanumeriques minuscules, tirets et underscores sont autorises (Docker Compose exige lowercase)"
                        .into(),
                );
            }
            let first = input.chars().next().unwrap_or(' ');
            if !first.is_ascii_alphanumeric() {
                return Err(
                    "Le nom doit commencer par une lettre ou un chiffre (Docker Compose)".into(),
                );
            }
            Ok(())
        })
        .interact_text()?;
    Ok(name)
}

fn ask_app_display_name(name: &str) -> Result<String, Box<dyn std::error::Error>> {
    let display_name: String = Input::new()
        .with_prompt(format!(
            "{}",
            style("Nom affiche dans l'interface (APP_NAME)").cyan()
        ))
        .default(name.to_string())
        .interact_text()?;
    Ok(display_name)
}

fn ask_description(name: &str) -> Result<String, Box<dyn std::error::Error>> {
    let desc: String = Input::new()
        .with_prompt(format!("{}", style("Description").cyan()))
        .default(format!("Application {name} propulsee par Rustwarden"))
        .interact_text()?;
    Ok(desc)
}

fn ask_target_dir(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let default = format!("./{name}");
    let dir: String = Input::new()
        .with_prompt(format!(
            "{}",
            style("Dossier cible (le projet sera cree a cet emplacement)").cyan()
        ))
        .default(default)
        .interact_text()?;

    let target = PathBuf::from(&dir);
    let target = if target.is_absolute() {
        target
    } else {
        std::env::current_dir()?.join(target)
    };
    Ok(target)
}

fn handle_existing_dir(project_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
    if project_dir.exists() {
        println!(
            "\n{}",
            style(format!(
                "Le dossier {} existe deja :",
                project_dir.display()
            ))
            .yellow(),
        );

        if let Ok(entries) = fs::read_dir(project_dir) {
            let items: Vec<String> = entries
                .filter_map(|e| e.ok())
                .map(|e| {
                    let name = e.file_name().to_string_lossy().to_string();
                    if e.path().is_dir() {
                        format!("  {}/", name)
                    } else {
                        format!("  {}", name)
                    }
                })
                .collect();

            if items.is_empty() {
                println!("  {}", style("(vide)").dim());
            } else {
                for item in &items {
                    println!("{item}");
                }
            }
        }

        println!();
        let confirm = Confirm::new()
            .with_prompt("Utiliser ce dossier ? Les fichiers existants du meme nom seront ecrases")
            .default(false)
            .interact()?;

        if !confirm {
            return Err("Operation annulee par l'utilisateur".into());
        }
    }
    Ok(())
}

/// Demande comment le projet consommateur doit consommer la federation
/// ActivityPub : pas du tout, protocole headless seul, ou protocole +
/// UI HTML de reference.
///
/// Si un mode federe est selectionne, demande aussi le domaine federe a
/// placer dans `FEDIVERSE_DOMAIN`.
///
/// Retourne `(FediverseMode, Some(domain))` quand la federation est
/// activee, ou `(FediverseMode::None, None)` sinon.
fn ask_fediverse_mode() -> Result<(FediverseMode, Option<String>), Box<dyn std::error::Error>> {
    let options = vec![
        "Pas de federation",
        "Federation headless (protocole AP seul, vous implementez votre propre UI)",
        "Federation avec UI de reference (protocole AP + interface HTML par defaut) [recommande]",
    ];
    let selection = Select::new()
        .with_prompt(format!(
            "{}",
            style("Federation ActivityPub (RFC 0001+, WebFinger, AP-native auth, invitations cross-instance)").cyan()
        ))
        .items(&options)
        .default(2)
        .interact()?;

    let mode = match selection {
        0 => FediverseMode::None,
        1 => FediverseMode::Headless,
        _ => FediverseMode::WithReferenceUi,
    };

    if matches!(mode, FediverseMode::None) {
        return Ok((mode, None));
    }

    let domain: String = Input::new()
        .with_prompt(format!(
            "{}",
            style("Domaine federe (FEDIVERSE_DOMAIN, ex. example.org)").cyan()
        ))
        .default("example.org".into())
        .validate_with(|input: &String| -> Result<(), String> {
            let trimmed = input.trim();
            if trimmed.is_empty() {
                return Err("Le domaine ne peut pas etre vide".into());
            }
            // Refuse les placeholders attendus par FediverseConfig::from_env.
            let lower = trimmed.to_ascii_lowercase();
            let stripped = lower
                .trim_start_matches("http://")
                .trim_start_matches("https://")
                .trim_end_matches('/');
            let placeholders = [
                "example.com",
                "example.net",
                "localhost.localdomain",
                "your.tld",
                "your-domain.tld",
                "domain.tld",
                "change-me.tld",
            ];
            if placeholders.contains(&stripped) {
                return Err(format!(
                    "`{stripped}` est un placeholder refuse par FediverseConfig — saisir un vrai domaine"
                ));
            }
            Ok(())
        })
        .interact_text()?;

    Ok((mode, Some(domain.trim().to_string())))
}

fn ask_ui_mode() -> Result<bool, Box<dyn std::error::Error>> {
    let options = vec!["Avec interface web (rustwarden-ui)", "API seule (headless)"];
    let selection = Select::new()
        .with_prompt(format!("{}", style("Mode").cyan()))
        .items(&options)
        .default(0)
        .interact()?;
    Ok(selection == 0)
}

fn ask_auto_migrate() -> Result<bool, Box<dyn std::error::Error>> {
    let confirm = Confirm::new()
        .with_prompt(format!(
            "{}",
            style("Lancer les migrations automatiquement au demarrage ?").cyan()
        ))
        .default(true)
        .interact()?;
    Ok(confirm)
}

fn ask_port() -> Result<u16, Box<dyn std::error::Error>> {
    let port: String = Input::new()
        .with_prompt(format!("{}", style("Port du serveur").cyan()))
        .default("3000".into())
        .validate_with(|input: &String| -> Result<(), String> {
            input
                .parse::<u16>()
                .map(|_| ())
                .map_err(|_| "Veuillez entrer un numero de port valide (1-65535)".into())
        })
        .interact_text()?;
    Ok(port.parse()?)
}

fn ask_database_config(
    project_name: &str,
) -> Result<(String, String, String), Box<dyn std::error::Error>> {
    let db_name: String = Input::new()
        .with_prompt(format!("{}", style("Nom de la base de donnees").cyan()))
        .default(project_name.replace('-', "_"))
        .interact_text()?;

    let db_user: String = Input::new()
        .with_prompt(format!("{}", style("Utilisateur PostgreSQL").cyan()))
        .default(format!("{}_user", project_name.replace('-', "_")))
        .interact_text()?;

    let default_pw = generate_random_password(16);
    let db_password: String = Input::new()
        .with_prompt(format!("{}", style("Mot de passe PostgreSQL").cyan()))
        .default(default_pw)
        .interact_text()?;

    Ok((db_name, db_user, db_password))
}

/// Validate an admin username against the RFC 0036 charset rules.
///
/// The framework's `Username::parse_allow_reserved` is `pub(crate)` inside
/// `rustwarden-core`, so it cannot be reused here; the rule is reimplemented
/// locally and kept in sync manually.
/// Source of truth: `docs/rfcs/0036-username-charset-validation.md` and
/// `crates/rustwarden-core/src/types/username.rs`.
///
/// Reserved names (`admin`, …) are deliberately accepted: the bootstrap path
/// (`ensure_default_admin`) goes through `parse_allow_reserved`.
fn validate_admin_username(raw: &str) -> Result<(), String> {
    const RULE: &str = "minuscules, chiffres et tirets uniquement, 3 à 39 caractères, \
                        sans '--', sans tiret en début ou en fin";

    let s = raw.trim();
    if s.len() < 3 || s.len() > 39 {
        return Err(format!(
            "Username admin invalide ({RULE}) : longueur hors 3-39"
        ));
    }
    if s.starts_with('-') || s.ends_with('-') {
        return Err(format!(
            "Username admin invalide ({RULE}) : tiret en début ou en fin"
        ));
    }
    if s.contains("--") {
        return Err(format!("Username admin invalide ({RULE}) : '--' interdit"));
    }
    if !s
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err(format!(
            "Username admin invalide ({RULE}) : caractère hors [a-z0-9-] \
             (les majuscules sont refusées, pas de '_' ni de '.')"
        ));
    }
    Ok(())
}

fn ask_admin_config() -> Result<(String, String, String), Box<dyn std::error::Error>> {
    let username: String = Input::new()
        .with_prompt(format!("{}", style("Username admin").cyan()))
        .default("admin".into())
        .validate_with(|input: &String| -> Result<(), String> { validate_admin_username(input) })
        .interact_text()?;
    let username = username.trim().to_string();

    let email: String = Input::new()
        .with_prompt(format!("{}", style("Email admin").cyan()))
        .default("admin@localhost".into())
        .interact_text()?;

    let default_pw = generate_random_password(12);
    let password: String = Input::new()
        .with_prompt(format!("{}", style("Mot de passe admin").cyan()))
        .default(default_pw)
        .interact_text()?;

    Ok((username, email, password))
}

fn ask_smtp_config(
    app_display_name: &str,
) -> Result<Option<SmtpConfig>, Box<dyn std::error::Error>> {
    let configure = Confirm::new()
        .with_prompt(format!(
            "{}",
            style("Configurer un serveur SMTP pour l'envoi d'emails ?").cyan()
        ))
        .default(false)
        .interact()?;

    if !configure {
        println!(
            "  {}",
            style("SMTP desactive. Les emails ne seront pas envoyes.").dim()
        );
        return Ok(None);
    }

    let host: String = Input::new()
        .with_prompt(format!("{}", style("Hote SMTP").cyan()))
        .interact_text()?;

    let port: String = Input::new()
        .with_prompt(format!("{}", style("Port SMTP").cyan()))
        .default("587".into())
        .validate_with(|input: &String| -> Result<(), String> {
            input
                .parse::<u16>()
                .map(|_| ())
                .map_err(|_| "Port invalide".into())
        })
        .interact_text()?;

    let user: String = Input::new()
        .with_prompt(format!("{}", style("Utilisateur SMTP").cyan()))
        .interact_text()?;

    let password: String = Input::new()
        .with_prompt(format!("{}", style("Mot de passe SMTP").cyan()))
        .interact_text()?;

    let from: String = Input::new()
        .with_prompt(format!("{}", style("Adresse expediteur (From)").cyan()))
        .default(user.clone())
        .interact_text()?;

    let from_name: String = Input::new()
        .with_prompt(format!("{}", style("Nom expediteur").cyan()))
        .default(app_display_name.to_string())
        .interact_text()?;

    let use_tls = Confirm::new()
        .with_prompt(format!("{}", style("Utiliser TLS ?").cyan()))
        .default(true)
        .interact()?;

    let use_starttls = Confirm::new()
        .with_prompt(format!("{}", style("Utiliser STARTTLS ?").cyan()))
        .default(true)
        .interact()?;

    Ok(Some(SmtpConfig {
        host,
        port: port.parse()?,
        user,
        password,
        from,
        from_name,
        use_tls,
        use_starttls,
    }))
}

// ---------------------------------------------------------------------------
// Generation aleatoire
// ---------------------------------------------------------------------------

fn generate_random_password(len: usize) -> String {
    // Strictly safe charset for passwords that flow through three layers:
    //   1. DATABASE_URL — RFC 3986 reserved chars (@, #, %, &, /, ?, :)
    //      would break the postgres://user:pass@host/db parser.
    //   2. .env files consumed by Docker Compose — `$` triggers variable
    //      substitution (`$VAR`); `~` may expand to `$HOME` in some shells.
    //   3. Interactive shells — `!` triggers history expansion in bash,
    //      `*` is a glob, `&`/`|`/`;` are job-control or pipe operators.
    // We keep alphanumeric + `-_` (RFC 3986 unreserved minus `.~`) which
    // are inert in all three contexts.
    const CHARSET: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_";
    let mut rng = rand::rng();
    (0..len)
        .map(|_| {
            let idx = rng.random_range(0..CHARSET.len());
            CHARSET[idx] as char
        })
        .collect()
}

fn ask_oauth_config(port: u16) -> Result<Option<OAuthWizardConfig>, Box<dyn std::error::Error>> {
    let enable = Confirm::new()
        .with_prompt(format!(
            "{}",
            style("Activer OAuth / SSO (Google, GitHub, Discord, Microsoft) ?").cyan()
        ))
        .default(false)
        .interact()?;

    if !enable {
        println!("  {} OAuth desactive.", style("→").dim());
        return Ok(None);
    }

    let redirect_base_url: String = Input::new()
        .with_prompt(format!(
            "{}",
            style("URL de base pour les callbacks OAuth").cyan()
        ))
        .default(format!("http://localhost:{}", port))
        .interact_text()?;

    let provider_names = ["google", "github", "discord", "microsoft"];
    let display_names = ["Google", "GitHub", "Discord", "Microsoft"];
    let mut providers = Vec::new();

    for (i, name) in provider_names.iter().enumerate() {
        let configure = Confirm::new()
            .with_prompt(format!(
                "  {} {}",
                style("Configurer").cyan(),
                style(display_names[i]).bold()
            ))
            .default(false)
            .interact()?;

        if !configure {
            continue;
        }

        let client_id: String = Input::new()
            .with_prompt(format!("    {} Client ID", style(display_names[i]).cyan()))
            .interact_text()?;

        let client_secret: String = Input::new()
            .with_prompt(format!(
                "    {} Client Secret",
                style(display_names[i]).cyan()
            ))
            .interact_text()?;

        providers.push(OAuthProviderConfig {
            name: name.to_string(),
            client_id,
            client_secret,
        });
    }

    if providers.is_empty() {
        println!(
            "  {} OAuth active mais aucun provider configure. Ajoutez-les dans .env plus tard.",
            style("⚠").yellow()
        );
    }

    Ok(Some(OAuthWizardConfig {
        enabled: true,
        redirect_base_url,
        providers,
    }))
}

fn generate_jwt_secret() -> String {
    let mut rng = rand::rng();
    (0..64)
        .map(|_| format!("{:02x}", rng.random::<u8>()))
        .collect::<String>()
}

// ---------------------------------------------------------------------------
// Decouverte du workspace Rustwarden
// ---------------------------------------------------------------------------

fn find_rustwarden_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
    let start_dirs = [
        std::env::current_exe()
            .ok()
            .and_then(|p| p.parent().map(|p| p.to_path_buf())),
        std::env::current_dir().ok(),
    ];

    for start in start_dirs.into_iter().flatten() {
        let mut dir = start;
        loop {
            let cargo_toml = dir.join("Cargo.toml");
            if cargo_toml.exists() {
                let content = fs::read_to_string(&cargo_toml)?;
                if content.contains("[workspace]") && dir.join("crates/rustwarden-core").exists() {
                    return Ok(dir);
                }
            }
            if !dir.pop() {
                break;
            }
        }
    }

    Err("Impossible de trouver la racine du projet Rustwarden.\n\
         Lancez cette commande depuis le repertoire Rustwarden \
         ou son sous-dossier."
        .into())
}

// ---------------------------------------------------------------------------
// Copie des crates et ressources
// ---------------------------------------------------------------------------

fn copy_crate(
    rustwarden_root: &Path,
    project_dir: &Path,
    crate_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let src = rustwarden_root.join("crates").join(crate_name);
    let dst = project_dir.join("crates").join(crate_name);

    if !src.exists() {
        return Err(format!("Crate source introuvable : {}", src.display()).into());
    }

    println!(
        "  {} {}",
        style("Copie").dim(),
        style(format!("crates/{crate_name}/")).white()
    );
    copy_dir_recursive(&src, &dst)?;
    Ok(())
}

fn copy_resources(
    rustwarden_root: &Path,
    project_dir: &Path,
    with_ui: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let dirs_to_copy: Vec<&str> = if with_ui {
        vec!["static", "database"]
    } else {
        vec!["database"]
    };

    for dir_name in dirs_to_copy {
        let src = rustwarden_root.join(dir_name);
        let dst = project_dir.join(dir_name);
        if src.exists() {
            println!(
                "  {} {}",
                style("Copie").dim(),
                style(format!("{dir_name}/")).white()
            );
            copy_dir_recursive(&src, &dst)?;
        }
    }

    Ok(())
}

/// Copy framework documentation relevant to consumer projects.
fn copy_docs(
    rustwarden_root: &Path,
    project_dir: &Path,
    fediverse_active: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut docs_to_copy: Vec<&str> = vec!["docs/SERVICES_REFERENCE.md", "docs/MIGRATION_GUIDE.md"];
    if fediverse_active {
        docs_to_copy.push("docs/fediverse-consumer-guide.md");
    }

    let dst_dir = project_dir.join("docs");
    fs::create_dir_all(&dst_dir)?;

    for doc in &docs_to_copy {
        let src = rustwarden_root.join(doc);
        if src.exists() {
            let filename = Path::new(doc).file_name().unwrap();
            let dst = dst_dir.join(filename);
            println!(
                "  {} {}",
                style("Copie").dim(),
                style(format!("docs/{}", filename.to_string_lossy())).white()
            );
            fs::copy(&src, &dst)?;
        }
    }

    Ok(())
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box<dyn std::error::Error>> {
    fs::create_dir_all(dst)?;

    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let file_type = entry.file_type()?;
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());

        let name = entry.file_name().to_string_lossy().to_string();
        if name == "target" || name == ".git" || name == "data" {
            continue;
        }

        if file_type.is_dir() {
            copy_dir_recursive(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Rebrand : remplacer "RustWarden" dans les templates copies
// ---------------------------------------------------------------------------

fn rebrand_templates(
    project_dir: &Path,
    app_display_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let template_dirs = [
        project_dir.join("crates/rustwarden-ui/templates"),
        project_dir.join("crates/rustwarden-core/templates"),
    ];

    let mut count = 0u32;
    for dir in &template_dirs {
        if dir.exists() {
            count += rebrand_dir(dir, app_display_name)?;
        }
    }

    if count > 0 {
        println!(
            "  {} {} fichier(s) rebrandes avec \"{}\"",
            style("Rebrand").dim(),
            count,
            app_display_name,
        );
    }
    Ok(())
}

fn rebrand_dir(dir: &Path, app_display_name: &str) -> Result<u32, Box<dyn std::error::Error>> {
    let mut count = 0u32;
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            count += rebrand_dir(&path, app_display_name)?;
        } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
            let content = fs::read_to_string(&path)?;
            if content.contains("RustWarden") {
                let replaced = content.replace("RustWarden", app_display_name);
                fs::write(&path, replaced)?;
                count += 1;
            }
        }
    }
    Ok(count)
}

// ---------------------------------------------------------------------------
// Generation des fichiers
// ---------------------------------------------------------------------------

fn generate_files(
    project_dir: &Path,
    cfg: &ProjectConfig,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut files: Vec<(&str, String)> = vec![
        ("Cargo.toml", templates::cargo_toml(cfg)),
        ("src/main.rs", templates::main_rs(cfg)),
        (".env", templates::dot_env(cfg)),
        (".env.example", templates::dot_env_example()),
        ("database/.env", templates::database_dot_env(cfg)),
        ("README.md", templates::readme(cfg)),
        ("DEVELOPER.md", templates::developer_md(cfg)),
    ];

    if cfg.with_ui {
        files.push((
            "crates/rustwarden-ui/templates/home.html",
            templates::custom_home_html(cfg),
        ));
    }

    for (path, content) in files {
        let full_path = project_dir.join(path);
        if let Some(parent) = full_path.parent() {
            fs::create_dir_all(parent)?;
        }
        println!("  {} {}", style("Genere").dim(), style(path).white());
        fs::write(full_path, content)?;
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Resume final
// ---------------------------------------------------------------------------

fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
    let divider = style("─".repeat(55)).dim();
    println!("\n{divider}");
    println!("  {}", style("Projet cree avec succes !").green().bold());
    println!("{divider}\n");

    println!("  {}       {}", style("Nom").bold(), cfg.name);
    println!("  {}   {}", style("App").bold(), cfg.app_display_name);
    println!("  {}   {}", style("Dossier").bold(), project_dir.display());
    println!(
        "  {}      {}",
        style("Mode").bold(),
        if cfg.with_ui {
            "Interface web"
        } else {
            "API seule"
        },
    );
    println!("  {}      {}", style("Port").bold(), cfg.port);
    println!("  {}        {}", style("DB").bold(), cfg.db_name);
    println!("  {}     {}", style("Admin").bold(), cfg.admin_username);
    println!(
        "  {}      {}",
        style("SMTP").bold(),
        if let Some(smtp) = &cfg.smtp {
            format!("{} (port {})", smtp.host, smtp.port)
        } else {
            "desactive".to_string()
        },
    );
    println!(
        "  {}     {}",
        style("OAuth").bold(),
        if let Some(ref oauth) = cfg.oauth {
            if oauth.providers.is_empty() {
                "active (aucun provider configure)".to_string()
            } else {
                let names: Vec<&str> = oauth.providers.iter().map(|p| p.name.as_str()).collect();
                format!("active ({})", names.join(", "))
            }
        } else {
            "desactive".to_string()
        },
    );

    println!("\n{divider}");
    println!("  {}", style("Prochaines etapes :").bold());
    println!("{divider}\n");

    println!(
        "  {}  Se deplacer dans le dossier :",
        style("1.").cyan().bold()
    );
    println!(
        "      {}",
        style(format!("cd {}", project_dir.display())).green()
    );
    println!(
        "  {}  Lancer la base de donnees :",
        style("2.").cyan().bold()
    );
    println!(
        "      {}",
        style("cd database && docker compose up -d && cd ..").green()
    );
    println!("  {}  Lancer l'application :", style("3.").cyan().bold());
    println!("      {}", style("cargo run").green());
    println!(
        "  {}  Générer la liste des écrans d'UI à coder :",
        style("4.").cyan().bold()
    );
    println!(
        "      {}",
        style("cargo run -p rustwarden-core --bin screen-select").green()
    );
    println!(
        "      puis {} pour recocher le réalisé",
        style("cargo run -p rustwarden-core --bin screen-select -- --check").green()
    );
    println!(
        "  {}  Ouvrir dans le navigateur :",
        style("5.").cyan().bold()
    );
    println!(
        "      {}",
        style(format!("http://localhost:{}", cfg.port)).underlined()
    );

    println!(
        "\n  {}",
        style("Les fichiers .env sont deja configures avec vos identifiants.").dim()
    );
    println!(
        "  {}",
        style("Voir .env.example pour la reference des variables disponibles.").dim()
    );
    println!();
}