nativ 0.3.0

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

#[derive(Args)]
pub struct FormatArgs {
    /// Check without applying changes (exit non-zero if anything would change)
    #[arg(long)]
    pub check: bool,

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

pub fn run(args: FormatArgs, _verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = Path::new(&args.dir);
    let sources = nativ_pipeline::discover_sources(project_dir)?;

    let mut changed = 0usize;
    let mut unchanged = 0usize;
    let mut errors = 0usize;

    for source in &sources {
        let content = std::fs::read_to_string(source)?;
        match nativ_compiler::format::format_source(&content) {
            Ok(formatted) if formatted == content => {
                unchanged += 1;
                println!("unchanged: {}", source.display());
            }
            Ok(formatted) => {
                changed += 1;
                if args.check {
                    println!("would reformat: {}", source.display());
                } else {
                    std::fs::write(source, formatted)?;
                    println!("reformatted: {}", source.display());
                }
            }
            Err(e) => {
                errors += 1;
                eprintln!("  {}: {e}", source.display());
            }
        }
    }

    println!(
        "{changed} {}, {unchanged} unchanged",
        if args.check {
            "would be reformatted"
        } else {
            "reformatted"
        }
    );

    if errors > 0 {
        return Err(format!("{errors} file(s) could not be formatted").into());
    }
    if args.check && changed > 0 {
        return Err(format!("{changed} file(s) would be reformatted").into());
    }
    Ok(())
}

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

    fn args_for(dir: &Path, check: bool) -> FormatArgs {
        FormatArgs {
            check,
            dir: dir.display().to_string(),
        }
    }

    #[test]
    fn fails_when_src_directory_is_missing() {
        let tmp = tempfile::tempdir().unwrap();

        let err = run(args_for(tmp.path(), false), false).unwrap_err();
        assert!(err.to_string().contains("src"), "{err}");
    }

    #[test]
    fn rewrites_messy_file_and_check_then_passes() {
        let tmp = tempfile::tempdir().unwrap();
        let src_dir = tmp.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        let file = src_dir.join("home.nativ");
        std::fs::write(&file, "screen Home:\n  text \"Hi\" ,red   \n").unwrap();

        // --check on a messy file: non-zero, file untouched.
        assert!(run(args_for(tmp.path(), true), false).is_err());
        assert_eq!(
            std::fs::read_to_string(&file).unwrap(),
            "screen Home:\n  text \"Hi\" ,red   \n"
        );

        // Write mode fixes the file.
        assert!(run(args_for(tmp.path(), false), false).is_ok());
        assert_eq!(
            std::fs::read_to_string(&file).unwrap(),
            "screen Home:\n  text \"Hi\", red\n"
        );

        // Now --check passes.
        assert!(run(args_for(tmp.path(), true), false).is_ok());
    }

    #[test]
    fn fails_on_unparseable_source() {
        let tmp = tempfile::tempdir().unwrap();
        let src_dir = tmp.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        // Tab indentation is always rejected by the preprocessor.
        std::fs::write(src_dir.join("bad.nativ"), "screen Bad:\n\ttext \"tabs\"\n").unwrap();

        let err = run(args_for(tmp.path(), false), false).unwrap_err();
        assert!(err.to_string().contains("could not be formatted"), "{err}");
    }
}