nativ 0.3.0

Nativ CLI — compile .nativ DSL to real SwiftUI and Jetpack Compose
use clap::Args;
use nativ_config::NativConfig;
use notify::{RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};

/// How long to keep collecting filesystem events before rebuilding.
///
/// Editors typically save via write-then-rename, which produces several
/// events in quick succession; one debounce window collapses the whole
/// burst into exactly one rebuild.
const DEBOUNCE: Duration = Duration::from_millis(300);

#[derive(Args)]
pub struct WatchArgs {
    /// Build only for iOS
    #[arg(long)]
    pub ios: bool,

    /// Build only for Android
    #[arg(long)]
    pub android: bool,

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

pub fn run(args: WatchArgs, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = Path::new(&args.dir);

    // Fail fast on a broken project setup (missing dir/config, no targets).
    // Once watching starts, build errors are reported but never kill the loop.
    let config = NativConfig::load(&project_dir.join("nativ.toml"))?;
    if nativ_pipeline::resolve_targets(args.ios, args.android, &config).is_empty() {
        return Err("No target platform specified. Enable ios or android in nativ.toml".into());
    }

    // Initial full build — same code path as `nativ build`.
    rebuild_and_report(project_dir, args.ios, args.android, verbose);

    let (tx, rx) = mpsc::channel();
    let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
        if let Ok(event) = res {
            for path in event.paths {
                let _ = tx.send(path);
            }
        }
    })?;
    // Watch the whole project so `src/**` created later is still picked up;
    // `triggers_rebuild` filters out build output, .git noise, etc.
    watcher.watch(project_dir, RecursiveMode::Recursive)?;

    println!(
        "Watching {} for changes... (Ctrl+C to stop)",
        project_dir.display()
    );

    // Block until any filesystem event arrives; a closed channel means the
    // watcher is gone and there is nothing left to wait for.
    while let Ok(first) = rx.recv() {
        let mut batch = vec![first];

        // Debounce: drain every event that lands inside the window so a
        // save burst (temp write + rename) triggers exactly one rebuild.
        let deadline = Instant::now() + DEBOUNCE;
        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
            match rx.recv_timeout(remaining) {
                Ok(path) => batch.push(path),
                Err(mpsc::RecvTimeoutError::Timeout) => break,
                Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
            }
        }

        if !batch.iter().any(|p| triggers_rebuild(p)) {
            continue;
        }

        // Clear the current line, then show a fresh status for this rebuild.
        print!("\r\x1b[2K");
        println!("Change detected, rebuilding...");
        rebuild_and_report(project_dir, args.ios, args.android, verbose);
        println!("Watching for changes... (Ctrl+C to stop)");
    }

    Ok(())
}

/// Event classification: only `.nativ` sources, `nativ.toml`, and `.nativ.env`
/// should trigger a rebuild. Editor temp/backup files (`app.nativ~`,
/// `.app.nativ.swp`) and generated `.swift`/`.kt` output must not.
pub(crate) fn triggers_rebuild(path: &Path) -> bool {
    path.extension().is_some_and(|ext| ext == "nativ")
        || path.file_name().is_some_and(|name| name == "nativ.toml")
        || path.file_name().is_some_and(|name| name == ".nativ.env")
}

/// Run one build pass and print a compact result line. Errors are reported,
/// never propagated — a parse error must not kill the watch loop.
fn rebuild_and_report(project_dir: &Path, ios: bool, android: bool, verbose: bool) {
    let start = Instant::now();
    match build_pass(project_dir, ios, android) {
        Ok(files) => {
            if verbose {
                for file in &files {
                    println!("    -> {}", file.display());
                }
            }
            println!(
                "Build OK: {} files generated ({:.2}s)",
                files.len(),
                start.elapsed().as_secs_f64()
            );
        }
        Err(e) => eprintln!("Build failed: {e}"),
    }
}

