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};
const DEBOUNCE: Duration = Duration::from_millis(300);
#[derive(Args)]
pub struct WatchArgs {
#[arg(long)]
pub ios: bool,
#[arg(long)]
pub android: bool,
#[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);
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());
}
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);
}
}
})?;
watcher.watch(project_dir, RecursiveMode::Recursive)?;
println!(
"Watching {} for changes... (Ctrl+C to stop)",
project_dir.display()
);
while let Ok(first) = rx.recv() {
let mut batch = vec![first];
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;
}
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(())
}
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")
}
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}"),
}
}
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::*;
#[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~", "src/.app.nativ.swp", "src/app.nativ.tmp", "src/4913", "build/ios/App.swift", "build/android/Main.kt", "README.md",
"Cargo.toml", ] {
assert!(
!triggers_rebuild(Path::new(path)),
"{path} should not trigger"
);
}
}
#[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)));
}
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:?}"
);
}
#[test]
fn build_pass_returns_error_with_file_location_on_parse_error() {
let tmp = tempfile::tempdir().unwrap();
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}");
}
}