nativ 0.3.0

Nativ CLI — compile .nativ DSL to real SwiftUI and Jetpack Compose
use clap::{Args, Subcommand, ValueEnum};
use std::path::Path;

#[derive(Args)]
pub struct VersionArgs {
    #[command(subcommand)]
    pub command: Option<VersionCommand>,
}

#[derive(Subcommand)]
pub enum VersionCommand {
    /// Bump app.version in nativ.toml
    Bump(VersionBumpArgs),
}

#[derive(Args)]
pub struct VersionBumpArgs {
    /// Version component to bump
    #[arg(value_enum, default_value_t = VersionLevel::Patch)]
    pub level: VersionLevel,

    /// Project directory (default: current directory)
    #[arg(short, long, default_value = ".")]
    pub dir: String,
}

#[derive(Clone, Copy, ValueEnum)]
pub enum VersionLevel {
    Major,
    Minor,
    Patch,
}

pub fn run(args: VersionArgs) -> Result<(), Box<dyn std::error::Error>> {
    match args.command {
        Some(VersionCommand::Bump(args)) => bump(args),
        None => {
            println!("nativ {}", env!("CARGO_PKG_VERSION"));
            Ok(())
        }
    }
}

fn bump(args: VersionBumpArgs) -> Result<(), Box<dyn std::error::Error>> {
    let config_path = Path::new(&args.dir).join("nativ.toml");
    let config = nativ_config::NativConfig::load(&config_path)?;
    let new_version = bumped_version(&config.app.version, args.level)?;
    let content = std::fs::read_to_string(&config_path)?;
    let updated = write_app_version(&content, &new_version)?;

    std::fs::write(&config_path, updated)?;
    println!("Version bumped: {} -> {new_version}", config.app.version);
    Ok(())
}

fn bumped_version(version: &str, level: VersionLevel) -> Result<String, String> {
    let mut parts = version
        .split('.')
        .map(|part| {
            part.parse::<u32>()
                .map_err(|_| format!("invalid version: {version}"))
        })
        .collect::<Result<Vec<_>, _>>()?;

    while parts.len() < 3 {
        parts.push(0);
    }

    match level {
        VersionLevel::Major => {
            parts[0] += 1;
            parts[1] = 0;
            parts[2] = 0;
        }
        VersionLevel::Minor => {
            parts[1] += 1;
            parts[2] = 0;
        }
        VersionLevel::Patch => {
            parts[2] += 1;
        }
    }

    Ok(format!("{}.{}.{}", parts[0], parts[1], parts[2]))
}

fn write_app_version(content: &str, version: &str) -> Result<String, String> {
    let mut out = Vec::new();
    let mut in_app = false;
    let mut saw_app = false;
    let mut wrote = false;

    for line in content.lines() {
        let trimmed = line.trim();
        if in_app && trimmed.starts_with('[') && trimmed.ends_with(']') {
            if !wrote {
                out.push(format!("version = \"{version}\""));
                wrote = true;
            }
            in_app = false;
        }

        if trimmed == "[app]" {
            in_app = true;
            saw_app = true;
        }

        if in_app && is_version_line(line) {
            let trimmed_start = line.trim_start();
            let indent = &line[..line.len() - trimmed_start.len()];
            out.push(format!("{indent}version = \"{version}\""));
            wrote = true;
            continue;
        }

        out.push(line.to_string());
    }

    if saw_app && in_app && !wrote {
        out.push(format!("version = \"{version}\""));
    }

    if !saw_app {
        return Err("[app] section not found in nativ.toml".into());
    }

    let mut updated = out.join("\n");
    if content.ends_with('\n') {
        updated.push('\n');
    }
    Ok(updated)
}

fn is_version_line(line: &str) -> bool {
    let Some(rest) = line.trim_start().strip_prefix("version") else {
        return false;
    };
    rest.trim_start().starts_with('=')
}

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

    #[test]
    fn version_command_never_fails() {
        assert!(run(VersionArgs { command: None }).is_ok());
    }

    #[test]
    fn bumps_semver_components() {
        assert_eq!(
            bumped_version("1.2.3", VersionLevel::Patch).unwrap(),
            "1.2.4"
        );
        assert_eq!(
            bumped_version("1.2.3", VersionLevel::Minor).unwrap(),
            "1.3.0"
        );
        assert_eq!(
            bumped_version("1.2.3", VersionLevel::Major).unwrap(),
            "2.0.0"
        );
        assert_eq!(bumped_version("1.2", VersionLevel::Patch).unwrap(), "1.2.1");
    }

    #[test]
    fn rewrites_or_inserts_app_version() {
        assert!(
            write_app_version("[app]\nname = \"Demo\"\nversion = \"1.0.0\"\n", "1.0.1")
                .unwrap()
                .contains("version = \"1.0.1\"")
        );
        assert_eq!(
            write_app_version("[app]\nname = \"Demo\"\n[build]\nios = true\n", "0.1.1").unwrap(),
            "[app]\nname = \"Demo\"\nversion = \"0.1.1\"\n[build]\nios = true\n"
        );
    }
}