/// One full build pass — the exact code path `nativ build` uses. The config
/// is reloaded each time so edits to nativ.toml take effect on the next
/// rebuild.
fn build_pass(
    project_dir: &Path,
    ios: bool,
    android: bool,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
    let config = NativConfig::load(&project_dir.join("nativ.toml"))?;
    let targets = nativ_pipeline::resolve_targets(ios, android, &config);
    if targets.is_empty() {
        return Err("No target platform specified. Enable ios or android in nativ.toml".into());
    }

    let results = nativ_pipeline::build(project_dir, &config, &targets)?;
    Ok(results
        .into_iter()
        .flat_map(|r| r.generated_files)
        .collect())
}

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

    // ─── event classification ────────────────────────────────────────

    #[test]
    fn nativ_sources_trigger_rebuild() {
        for path in ["app.nativ", "src/app.nativ", "src/screens/Home.nativ"] {
            assert!(triggers_rebuild(Path::new(path)), "{path} should trigger");
        }
    }

    #[test]
    fn config_and_env_files_trigger_rebuild() {
        for path in ["nativ.toml", "my-app/nativ.toml", ".nativ.env"] {
            assert!(triggers_rebuild(Path::new(path)), "{path} should trigger");
        }
    }

    #[test]
    fn unrelated_and_temp_files_do_not_trigger() {
        for path in [
            "src/app.nativ~",        // backup file
            "src/.app.nativ.swp",    // vim swap
            "src/app.nativ.tmp",     // editor temp write
            "src/4913",              // vim probe file
            "build/ios/App.swift",   // generated output
            "build/android/Main.kt", // generated output
            "README.md",
            "Cargo.toml", // only nativ.toml counts, not any .toml
        ] {
            assert!(
                !triggers_rebuild(Path::new(path)),
                "{path} should not trigger"
            );
        }
    }

    /// The loop rebuilds once per debounced batch iff any path is relevant —
    /// a write-then-rename save burst rebuilds, output-only noise does not.
    #[test]
    fn debounced_batch_rebuilds_only_when_a_relevant_path_is_present() {
        let save_burst = [
            PathBuf::from("src/.app.nativ.tmp123"),
            PathBuf::from("src/app.nativ"),
        ];
        assert!(save_burst.iter().any(|p| triggers_rebuild(p)));

        let output_noise = [
            PathBuf::from("build/ios/App.swift"),
            PathBuf::from("build/android/MainActivity.kt"),
        ];
        assert!(!output_noise.iter().any(|p| triggers_rebuild(p)));
    }

    // ─── build pass (same code path the watch loop uses) ─────────────

    fn scaffold(dir: &Path, source: &str) {
        std::fs::write(dir.join("nativ.toml"), "[app]\nname = \"t\"\n").unwrap();
        std::fs::create_dir_all(dir.join("src")).unwrap();
        std::fs::write(dir.join("src").join("home.nativ"), source).unwrap();
    }

    #[test]
    fn build_pass_compiles_a_valid_project() {
        let tmp = tempfile::tempdir().unwrap();
        scaffold(tmp.path(), "screen Home:\n  text \"Hello\"\n");

        let files = build_pass(tmp.path(), true, false).unwrap();
        assert!(!files.is_empty(), "expected generated files");
        assert!(
            files
                .iter()
                .any(|f| f.extension().is_some_and(|e| e == "swift")),
            "expected .swift output, got: {files:?}"
        );
    }

    /// A parse error must surface as a plain Err (with file location) that
    /// the loop reports and survives — not a panic.
    #[test]
    fn build_pass_returns_error_with_file_location_on_parse_error() {
        let tmp = tempfile::tempdir().unwrap();
        // Tab indentation is always a parse error.
        scaffold(tmp.path(), "screen Bad:\n\ttext \"tabs\"\n");

        let err = build_pass(tmp.path(), true, false).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("home.nativ"),
            "error should name the file: {msg}"
        );
        assert!(
            msg.contains(':'),
            "error should carry file:line info: {msg}"
        );
    }

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

        let err = build_pass(tmp.path(), true, false).unwrap_err();
        assert!(err.to_string().contains("nativ.toml"), "{err}");
    }
}