fledge 0.4.0

Corvid-themed project scaffolding CLI — get your projects ready to fly.
use anyhow::Result;
use dialoguer::{Input, Select, theme::ColorfulTheme};

use crate::config::Config;
use crate::templates::Template;

pub fn select_template(templates: &[Template]) -> Result<usize> {
    let items: Vec<String> = templates
        .iter()
        .map(|t| format!("{:<14} {}", t.name, t.description))
        .collect();

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Select a template")
        .items(&items)
        .default(0)
        .interact()?;

    Ok(selection)
}

pub fn prompt_variables(
    template: &Template,
    project_name: &str,
    config: &Config,
) -> Result<tera::Context> {
    let mut ctx = tera::Context::new();

    // Core variables
    ctx.insert("project_name", project_name);
    ctx.insert("project_name_snake", &to_snake_case(project_name));
    ctx.insert("project_name_pascal", &to_pascal_case(project_name));

    // Date variables
    let now = chrono::Local::now();
    ctx.insert("year", &now.format("%Y").to_string());
    ctx.insert("date", &now.format("%Y-%m-%d").to_string());

    // Author — from config, git, or prompt
    let author = match config.author_or_git() {
        Some(a) => a,
        None => Input::with_theme(&ColorfulTheme::default())
            .with_prompt("Author name")
            .interact_text()?,
    };
    ctx.insert("author", &author);

    // GitHub org — from config or prompt
    let github_org = match config.github_org() {
        Some(org) => org,
        None => Input::with_theme(&ColorfulTheme::default())
            .with_prompt("GitHub organization")
            .default("CorvidLabs".to_string())
            .interact_text()?,
    };
    ctx.insert("github_org", &github_org);

    // License
    ctx.insert("license", &config.license());

    // Template-specific prompts
    for (key, prompt_def) in &template.manifest.prompts {
        let theme = ColorfulTheme::default();
        let value: String = if let Some(ref default) = prompt_def.default {
            let rendered = render_default(default, &ctx).unwrap_or_else(|_| default.clone());
            Input::with_theme(&theme)
                .with_prompt(&prompt_def.message)
                .default(rendered)
                .interact_text()?
        } else {
            Input::with_theme(&theme)
                .with_prompt(&prompt_def.message)
                .interact_text()?
        };
        ctx.insert(key, &value);
    }

    Ok(ctx)
}

fn render_default(template: &str, ctx: &tera::Context) -> Result<String> {
    if !template.contains("{{") {
        return Ok(template.to_string());
    }
    let mut tera = tera::Tera::default();
    tera.add_raw_template("__default__", template)?;
    Ok(tera.render("__default__", ctx)?)
}

fn to_snake_case(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c == '-' {
                '_'
            } else {
                c.to_ascii_lowercase()
            }
        })
        .collect()
}

fn to_pascal_case(s: &str) -> String {
    s.split(['-', '_'])
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => {
                    let mut s = first.to_uppercase().to_string();
                    s.extend(chars);
                    s
                }
            }
        })
        .collect()
}

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

    #[test]
    fn test_to_snake_case() {
        assert_eq!(to_snake_case("my-project"), "my_project");
        assert_eq!(to_snake_case("MyProject"), "myproject");
        assert_eq!(to_snake_case("already_snake"), "already_snake");
    }

    #[test]
    fn test_to_snake_case_multiple_hyphens() {
        assert_eq!(to_snake_case("my-cool-project"), "my_cool_project");
    }

    #[test]
    fn test_to_snake_case_empty() {
        assert_eq!(to_snake_case(""), "");
    }

    #[test]
    fn test_to_snake_case_single_char() {
        assert_eq!(to_snake_case("A"), "a");
    }

    #[test]
    fn test_to_pascal_case() {
        assert_eq!(to_pascal_case("my-project"), "MyProject");
        assert_eq!(to_pascal_case("my_project"), "MyProject");
        assert_eq!(to_pascal_case("single"), "Single");
    }

    #[test]
    fn test_to_pascal_case_multiple_segments() {
        assert_eq!(to_pascal_case("my-cool-project"), "MyCoolProject");
    }

    #[test]
    fn test_to_pascal_case_mixed_separators() {
        assert_eq!(to_pascal_case("my-cool_project"), "MyCoolProject");
    }

    #[test]
    fn test_to_pascal_case_empty() {
        assert_eq!(to_pascal_case(""), "");
    }

    #[test]
    fn test_to_pascal_case_single_char() {
        assert_eq!(to_pascal_case("a"), "A");
    }

    #[test]
    fn render_default_plain_string() {
        let ctx = tera::Context::new();
        assert_eq!(render_default("hello world", &ctx).unwrap(), "hello world");
    }

    #[test]
    fn render_default_with_variable() {
        let mut ctx = tera::Context::new();
        ctx.insert("project_name", "my-app");
        assert_eq!(
            render_default("A {{ project_name }} project", &ctx).unwrap(),
            "A my-app project"
        );
    }

    #[test]
    fn render_default_no_braces_passthrough() {
        let ctx = tera::Context::new();
        assert_eq!(
            render_default("no variables here", &ctx).unwrap(),
            "no variables here"
        );
    }

    #[test]
    fn render_default_missing_var_errors() {
        let ctx = tera::Context::new();
        let result = render_default("{{ missing }}", &ctx);
        assert!(result.is_err());
    }
}