nativ 0.3.0

Nativ CLI — compile .nativ DSL to real SwiftUI and Jetpack Compose
use clap::Args;
use nativ_config::NativConfig;
use std::path::Path;

#[derive(Args)]
pub struct InitArgs {
    /// Project name (defaults to current directory name)
    pub name: Option<String>,
}

/// `app` declarations require an uppercase-initial identifier, but project
/// directories are conventionally kebab/lowercase — derive one from the
/// other ("my-app" -> "MyApp"). The display name keeps the original spelling.
fn app_decl_name(project_name: &str) -> String {
    let pascal: String = project_name
        .split(['-', '_'])
        .filter(|s| !s.is_empty())
        .map(|s| {
            let mut chars = s.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect();

    if pascal
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphabetic())
    {
        pascal
    } else {
        format!("App{pascal}")
    }
}

pub fn run(args: InitArgs, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
    let project_name = args.name.unwrap_or_else(|| {
        std::env::current_dir()
            .ok()
            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
            .unwrap_or_else(|| "my-app".to_string())
    });

    // Validate project name
    if project_name.contains(' ') {
        return Err("Project name cannot contain spaces. Example: 'my-app' or 'myapp'".into());
    }
    if !project_name
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(
            "Project name must only contain letters, numbers, hyphens (-) and underscores (_)"
                .into(),
        );
    }

    let project_dir = Path::new(&project_name);

    if project_dir.exists() {
        return Err(format!(
            "Directory already exists: '{}'\n  Hint: choose a different name or remove the existing directory",
            project_name
        )
        .into());
    }

    if verbose {
        println!("Creating project: {project_name}");
    }

    // Create directory structure
    let dirs = [
        "",
        "assets",
        "i18n",
        "src",
        "src/screens",
        "src/components",
        "src/models",
    ];

    for dir in &dirs {
        let path = project_dir.join(dir);
        std::fs::create_dir_all(&path)?;
        if verbose {
            println!("  Dir: {}", path.display());
        }
    }

    // Generate nativ.toml
    let config_content = NativConfig::default_toml(&project_name);
    std::fs::write(project_dir.join("nativ.toml"), config_content)?;

    // Generate .gitignore
    std::fs::write(project_dir.join(".gitignore"), "build/\n.DS_Store\n")?;

    // Generate app.nativ
    let decl_name = app_decl_name(&project_name);
    std::fs::write(
        project_dir.join("src/app.nativ"),
        format!(
            r#"app {decl_name}:
  name: "{project_name}"
  start: Home
"#
        ),
    )?;

    // Generate models/Item.nativ — a typed model with a few fields.
    std::fs::write(
        project_dir.join("src/models/Item.nativ"),
        r#"model Item:
  title: text
  done: boolean
"#,
    )?;

    // Generate Home.nativ — a compelling starter with state, list, each,
    // navigation, haptic, and conditional UI. Exercises real capabilities
    // so the developer sees what Nativ can do on the first build.
    std::fs::write(
        project_dir.join("src/screens/Home.nativ"),
        format!(
            r#"screen Home:
  state items: list of Item = [Item(title: "Welcome to {project_name}!", done: false), Item(title: "Tap a row to navigate", done: false), Item(title: "Long-press for haptics", done: false)]
  state count = 0

  scroll:
    column spacing: 16:
      text "{project_name}", big, bold
      text "Tap the button to add items. Tap a row to navigate.", small, gray

      button "Add Item ({{count}})":
        count += 1
        haptic impact

      if count > 5:
        text "That's a lot of items!", green, bold
      else:
        text "Keep tapping!", small, gray

      divider

      each item in items:
        card:
          column spacing: 4:
            text item.title, bold
            text "Tap to view details", small, gray
          on tap:
            go to Detail(item)
          on long press:
            haptic notification
"#
        ),
    )?;

    // Generate Detail.nativ — a detail screen with a model param,
    // conditional UI, and navigation back.
    std::fs::write(
        project_dir.join("src/screens/Detail.nativ"),
        r#"screen Detail(item):
  state revealed = false

  column spacing: 16:
    text item.title, big, bold

    if item.done:
      text "This item is done.", green
    else:
      text "This item is not done yet.", gray

    if revealed:
      text "You revealed the detail!", bold
    else:
      button "Reveal":
        revealed = true
        haptic impact

    button "Back":
      go back
"#,
    )?;

    // Generate i18n files
    std::fs::write(
        project_dir.join("i18n/en.json"),
        "{\n  \"welcome\": \"Welcome\",\n  \"get_started\": \"Get Started\"\n}\n",
    )?;

    println!("Project created: {project_name}/");
    println!();
    println!("  cd {project_name}");
    println!("  nativ build");
    println!();
    println!("to get started.");

    Ok(())
}

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

    // Name validation runs before any filesystem access, so these tests are
    // safe to call directly regardless of the process working directory.

    #[test]
    fn app_decl_name_pascal_cases_project_names() {
        assert_eq!(app_decl_name("demo"), "Demo");
        assert_eq!(app_decl_name("my-app"), "MyApp");
        assert_eq!(app_decl_name("my_cool_app2"), "MyCoolApp2");
        assert_eq!(app_decl_name("TodoApp"), "TodoApp");
        assert_eq!(app_decl_name("2cool"), "App2cool");
    }

    #[test]
    fn rejects_name_with_spaces() {
        let args = InitArgs {
            name: Some("bad name".to_string()),
        };
        let err = run(args, false).unwrap_err();
        assert!(err.to_string().contains("spaces"), "{err}");
    }

    #[test]
    fn rejects_name_with_special_characters() {
        for bad in ["bad!name", "bad/name", "bad.name", "bad☃name"] {
            let args = InitArgs {
                name: Some(bad.to_string()),
            };
            let err = run(args, false).unwrap_err();
            assert!(
                err.to_string().contains("letters, numbers"),
                "unexpected error for {bad}: {err}"
            );
        }
    }
}