use clap::Args;
use nativ_compiler::ast::*;
use nativ_config::NativConfig;
use nativ_pipeline::{BuildOptions, Target};
use notify::{RecursiveMode, Watcher};
use serde::Serialize;
use std::collections::HashMap;
use std::io::{self, IsTerminal, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream, UdpSocket};
use std::path::{Component, Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const DEBOUNCE: Duration = Duration::from_millis(300);
const NATIVE_BUILD_COOLDOWN: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum ChangeLevel {
Patch,
Screen,
App,
}
impl std::fmt::Display for ChangeLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChangeLevel::Patch => write!(f, "Patch"),
ChangeLevel::Screen => write!(f, "Screen"),
ChangeLevel::App => write!(f, "App"),
}
}
}
impl ChangeLevel {
fn as_str(&self) -> &'static str {
match self {
ChangeLevel::Patch => "patch",
ChangeLevel::Screen => "screen",
ChangeLevel::App => "app",
}
}
}
#[derive(Args)]
pub struct DevArgs {
#[arg(short, long, default_value = ".")]
pub dir: String,
#[arg(long)]
pub ios: bool,
#[arg(long)]
pub android: bool,
#[arg(long)]
pub dev_shell: bool,
#[arg(long, default_value_t = 0)]
pub dev_shell_port: u16,
#[arg(long)]
pub dev_shell_compile: bool,
}
pub fn run(args: DevArgs, verbose: bool, quiet: bool) -> Result<(), Box<dyn std::error::Error>> {
let project_dir = Path::new(&args.dir);
if args.dev_shell_compile && !args.dev_shell {
return Err("--dev-shell-compile requires --dev-shell".into());
}
let config = NativConfig::load(&project_dir.join("nativ.toml"))?;
let (ios, android) = if should_prompt_dev_target(&args, quiet) {
prompt_dev_target(&config)?
} else {
(args.ios, args.android)
};
let initial_targets = nativ_pipeline::resolve_targets(ios, android, &config);
if initial_targets.is_empty() {
return Err("No target platform specified. Enable ios or android in nativ.toml".into());
}
let sources = nativ_pipeline::discover_sources(project_dir)
.map_err(|e| format!("Failed to discover source files: {e}"))?;
let mut cache: HashMap<PathBuf, NativFile> = HashMap::new();
for source in &sources {
match parse_and_cache(source, &mut cache, verbose) {
Ok(true) => {}
Ok(false) => {
}
Err(e) => {
eprintln!(" warning: could not read {}: {e}", source.display());
}
}
}
if verbose {
println!(
" {} source files indexed for change detection",
cache.len()
);
}
let target_names: Vec<&str> = initial_targets
.iter()
.map(|t| match t {
Target::Ios => "iOS",
Target::Android => "Android",
})
.collect();
println!(
"Nativ dev server watching {} for changes... (Ctrl+C to stop)",
project_dir.display()
);
println!(
" Targets: {} | Native build: {}",
target_names.join(", "),
if ios || android {
let mut parts = Vec::new();
if ios {
parts.push("iOS simulator (xcodebuild)");
}
if android {
parts.push("Android emulator (gradle)");
}
parts.join(", ")
} else {
"disabled (use --ios / --android)".to_string()
}
);
let dev_shell_refresh_url = if args.dev_shell {
let addr = start_dev_shell_server(project_dir, args.dev_shell_port)?;
let url = format!("http://{}:{}/__nativ_refresh", local_ip_hint(), addr.port());
println!(
" Dev Shell: Patch changes write build/.nativ-dev-refresh.json and skip native rebuild"
);
if args.dev_shell_compile {
println!(" Dev Shell compile: Patch native_compile plans execute before refresh");
}
println!(" Dev Shell refresh: {url}");
Some(url)
} else {
None
};
let mut last_native_build: HashMap<&str, Instant> = HashMap::new();
let mut refresh_sequence = 0_u64;
let app_name = &config.app.name;
let application_id = application_id(&config);
println!(" Initial native preview build...");
build_and_deploy(
project_dir,
ios,
android,
ChangeLevel::App,
&[],
&mut last_native_build,
verbose,
app_name,
&application_id,
args.dev_shell,
dev_shell_refresh_url.as_deref(),
args.dev_shell_compile,
&mut refresh_sequence,
);
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)?;
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(()),
}
}
let relevant: Vec<&PathBuf> = batch
.iter()
.filter(|p| super::watch::triggers_rebuild(p))
.collect();
if relevant.is_empty() {
continue;
}
let classifications = classify_batch(&relevant, &mut cache, verbose);
print!("\r\x1b[2K");
for Classification {
path,
level,
detail,
} in &classifications
{
let rel = path.strip_prefix(project_dir).unwrap_or(path);
println!(" {} modified → {level} ({detail})", rel.display());
}
let overall_level = classifications
.iter()
.map(|c| c.level)
.max()
.unwrap_or(ChangeLevel::App);
let changed_screens: Vec<String> = extract_changed_names(&classifications);
build_and_deploy(
project_dir,
ios,
android,
overall_level,
&changed_screens,
&mut last_native_build,
verbose,
app_name,
&application_id,
args.dev_shell,
dev_shell_refresh_url.as_deref(),
args.dev_shell_compile,
&mut refresh_sequence,
);
}
Ok(())
}
fn should_prompt_dev_target(args: &DevArgs, quiet: bool) -> bool {
!quiet && !args.ios && !args.android && io::stdin().is_terminal() && io::stdout().is_terminal()
}
fn prompt_dev_target(config: &NativConfig) -> Result<(bool, bool), Box<dyn std::error::Error>> {
println!("Native preview target:");
println!(" 1) iOS Simulator");
println!(" 2) Android device/emulator");
println!(" 3) iOS + Android");
print!("Select [Enter = nativ.toml defaults]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
parse_dev_menu_choice(input.trim(), config)
.ok_or_else(|| "Invalid native preview target selection".into())
}
fn parse_dev_menu_choice(choice: &str, config: &NativConfig) -> Option<(bool, bool)> {
Some(match choice {
"" | "0" => (config.build.ios, config.build.android),
"1" => (true, false),
"2" => (false, true),
"3" => (true, true),
_ => return None,
})
}
fn application_id(config: &NativConfig) -> String {
config.app.bundle_id.clone().unwrap_or_else(|| {
let segment: String = config
.app
.name
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
format!(
"com.example.{}",
if segment.is_empty() { "app" } else { &segment }
)
})
}
#[allow(clippy::too_many_arguments)]
fn build_and_deploy(
project_dir: &Path,
ios_flag: bool,
android_flag: bool,
level: ChangeLevel,
changed_screens: &[String],
last_native_build: &mut HashMap<&str, Instant>,
verbose: bool,
app_name: &str,
application_id: &str,
dev_shell: bool,
dev_shell_refresh_url: Option<&str>,
dev_shell_compile: bool,
refresh_sequence: &mut u64,
) {
let start = Instant::now();
match run_nativ_build(
project_dir,
ios_flag,
android_flag,
level,
changed_screens,
verbose,
dev_shell,
) {
Ok(results) => {
let elapsed = start.elapsed();
let file_count: usize = results.iter().map(|r| r.generated_files.len()).sum();
println!(
" Build OK: {} files generated ({:.2}s)",
file_count,
elapsed.as_secs_f64()
);
if let Some(url) = dev_shell_refresh_url
&& let Err(e) = seed_dev_shell_refresh_url(project_dir, &results, url)
{
eprintln!(" warning: could not seed dev refresh URL: {e}");
}
let sequence = next_refresh_sequence(refresh_sequence);
let execute_native_compile =
should_execute_dev_shell_compile(dev_shell, dev_shell_compile, level);
let refresh_result = if execute_native_compile {
write_dev_refresh_manifest_with_compile(
project_dir,
level,
changed_screens,
&results,
sequence,
true,
)
} else {
write_dev_refresh_manifest(project_dir, level, changed_screens, &results, sequence)
};
if let Err(e) = refresh_result {
eprintln!(" warning: could not write dev refresh manifest: {e}");
}
if should_skip_native_build_for_dev_shell(dev_shell, level) {
if execute_native_compile {
println!(" [Dev Shell] Patch native compile recorded; full rebuild skipped");
} else {
println!(" [Dev Shell] Patch refresh ready; native rebuild skipped");
}
return;
}
let now = Instant::now();
if ios_flag {
if cooldown_elapsed(last_native_build, "ios", now) {
last_native_build.insert("ios", now);
run_xcodebuild(project_dir, app_name);
} else {
println!(" [iOS] native build skipped (cooldown)");
}
}
if android_flag {
if cooldown_elapsed(last_native_build, "android", now) {
last_native_build.insert("android", now);
run_gradle_assemble(project_dir, application_id);
} else {
println!(" [Android] native build skipped (cooldown)");
}
}
}
Err(e) => {
let message = e.to_string();
eprintln!(" Build failed:\n{}", indent_error(&message));
let sequence = next_refresh_sequence(refresh_sequence);
if let Err(e) = write_dev_refresh_error_manifest(
project_dir,
level,
changed_screens,
&message,
sequence,
) {
eprintln!(" warning: could not write dev refresh manifest: {e}");
}
}
}
}
fn cooldown_elapsed(last: &HashMap<&str, Instant>, key: &str, now: Instant) -> bool {
last.get(key)
.is_none_or(|last_time| now.duration_since(*last_time) >= NATIVE_BUILD_COOLDOWN)
}
fn run_nativ_build(
project_dir: &Path,
ios_flag: bool,
android_flag: bool,
level: ChangeLevel,
changed_screens: &[String],
verbose: bool,
dev_shell: bool,
) -> Result<Vec<nativ_pipeline::BuildResult>, Box<dyn std::error::Error>> {
let config = NativConfig::load(&project_dir.join("nativ.toml"))?;
let targets = nativ_pipeline::resolve_targets(ios_flag, android_flag, &config);
if targets.is_empty() {
return Err("No target platform specified. Enable ios or android in nativ.toml".into());
}
let results = nativ_pipeline::incremental_build_with_options(
project_dir,
&config,
&targets,
&level.to_string(),
changed_screens,
BuildOptions { dev: dev_shell },
)?;
if verbose {
for result in &results {
let platform = match result.target {
nativ_pipeline::Target::Ios => "iOS",
nativ_pipeline::Target::Android => "Android",
};
println!(" {platform}: {} files", result.generated_files.len());
for file in &result.generated_files {
println!(" -> {}", file.display());
}
}
}
Ok(results)
}
fn seed_dev_shell_refresh_url(
project_dir: &Path,
results: &[nativ_pipeline::BuildResult],
url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let escaped = escape_dev_shell_string(url);
for file in results.iter().flat_map(|result| &result.generated_files) {
let path = if file.is_absolute() || file.exists() {
file.clone()
} else {
project_dir.join(file)
};
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name != "NativDevMenuView.swift" && name != "NativDevMenu.kt" {
continue;
}
let mut content = std::fs::read_to_string(&path)?;
if name.ends_with(".swift") {
content = content
.replace(
"@State private var refreshURL = \"\"",
&format!("@State private var refreshURL = \"{escaped}\""),
)
.replace(
"@State private var refreshStatus = \"Enter the nativ dev refresh URL, then fetch.\"",
"@State private var refreshStatus = \"Auto-polling dev refresh manifest.\"",
)
.replace(
"@State private var autoPollRefresh = false",
"@State private var autoPollRefresh = true",
);
} else {
content = content
.replace(
"var refreshUrl by remember { mutableStateOf(\"\") }",
&format!("var refreshUrl by remember {{ mutableStateOf(\"{escaped}\") }}"),
)
.replace(
"var refreshStatus by remember { mutableStateOf(\"Enter the nativ dev refresh URL, then fetch.\") }",
"var refreshStatus by remember { mutableStateOf(\"Auto-polling dev refresh manifest.\") }",
)
.replace(
"var autoRefresh by remember { mutableStateOf(false) }",
"var autoRefresh by remember { mutableStateOf(true) }",
);
}
std::fs::write(path, content)?;
}
Ok(())
}
fn escape_dev_shell_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn should_skip_native_build_for_dev_shell(dev_shell: bool, level: ChangeLevel) -> bool {
dev_shell && level == ChangeLevel::Patch
}
fn should_execute_dev_shell_compile(
dev_shell: bool,
dev_shell_compile: bool,
level: ChangeLevel,
) -> bool {
dev_shell_compile && should_skip_native_build_for_dev_shell(dev_shell, level)
}
fn next_refresh_sequence(sequence: &mut u64) -> u64 {
*sequence = sequence.saturating_add(1);
*sequence
}
#[derive(Serialize)]
struct DevRefreshManifest {
version: u32,
sequence: u64,
status: &'static str,
change: String,
changed_screens: Vec<String>,
generated_file_count: usize,
targets: Vec<DevRefreshTarget>,
error: Option<String>,
timestamp_ms: u128,
}
#[derive(Serialize)]
struct DevRefreshTarget {
target: &'static str,
generated_files: Vec<String>,
served_files: Vec<String>,
native_compile: Option<DevNativeCompile>,
}
#[derive(Serialize)]
struct DevNativeCompile {
cwd: String,
program: String,
args: Vec<String>,
inputs: Vec<String>,
artifacts: Vec<String>,
result: Option<DevNativeCompileResult>,
}
#[derive(Serialize)]
struct DevNativeCompileResult {
status: &'static str,
duration_ms: u128,
exit_code: Option<i32>,
stderr_excerpt: Option<String>,
}
fn write_dev_refresh_manifest(
project_dir: &Path,
level: ChangeLevel,
changed_screens: &[String],
results: &[nativ_pipeline::BuildResult],
sequence: u64,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
write_dev_refresh_manifest_with_compile(
project_dir,
level,
changed_screens,
results,
sequence,
false,
)
}
fn write_dev_refresh_manifest_with_compile(
project_dir: &Path,
level: ChangeLevel,
changed_screens: &[String],
results: &[nativ_pipeline::BuildResult],
sequence: u64,
execute_native_compile: bool,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let mut targets: Vec<DevRefreshTarget> = results
.iter()
.map(|result| {
dev_refresh_target(project_dir, level, result, sequence, execute_native_compile)
})
.collect();
if execute_native_compile {
execute_dev_native_compile_plans(project_dir, &mut targets);
}
let generated_file_count = targets.iter().map(|t| t.generated_files.len()).sum();
let manifest = DevRefreshManifest {
version: 5,
sequence,
status: "ok",
change: level.as_str().to_string(),
changed_screens: changed_screens.to_vec(),
generated_file_count,
targets,
error: None,
timestamp_ms: now_ms(),
};
write_dev_refresh_json(project_dir, &manifest)
}
fn dev_refresh_target(
project_dir: &Path,
level: ChangeLevel,
result: &nativ_pipeline::BuildResult,
sequence: u64,
serve_native_artifacts: bool,
) -> DevRefreshTarget {
let generated_files: Vec<String> = result
.generated_files
.iter()
.map(|path| manifest_path(project_dir, path))
.collect();
let mut served_files: Vec<String> = generated_files
.iter()
.map(|path| format!("/files/{}", percent_encode_path(path)))
.collect();
let native_compile = dev_native_compile_plan(
project_dir,
level,
result.target,
&generated_files,
sequence,
);
if serve_native_artifacts && let Some(plan) = &native_compile {
served_files.extend(
plan.artifacts
.iter()
.map(|path| format!("/files/{}", percent_encode_path(path))),
);
}
DevRefreshTarget {
target: target_name(result.target),
generated_files,
served_files,
native_compile,
}
}
fn dev_native_compile_plan(
project_dir: &Path,
level: ChangeLevel,
target: Target,
generated_files: &[String],
sequence: u64,
) -> Option<DevNativeCompile> {
if level != ChangeLevel::Patch {
return None;
}
match target {
Target::Ios => ios_native_compile_plan(project_dir, generated_files, sequence),
Target::Android => android_native_compile_plan(project_dir, generated_files, sequence),
}
}
fn ios_native_compile_plan(
project_dir: &Path,
generated_files: &[String],
sequence: u64,
) -> Option<DevNativeCompile> {
let changed_swift: Vec<String> = generated_files
.iter()
.filter(|path| path.ends_with(".swift"))
.cloned()
.collect();
if changed_swift.is_empty() {
return None;
}
let ios_root =
target_output_root(generated_files, "ios").unwrap_or_else(|| "build/ios".to_string());
let module = format!("NativDevPatch{sequence}");
let factory_class = format!("NativDevPatchFactory{sequence}");
let patch_dir = format!("{ios_root}/.nativ-dev-patch");
let factory = format!("{patch_dir}/{factory_class}.swift");
let artifact = format!("{patch_dir}/lib{module}.dylib");
let root_view = ios_patch_root_view(project_dir, &changed_swift)?;
write_ios_patch_factory(project_dir, &ios_root, &factory, &factory_class, &root_view).ok()?;
let mut inputs = ios_patch_compile_inputs(project_dir, &project_dir.join(&ios_root));
if inputs.is_empty() {
inputs = changed_swift.clone();
} else {
inputs.extend(changed_swift.iter().cloned());
inputs.sort();
inputs.dedup();
}
inputs.push(factory.clone());
inputs.sort();
inputs.dedup();
let mut args = vec![
"--sdk".to_string(),
"iphonesimulator".to_string(),
"swiftc".to_string(),
"-target".to_string(),
ios_simulator_swift_target().to_string(),
"-emit-library".to_string(),
"-emit-module".to_string(),
"-module-name".to_string(),
module,
"-o".to_string(),
artifact.clone(),
];
args.extend(inputs.iter().cloned());
Some(DevNativeCompile {
cwd: ".".to_string(),
program: "xcrun".to_string(),
args,
inputs,
artifacts: vec![artifact],
result: None,
})
}
fn ios_patch_root_view(project_dir: &Path, changed_swift: &[String]) -> Option<String> {
changed_swift
.iter()
.filter_map(|path| swift_app_root_view(project_dir, path))
.next()
.or_else(|| {
changed_swift
.iter()
.filter_map(|path| swift_patch_root_view(project_dir, path))
.next()
})
}
fn swift_app_root_view(project_dir: &Path, path: &str) -> Option<String> {
let source = std::fs::read_to_string(project_dir.join(path)).ok()?;
if !source.contains("@main") {
return None;
}
let mut in_window_group = false;
for line in source.lines() {
if line.contains("WindowGroup") {
in_window_group = true;
}
if !in_window_group || line.contains("NativDevMenuView") {
continue;
}
for token in line.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') {
if token.ends_with("View") && line.contains(&format!("{token}(")) {
return Some(token.to_string());
}
}
}
None
}
fn swift_patch_root_view(project_dir: &Path, path: &str) -> Option<String> {
let name = Path::new(path).file_stem()?.to_string_lossy();
if name == "NativDevMenuView" {
return None;
}
let root_view = format!("{name}View");
let source = std::fs::read_to_string(project_dir.join(path)).ok()?;
if source.contains(&format!("struct {root_view}")) {
Some(root_view)
} else {
None
}
}
fn ios_simulator_swift_target() -> &'static str {
if cfg!(target_arch = "x86_64") {
"x86_64-apple-ios17.0-simulator"
} else {
"arm64-apple-ios17.0-simulator"
}
}
fn ios_patch_compile_inputs(project_dir: &Path, ios_root: &Path) -> Vec<String> {
let mut files = Vec::new();
collect_ios_patch_compile_inputs(project_dir, ios_root, &mut files);
files.sort();
files.dedup();
files
}
fn collect_ios_patch_compile_inputs(project_dir: &Path, dir: &Path, files: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path
.components()
.any(|component| component.as_os_str() == ".nativ-dev-patch")
{
continue;
}
if path.is_dir() {
collect_ios_patch_compile_inputs(project_dir, &path, files);
} else if path
.extension()
.is_some_and(|value| value.to_string_lossy().eq_ignore_ascii_case("swift"))
&& is_ios_patch_compile_input(&path)
{
files.push(manifest_path(project_dir, &path));
}
}
}
fn is_ios_patch_compile_input(path: &Path) -> bool {
if path
.file_name()
.is_some_and(|name| name == "NativDevMenuView.swift")
{
return false;
}
std::fs::read_to_string(path).is_ok_and(|source| !source.contains("@main"))
}
fn write_ios_patch_factory(
project_dir: &Path,
ios_root: &str,
factory: &str,
factory_class: &str,
root_view: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let factory_path = project_dir.join(factory);
if let Some(parent) = factory_path.parent() {
std::fs::create_dir_all(parent)?;
}
let root = ios_patch_root_expression(project_dir, ios_root, root_view);
let source = format!(
r#"import SwiftUI
import UIKit
@objc({factory_class})
public final class {factory_class}: NSObject {{
@objc public func makeViewController() -> UIViewController {{
UIHostingController(rootView: {root})
}}
}}
"#
);
std::fs::write(factory_path, source)?;
Ok(())
}
fn ios_patch_root_expression(project_dir: &Path, ios_root: &str, root_view: &str) -> String {
let mut root = format!("{root_view}()");
if project_dir.join(ios_root).join("AppState.swift").exists() {
root.push_str(".environment(AppState())");
}
if project_dir.join(ios_root).join("Router.swift").exists() {
root.push_str(".environment(Router())");
}
root
}
fn android_native_compile_plan(
project_dir: &Path,
generated_files: &[String],
sequence: u64,
) -> Option<DevNativeCompile> {
let changed_kt: Vec<String> = generated_files
.iter()
.filter(|path| path.ends_with(".kt"))
.cloned()
.collect();
if changed_kt.is_empty() {
return None;
}
let android_root = target_output_root(generated_files, "android")
.unwrap_or_else(|| "build/android".to_string());
let factory_class = format!("NativDevPatchFactory{sequence}");
let factory = format!("{android_root}/app/src/main/java/com/example/app/{factory_class}.kt");
let artifact = format!("{android_root}/app/build/outputs/apk/debug/app-debug.apk");
write_android_patch_factory(project_dir, &factory, &factory_class).ok()?;
let mut inputs =
manifest_files_with_extension(project_dir, &project_dir.join(&android_root), "kt");
if inputs.is_empty() {
inputs = changed_kt.clone();
} else {
inputs.extend(changed_kt.iter().cloned());
inputs.sort();
inputs.dedup();
}
inputs.push(factory);
inputs.sort();
inputs.dedup();
let changed = changed_kt
.iter()
.map(|path| {
path.strip_prefix(&format!("{android_root}/"))
.unwrap_or(path)
})
.collect::<Vec<_>>()
.join(",");
let program = android_gradle_program(project_dir, &android_root);
Some(DevNativeCompile {
cwd: android_root,
program,
args: vec![
"assembleDebug".to_string(),
format!("-Pnativ.changed={changed}"),
],
inputs,
artifacts: vec![artifact],
result: None,
})
}
fn write_android_patch_factory(
project_dir: &Path,
factory: &str,
factory_class: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let factory_path = project_dir.join(factory);
if let Some(parent) = factory_path.parent() {
std::fs::create_dir_all(parent)?;
}
let source = format!(
r#"package com.example.app
import androidx.compose.runtime.Composable
class {factory_class} : NativDevPatchFactory {{
@Composable
override fun Content() {{
AppNavHost()
}}
}}
"#
);
std::fs::write(factory_path, source)?;
Ok(())
}
fn android_gradle_program(project_dir: &Path, android_root: &str) -> String {
let wrapper_name = if cfg!(windows) {
"gradlew.bat"
} else {
"gradlew"
};
if project_dir.join(android_root).join(wrapper_name).exists() {
if cfg!(windows) {
format!(".\\{wrapper_name}")
} else {
format!("./{wrapper_name}")
}
} else {
"gradle".to_string()
}
}
fn execute_dev_native_compile_plans(project_dir: &Path, targets: &mut [DevRefreshTarget]) {
for target in targets {
let Some(plan) = target.native_compile.as_mut() else {
continue;
};
println!(
" [Dev Shell] {}: {} {}",
target.target,
plan.program,
plan.args.join(" ")
);
let result = execute_dev_native_compile_plan(project_dir, plan);
plan.result = Some(result);
}
}
fn execute_dev_native_compile_plan(
project_dir: &Path,
plan: &DevNativeCompile,
) -> DevNativeCompileResult {
let cwd = compile_plan_cwd(project_dir, &plan.cwd);
let start = Instant::now();
let output = std::process::Command::new(&plan.program)
.args(&plan.args)
.current_dir(cwd)
.output();
let duration_ms = start.elapsed().as_millis();
match output {
Ok(output) => {
if output.status.success()
&& let Some(sign_error) = sign_dev_native_compile_artifacts(project_dir, plan)
{
return DevNativeCompileResult {
status: "failed",
duration_ms: start.elapsed().as_millis(),
exit_code: sign_error.exit_code,
stderr_excerpt: sign_error.stderr_excerpt,
};
}
DevNativeCompileResult {
status: if output.status.success() {
"ok"
} else {
"failed"
},
duration_ms,
exit_code: output.status.code(),
stderr_excerpt: command_output_excerpt(&output.stderr),
}
}
Err(error) => DevNativeCompileResult {
status: "skipped",
duration_ms,
exit_code: None,
stderr_excerpt: Some(error.to_string()),
},
}
}
struct DevNativeCompileSignError {
exit_code: Option<i32>,
stderr_excerpt: Option<String>,
}
fn sign_dev_native_compile_artifacts(
project_dir: &Path,
plan: &DevNativeCompile,
) -> Option<DevNativeCompileSignError> {
for artifact in plan
.artifacts
.iter()
.filter(|path| path.ends_with(".dylib"))
{
let output = std::process::Command::new("codesign")
.args(["--force", "--sign", "-", "--timestamp=none"])
.arg(compile_plan_cwd(project_dir, artifact))
.output();
match output {
Ok(output) if output.status.success() => {}
Ok(output) => {
return Some(DevNativeCompileSignError {
exit_code: output.status.code(),
stderr_excerpt: command_output_excerpt(&output.stderr),
});
}
Err(error) => {
return Some(DevNativeCompileSignError {
exit_code: None,
stderr_excerpt: Some(error.to_string()),
});
}
}
}
None
}
fn compile_plan_cwd(project_dir: &Path, cwd: &str) -> PathBuf {
let path = Path::new(cwd);
if path.is_absolute() {
path.to_path_buf()
} else {
project_dir.join(path)
}
}
fn command_output_excerpt(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.lines().take(20).collect::<Vec<_>>().join("\n"))
}
}
fn target_output_root(generated_files: &[String], target_dir: &str) -> Option<String> {
let prefixed = format!("{target_dir}/");
let nested = format!("/{target_dir}/");
for path in generated_files {
if path.starts_with(&prefixed) {
return Some(target_dir.to_string());
}
if let Some(index) = path.find(&nested) {
return Some(path[..index + nested.len() - 1].to_string());
}
}
None
}
fn manifest_files_with_extension(project_dir: &Path, dir: &Path, ext: &str) -> Vec<String> {
let mut files = Vec::new();
collect_manifest_files_with_extension(project_dir, dir, ext, &mut files);
files.sort();
files.dedup();
files
}
fn collect_manifest_files_with_extension(
project_dir: &Path,
dir: &Path,
ext: &str,
files: &mut Vec<String>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_manifest_files_with_extension(project_dir, &path, ext, files);
} else if path
.extension()
.is_some_and(|value| value.to_string_lossy().eq_ignore_ascii_case(ext))
{
files.push(manifest_path(project_dir, &path));
}
}
}
fn write_dev_refresh_error_manifest(
project_dir: &Path,
level: ChangeLevel,
changed_screens: &[String],
message: &str,
sequence: u64,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let manifest = DevRefreshManifest {
version: 5,
sequence,
status: "error",
change: level.as_str().to_string(),
changed_screens: changed_screens.to_vec(),
generated_file_count: 0,
targets: Vec::new(),
error: Some(message.to_string()),
timestamp_ms: now_ms(),
};
write_dev_refresh_json(project_dir, &manifest)
}
fn write_dev_refresh_json(
project_dir: &Path,
manifest: &DevRefreshManifest,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(manifest)?;
let output_dir = project_dir.join("build");
std::fs::create_dir_all(&output_dir)?;
let path = output_dir.join(".nativ-dev-refresh.json");
let tmp_path = output_dir.join(".nativ-dev-refresh.json.tmp");
std::fs::write(&tmp_path, json)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
std::fs::rename(&tmp_path, &path)?;
Ok(path)
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn target_name(target: Target) -> &'static str {
match target {
Target::Ios => "ios",
Target::Android => "android",
}
}
fn manifest_path(project_dir: &Path, path: &Path) -> String {
path.strip_prefix(project_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn percent_encode_path(path: &str) -> String {
let mut encoded = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
encoded.push('%');
encoded.push(hex_digit(byte >> 4));
encoded.push(hex_digit(byte & 0x0f));
}
}
}
encoded
}
fn percent_decode_path(path: &str) -> Option<String> {
let bytes = path.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hi = from_hex_digit(*bytes.get(i + 1)?)?;
let lo = from_hex_digit(*bytes.get(i + 2)?)?;
decoded.push((hi << 4) | lo);
i += 3;
} else {
decoded.push(bytes[i]);
i += 1;
}
}
String::from_utf8(decoded).ok()
}
fn hex_digit(value: u8) -> char {
match value {
0..=9 => (b'0' + value) as char,
10..=15 => (b'A' + value - 10) as char,
_ => unreachable!("hex digit out of range"),
}
}
fn from_hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn start_dev_shell_server(
project_dir: &Path,
port: u16,
) -> Result<SocketAddr, Box<dyn std::error::Error>> {
let listener = TcpListener::bind(("0.0.0.0", port))?;
let addr = listener.local_addr()?;
let project_dir = project_dir.to_path_buf();
thread::spawn(move || {
for stream in listener.incoming().flatten() {
handle_dev_shell_request(stream, &project_dir);
}
});
Ok(addr)
}
fn handle_dev_shell_request(mut stream: TcpStream, project_dir: &Path) {
let mut buf = [0; 1024];
let Ok(n) = stream.read(&mut buf) else {
return;
};
if n == 0 {
return;
}
let request = String::from_utf8_lossy(&buf[..n]);
let method = request.split_whitespace().next().unwrap_or_default();
let Some(raw_path) = request.split_whitespace().nth(1) else {
return;
};
let path = raw_path.split('?').next().unwrap_or(raw_path);
if method == "OPTIONS" {
write_http(&mut stream, "204 No Content", "text/plain", "");
return;
}
if method != "GET" {
write_http(
&mut stream,
"405 Method Not Allowed",
"text/plain",
"method not allowed",
);
return;
}
if path == "/__nativ_refresh" {
let manifest = project_dir.join("build").join(".nativ-dev-refresh.json");
match std::fs::read_to_string(&manifest) {
Ok(body) => write_http(&mut stream, "200 OK", "application/json", &body),
Err(_) => write_http(
&mut stream,
"404 Not Found",
"application/json",
r#"{"status":"missing","error":"refresh manifest not found"}"#,
),
}
return;
}
if let Some(file) = dev_shell_file_path(project_dir, path) {
match std::fs::read(&file) {
Ok(body) => write_http_bytes(&mut stream, "200 OK", content_type(&file), &body),
Err(_) => write_http(&mut stream, "404 Not Found", "text/plain", "not found"),
}
return;
}
write_http(&mut stream, "404 Not Found", "text/plain", "not found");
}
fn dev_shell_file_path(project_dir: &Path, request_path: &str) -> Option<PathBuf> {
let relative = request_path.strip_prefix("/files/")?;
let decoded = percent_decode_path(relative)?;
let path = Path::new(&decoded);
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => clean.push(part),
_ => return None,
}
}
if !dev_shell_allowed_file_roots(project_dir)
.iter()
.any(|root| clean.starts_with(root))
{
return None;
}
Some(project_dir.join(clean))
}
fn dev_shell_allowed_file_roots(project_dir: &Path) -> Vec<PathBuf> {
let mut roots = vec![PathBuf::from("build")];
if let Ok(config) = NativConfig::load(&project_dir.join("nativ.toml")) {
let output = PathBuf::from(config.output.directory);
if !output.as_os_str().is_empty()
&& output
.components()
.all(|component| matches!(component, Component::Normal(_)))
&& !roots.iter().any(|root| root == &output)
{
roots.push(output);
}
}
roots
}
fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => "application/json",
Some("swift") => "text/plain; charset=utf-8",
Some("kt") | Some("kts") => "text/plain; charset=utf-8",
Some("xml") => "application/xml; charset=utf-8",
Some("plist") => "application/xml; charset=utf-8",
Some("apk") | Some("aab") | Some("dex") | Some("jar") | Some("zip") | Some("bin") => {
"application/octet-stream"
}
_ => "text/plain; charset=utf-8",
}
}
fn write_http(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
write_http_bytes(stream, status, content_type, body.as_bytes());
}
fn write_http_bytes(stream: &mut TcpStream, status: &str, content_type: &str, body: &[u8]) {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\nAccess-Control-Allow-Headers: *\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Write);
}
fn local_ip_hint() -> String {
UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
let _ = sock.connect("8.8.8.8:80");
sock.local_addr()
})
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|_| "<your-computer-ip>".to_string())
}
fn run_xcodebuild(project_dir: &Path, app_name: &str) {
let ios_project = project_dir
.join("build")
.join("ios")
.join(format!("{app_name}.xcodeproj"));
if !ios_project.exists() {
println!(
" [iOS] Xcode project not found at {}; skipping",
ios_project.display()
);
return;
}
let derived_data = project_dir.join("build").join("ios").join("DerivedData");
println!(" [iOS] Building for simulator...");
let result = std::process::Command::new("xcrun")
.args([
"xcodebuild",
"build",
"-project",
&ios_project.to_string_lossy(),
"-scheme",
app_name,
"-destination",
"generic/platform=iOS Simulator",
"-derivedDataPath",
&derived_data.to_string_lossy(),
"CODE_SIGNING_ALLOWED=NO",
"-quiet",
])
.output();
match result {
Ok(output) => {
if output.status.success() {
println!(" [iOS] Simulator build succeeded");
install_and_launch_ios(&derived_data);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
eprintln!(" [iOS] Simulator build FAILED");
for line in stderr.lines().take(20) {
eprintln!(" {line}");
}
if verbose_logging() {
for line in stdout.lines().take(10) {
println!(" {line}");
}
}
}
}
Err(e) => {
eprintln!(" [iOS] Could not launch xcodebuild: {e}");
eprintln!(" (This is expected on non-macOS hosts)");
}
}
}
fn install_and_launch_ios(derived_data: &Path) {
let products_dir = derived_data
.join("Build")
.join("Products")
.join("Debug-iphonesimulator");
let app_bundle = match std::fs::read_dir(&products_dir).ok().and_then(|entries| {
entries
.filter_map(|e| e.ok())
.find(|e| e.path().extension().is_some_and(|ext| ext == "app"))
}) {
Some(entry) => entry.path(),
None => {
eprintln!(
" [iOS] Build succeeded but .app not found in {}",
products_dir.display()
);
return;
}
};
let app_name = app_bundle
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "app".into());
println!(" [iOS] Installing {app_name} to simulator...");
let mut install_result = run_simctl_install(&app_bundle, true);
if !install_result.as_ref().is_ok_and(|o| o.status.success()) {
install_result = run_simctl_install(&app_bundle, false);
}
let install_ok = install_result.as_ref().is_ok_and(|o| o.status.success());
if !install_ok {
if let Ok(output) = &install_result {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!(" [iOS] simctl install: {}", stderr.trim());
}
}
eprintln!(
" [iOS] Install failed (is a simulator booted? Start one with 'xcrun simctl boot <device-name>')"
);
return;
}
println!(" [iOS] Installed. Relaunching...");
let info_plist = app_bundle.join("Info.plist");
let bundle_id_result = std::process::Command::new("plutil")
.args([
"-extract",
"CFBundleIdentifier",
"raw",
&info_plist.to_string_lossy(),
])
.output();
if let Ok(output) = &bundle_id_result
&& output.status.success()
{
let bundle_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !bundle_id.is_empty() {
let _ = std::process::Command::new("xcrun")
.args(["simctl", "launch", "booted", &bundle_id])
.output();
println!(" [iOS] Launched {bundle_id} on simulator");
}
}
}
fn run_simctl_install(
app_bundle: &Path,
reload_running_app: bool,
) -> std::io::Result<std::process::Output> {
let args = simctl_install_args(app_bundle, reload_running_app);
std::process::Command::new("xcrun").args(args).output()
}
fn simctl_install_args(app_bundle: &Path, reload_running_app: bool) -> Vec<String> {
let mut args = vec!["simctl".to_string(), "install".to_string()];
if reload_running_app {
args.push("--bundle-id-kill-and-reload".to_string());
}
args.push("booted".to_string());
args.push(app_bundle.to_string_lossy().into_owned());
args
}
fn install_and_launch_android(android_dir: &Path, application_id: &str) {
let apk = android_dir
.join("app")
.join("build")
.join("outputs")
.join("apk")
.join("debug")
.join("app-debug.apk");
if !apk.exists() {
eprintln!(
" [Android] Build succeeded but app-debug.apk not found at {}",
apk.display()
);
return;
}
println!(" [Android] Installing to device...");
let install_result = std::process::Command::new("adb")
.args(["install", "-r", &apk.to_string_lossy()])
.output();
let install_ok = install_result.as_ref().is_ok_and(|o| o.status.success());
if !install_ok {
eprintln!(
" [Android] adb install failed (is a device/emulator connected? Start one with 'emulator -avd <name>')"
);
return;
}
println!(" [Android] Launching...");
let _ = std::process::Command::new("adb")
.args([
"shell",
"am",
"start",
"-n",
&format!("{application_id}/.MainActivity"),
])
.output();
println!(" [Android] Launched on device");
}
fn run_gradle_assemble(project_dir: &Path, application_id: &str) {
let android_dir = project_dir.join("build").join("android");
let gradlew = android_dir.join("gradlew");
if !android_dir.exists() {
println!(
" [Android] Build directory not found at {}; skipping",
android_dir.display()
);
return;
}
if !gradlew.exists() {
println!(
" [Android] gradlew not found at {}; skipping",
gradlew.display()
);
println!(" (Run `nativ build --android` first to generate the Gradle wrapper)");
return;
}
println!(" [Android] Building with gradle...");
let result = std::process::Command::new(&gradlew)
.args(["assembleDebug"])
.current_dir(&android_dir)
.output();
match result {
Ok(output) => {
if output.status.success() {
println!(" [Android] assembleDebug succeeded");
install_and_launch_android(&android_dir, application_id);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
eprintln!(" [Android] assembleDebug FAILED");
for line in stderr.lines().take(20) {
eprintln!(" {line}");
}
if verbose_logging() {
for line in stdout.lines().take(10) {
println!(" {line}");
}
}
}
}
Err(e) => {
eprintln!(" [Android] Could not launch gradle: {e}");
}
}
}
fn verbose_logging() -> bool {
std::env::args().any(|a| a == "--verbose" || a == "-v")
}
fn indent_error(msg: &str) -> String {
msg.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn parse_and_cache(
path: &Path,
cache: &mut HashMap<PathBuf, NativFile>,
_verbose: bool,
) -> Result<bool, std::io::Error> {
let content = std::fs::read_to_string(path)?;
match nativ_compiler::parse(&content) {
Ok(ast) => {
cache.insert(dev_cache_path(path), ast);
Ok(true)
}
Err(e) => {
eprintln!(
" warning: parse error in {}: {}",
path.display(),
e.concise_message()
);
Ok(false)
}
}
}
fn dev_cache_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
struct Classification {
path: PathBuf,
level: ChangeLevel,
detail: String,
}
fn classify_batch(
paths: &[&PathBuf],
cache: &mut HashMap<PathBuf, NativFile>,
_verbose: bool,
) -> Vec<Classification> {
let mut results: Vec<Classification> = Vec::new();
let mut unique_paths: Vec<PathBuf> = Vec::new();
for &path in paths {
let path = dev_cache_path(path);
if !unique_paths.iter().any(|seen| seen == &path) {
unique_paths.push(path);
}
}
for path in &unique_paths {
if path.file_name().is_some_and(|n| n == "nativ.toml") {
results.push(Classification {
path: path.clone(),
level: ChangeLevel::App,
detail: "config changed".to_string(),
});
continue;
}
if path.file_name().is_some_and(|n| n == ".nativ.env") {
results.push(Classification {
path: path.clone(),
level: ChangeLevel::App,
detail: "env changed".to_string(),
});
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
results.push(Classification {
path: path.clone(),
level: ChangeLevel::App,
detail: format!("unreadable: {e}"),
});
continue;
}
};
let new_ast = match nativ_compiler::parse(&content) {
Ok(ast) => ast,
Err(e) => {
results.push(Classification {
path: path.clone(),
level: ChangeLevel::App,
detail: format!("parse error: {}", e.concise_message()),
});
continue;
}
};
let (level, detail) = match cache.get(path) {
None => {
let is_new_top_level =
new_ast.app.is_some() || !new_ast.models.is_empty() || !new_ast.apis.is_empty();
if is_new_top_level {
(
ChangeLevel::App,
"new app/model/api declaration added".to_string(),
)
} else if !new_ast.screens.is_empty() || !new_ast.components.is_empty() {
(
ChangeLevel::Screen,
"new screen/component file added".to_string(),
)
} else {
(ChangeLevel::App, "new file added".to_string())
}
}
Some(old_ast) => classify_change(old_ast, &new_ast),
};
cache.insert(path.clone(), new_ast);
results.push(Classification {
path: path.clone(),
level,
detail,
});
}
if results.len() > 1 {
for r in &mut results {
if r.level != ChangeLevel::App {
r.level = ChangeLevel::App;
r.detail = format!("multiple files changed ({})", r.detail);
}
}
}
results
}
fn classify_change(old: &NativFile, new: &NativFile) -> (ChangeLevel, String) {
if app_decl_changed(old, new) {
return (ChangeLevel::App, "app config changed".to_string());
}
if models_changed(old, new) {
return (
ChangeLevel::App,
format!("models changed ({})", models_detail(old, new)),
);
}
if apis_changed(old, new) {
return (ChangeLevel::App, "API declarations changed".to_string());
}
if screens_added_or_removed(old, new) {
return (
ChangeLevel::App,
format!(
"screens added or removed ({})",
name_diff(&old.screens, &new.screens)
),
);
}
if components_added_or_removed(old, new) {
return (
ChangeLevel::App,
format!(
"components added or removed ({})",
name_diff(&old.components, &new.components)
),
);
}
for old_screen in &old.screens {
if let Some(new_screen) = new.screens.iter().find(|s| s.name == old_screen.name) {
if !params_equivalent(&old_screen.params, &new_screen.params) {
return (
ChangeLevel::Screen,
format!(
"\"{}\" parameters changed ({} → {})",
old_screen.name,
param_summary(&old_screen.params),
param_summary(&new_screen.params),
),
);
}
let old_sig = body_signature(&old_screen.body);
let new_sig = body_signature(&new_screen.body);
if old_sig != new_sig {
return (
ChangeLevel::Screen,
format!(
"\"{}\" elements changed ({} items affected)",
old_screen.name,
element_diff_count(&old_screen.body, &new_screen.body),
),
);
}
}
}
for old_comp in &old.components {
if let Some(new_comp) = new.components.iter().find(|c| c.name == old_comp.name) {
if !params_equivalent(&old_comp.params, &new_comp.params) {
return (
ChangeLevel::Screen,
format!(
"\"{}\" parameters changed ({} → {})",
old_comp.name,
param_summary(&old_comp.params),
param_summary(&new_comp.params),
),
);
}
let old_sig = body_signature(&old_comp.body);
let new_sig = body_signature(&new_comp.body);
if old_sig != new_sig {
return (
ChangeLevel::Screen,
format!(
"\"{}\" elements changed ({} items affected)",
old_comp.name,
element_diff_count(&old_comp.body, &new_comp.body),
),
);
}
}
}
let mut patches: Vec<String> = Vec::new();
for old_screen in &old.screens {
if let Some(new_screen) = new.screens.iter().find(|s| s.name == old_screen.name)
&& !body_expression_equal(&old_screen.body, &new_screen.body)
{
patches.push(format!(
"\"{}\" values changed ({} elements)",
old_screen.name,
old_screen.body.len()
));
}
}
for old_comp in &old.components {
if let Some(new_comp) = new.components.iter().find(|c| c.name == old_comp.name)
&& !body_expression_equal(&old_comp.body, &new_comp.body)
{
patches.push(format!(
"\"{}\" values changed ({} elements)",
old_comp.name,
old_comp.body.len()
));
}
}
if !patches.is_empty() {
return (ChangeLevel::Patch, patches.join("; "));
}
(ChangeLevel::Patch, "no semantic change".to_string())
}
fn extract_changed_names(classifications: &[Classification]) -> Vec<String> {
let mut names = Vec::new();
for c in classifications {
if let Some(start) = c.detail.find('"') {
let after_quote = &c.detail[start + 1..];
if let Some(end) = after_quote.find('"') {
let name = &after_quote[..end];
if !name.is_empty() && !names.contains(&name.to_string()) {
names.push(name.to_string());
}
}
}
}
names
}
fn app_decl_changed(old: &NativFile, new: &NativFile) -> bool {
match (&old.app, &new.app) {
(None, None) => false,
(Some(_), None) | (None, Some(_)) => true,
(Some(a), Some(b)) => {
a.name != b.name
|| a.properties.len() != b.properties.len()
|| a.properties
.iter()
.zip(&b.properties)
.any(|(pa, pb)| pa.key != pb.key)
|| app_nav_changed(&a.navigation, &b.navigation)
}
}
}
fn app_nav_changed(old: &Option<NavDecl>, new: &Option<NavDecl>) -> bool {
match (old, new) {
(None, None) => false,
(Some(_), None) | (None, Some(_)) => true,
(Some(a), Some(b)) => a.tabs != b.tabs,
}
}
fn models_changed(old: &NativFile, new: &NativFile) -> bool {
if old.models.len() != new.models.len() {
return true;
}
old.models
.iter()
.zip(&new.models)
.any(|(a, b)| a.name != b.name || a.fields.len() != b.fields.len())
}
fn models_detail(old: &NativFile, new: &NativFile) -> String {
let old_names: Vec<&str> = old.models.iter().map(|m| m.name.as_str()).collect();
let new_names: Vec<&str> = new.models.iter().map(|m| m.name.as_str()).collect();
if old_names == new_names {
"field changes".to_string()
} else {
name_diff_from_vecs(&old_names, &new_names)
}
}
fn apis_changed(old: &NativFile, new: &NativFile) -> bool {
old.apis.len() != new.apis.len()
}
fn screens_added_or_removed(old: &NativFile, new: &NativFile) -> bool {
screen_names(old) != screen_names(new)
}
fn components_added_or_removed(old: &NativFile, new: &NativFile) -> bool {
component_names(old) != component_names(new)
}
fn screen_names(file: &NativFile) -> Vec<&str> {
file.screens.iter().map(|s| s.name.as_str()).collect()
}
fn component_names(file: &NativFile) -> Vec<&str> {
file.components.iter().map(|c| c.name.as_str()).collect()
}
fn name_diff(old: &[impl Named], new: &[impl Named]) -> String {
let old_names: Vec<&str> = old.iter().map(|x| x.name()).collect();
let new_names: Vec<&str> = new.iter().map(|x| x.name()).collect();
name_diff_from_vecs(&old_names, &new_names)
}
fn name_diff_from_vecs(old: &[&str], new: &[&str]) -> String {
let mut parts: Vec<String> = Vec::new();
for name in old {
if !new.contains(name) {
parts.push(format!("-{name}"));
}
}
for name in new {
if !old.contains(name) {
parts.push(format!("+{name}"));
}
}
if parts.is_empty() {
"names unchanged".to_string()
} else {
parts.join(", ")
}
}
trait Named {
fn name(&self) -> &str;
}
impl Named for ScreenDecl {
fn name(&self) -> &str {
&self.name
}
}
impl Named for ComponentDecl {
fn name(&self) -> &str {
&self.name
}
}
fn body_signature(body: &[Statement]) -> Vec<StatementKind> {
let mut sig = Vec::new();
let mut work: Vec<&[Statement]> = vec![body];
while let Some(stmts) = work.pop() {
for stmt in stmts {
sig.push(statement_kind(stmt));
match stmt {
Statement::UiElement(e) => work.push(&e.body),
Statement::Layout(l) => work.push(&l.body),
Statement::Conditional(c) => {
work.push(&c.then_body);
for (_, else_if_body) in &c.else_if_clauses {
work.push(else_if_body);
}
if let Some(eb) = &c.else_body {
work.push(eb);
}
}
Statement::Loop(l) => work.push(&l.body),
Statement::Lifecycle(lc) => work.push(&lc.body),
Statement::EventHandler(ev) => work.push(&ev.body),
Statement::LoadStmt(ld) => work.push(&ld.body),
Statement::AnimateStmt(AnimateStmt::Block(b, _)) => {
work.push(b);
}
_ => {}
}
}
}
sig
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum StatementKind {
StateDecl,
UiElement(UiElementKindDiscriminant),
Layout(LayoutKindDiscriminant),
Conditional,
Loop,
Lifecycle,
EventHandler,
Action,
ComponentCall,
PropertyLine,
StoreDecl,
RememberStmt,
AnimateStmt,
LoadStmt,
RawBlock,
NavigationDecl,
PermissionsDecl,
BackgroundTaskDecl,
DeepLinksDecl,
StoreProductsDecl,
DatabaseDecl,
FreeCall,
MlModelDecl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum UiElementKindDiscriminant {
Text,
Button,
Image,
Toggle,
TextField,
Spinner,
Divider,
Spacer,
Input,
Chart,
Video,
Canvas,
Circle,
Rect,
Line,
Path,
}
impl From<&UiElementKind> for UiElementKindDiscriminant {
fn from(kind: &UiElementKind) -> Self {
match kind {
UiElementKind::Text => Self::Text,
UiElementKind::Button => Self::Button,
UiElementKind::Image => Self::Image,
UiElementKind::Toggle => Self::Toggle,
UiElementKind::TextField => Self::TextField,
UiElementKind::Spinner => Self::Spinner,
UiElementKind::Divider => Self::Divider,
UiElementKind::Spacer => Self::Spacer,
UiElementKind::Input => Self::Input,
UiElementKind::Chart => Self::Chart,
UiElementKind::Video => Self::Video,
UiElementKind::Canvas => Self::Canvas,
UiElementKind::Circle => Self::Circle,
UiElementKind::Rect => Self::Rect,
UiElementKind::Line => Self::Line,
UiElementKind::Path => Self::Path,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum LayoutKindDiscriminant {
Column,
Row,
Scroll,
Card,
Section,
List,
Form,
}
impl From<&LayoutKind> for LayoutKindDiscriminant {
fn from(kind: &LayoutKind) -> Self {
match kind {
LayoutKind::Column => Self::Column,
LayoutKind::Row => Self::Row,
LayoutKind::Scroll => Self::Scroll,
LayoutKind::Card => Self::Card,
LayoutKind::Section => Self::Section,
LayoutKind::List => Self::List,
LayoutKind::Form => Self::Form,
}
}
}
fn statement_kind(stmt: &Statement) -> StatementKind {
match stmt {
Statement::StateDecl(_) => StatementKind::StateDecl,
Statement::UiElement(e) => {
StatementKind::UiElement(UiElementKindDiscriminant::from(&e.kind))
}
Statement::Layout(l) => StatementKind::Layout(LayoutKindDiscriminant::from(&l.kind)),
Statement::Conditional(_) => StatementKind::Conditional,
Statement::Loop(_) => StatementKind::Loop,
Statement::Lifecycle(_) => StatementKind::Lifecycle,
Statement::EventHandler(_) => StatementKind::EventHandler,
Statement::Action(_) => StatementKind::Action,
Statement::ComponentCall(_) => StatementKind::ComponentCall,
Statement::PropertyLine(_) => StatementKind::PropertyLine,
Statement::StoreDecl(_) => StatementKind::StoreDecl,
Statement::RememberStmt(_) => StatementKind::RememberStmt,
Statement::AnimateStmt(_) => StatementKind::AnimateStmt,
Statement::LoadStmt(_) => StatementKind::LoadStmt,
Statement::RawBlock(_) => StatementKind::RawBlock,
Statement::NavigationDecl(_) => StatementKind::NavigationDecl,
Statement::PermissionsDecl(_) => StatementKind::PermissionsDecl,
Statement::BackgroundTaskDecl(_) => StatementKind::BackgroundTaskDecl,
Statement::DeepLinksDecl(_) => StatementKind::DeepLinksDecl,
Statement::StoreProductsDecl(_) => StatementKind::StoreProductsDecl,
Statement::DatabaseDecl(_) => StatementKind::DatabaseDecl,
Statement::FreeCall(_) => StatementKind::FreeCall,
Statement::MlModelDecl(_) => StatementKind::MlModelDecl,
}
}
fn element_diff_count(old_body: &[Statement], new_body: &[Statement]) -> usize {
let affected = old_body.len().max(new_body.len());
let old_sig = body_signature(old_body);
let new_sig = body_signature(new_body);
(0..affected)
.filter(|&i| old_sig.get(i) != new_sig.get(i))
.count()
}
fn param_summary(params: &[Param]) -> String {
if params.is_empty() {
"none".to_string()
} else {
params
.iter()
.map(|p| {
let mut s = p.name.clone();
if let Some(ty) = &p.type_annotation {
s.push_str(&format!(": {ty:?}"));
}
s
})
.collect::<Vec<_>>()
.join(", ")
}
}
fn params_equivalent(old: &[Param], new: &[Param]) -> bool {
old.len() == new.len()
&& old
.iter()
.zip(new)
.all(|(old, new)| old.name == new.name && old.type_annotation == new.type_annotation)
}
fn body_expression_equal(old_body: &[Statement], new_body: &[Statement]) -> bool {
if old_body.len() != new_body.len() {
return false;
}
let old_dbg = debug_strip_spans(old_body);
let new_dbg = debug_strip_spans(new_body);
old_dbg == new_dbg
}
fn debug_strip_spans(stmts: &[Statement]) -> String {
let raw = format!("{stmts:#?}");
raw.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.starts_with("span:")
&& !trimmed.starts_with("Span {")
&& trimmed != "},"
&& !trimmed.starts_with("col:")
&& !trimmed.starts_with("line:")
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use nativ_compiler::parse;
#[test]
fn application_id_prefers_explicit_bundle_id() {
let config =
NativConfig::parse("[app]\nname = \"Demo\"\nbundle_id = \"dev.nativ.demo\"\n").unwrap();
assert_eq!(application_id(&config), "dev.nativ.demo");
}
#[test]
fn application_id_falls_back_to_sanitized_app_name() {
let config = NativConfig::parse("[app]\nname = \"My Cool-App!\"\n").unwrap();
assert_eq!(application_id(&config), "com.example.mycoolapp");
}
#[test]
fn dev_menu_default_uses_config_targets() {
let config =
NativConfig::parse("[app]\nname = \"Demo\"\n\n[build]\nios = false\nandroid = true\n")
.unwrap();
assert_eq!(parse_dev_menu_choice("", &config), Some((false, true)));
}
#[test]
fn dev_menu_can_select_both_native_targets() {
let config = NativConfig::parse("[app]\nname = \"Demo\"\n").unwrap();
assert_eq!(parse_dev_menu_choice("3", &config), Some((true, true)));
assert_eq!(parse_dev_menu_choice("x", &config), None);
}
#[test]
fn dev_shell_skips_native_builds_only_for_patch_changes() {
assert!(should_skip_native_build_for_dev_shell(
true,
ChangeLevel::Patch
));
assert!(!should_skip_native_build_for_dev_shell(
true,
ChangeLevel::Screen
));
assert!(!should_skip_native_build_for_dev_shell(
true,
ChangeLevel::App
));
assert!(!should_skip_native_build_for_dev_shell(
false,
ChangeLevel::Patch
));
}
#[test]
fn dev_shell_compile_executes_only_for_patch_dev_shell_changes() {
assert!(should_execute_dev_shell_compile(
true,
true,
ChangeLevel::Patch
));
assert!(!should_execute_dev_shell_compile(
true,
true,
ChangeLevel::Screen
));
assert!(!should_execute_dev_shell_compile(
true,
false,
ChangeLevel::Patch
));
assert!(!should_execute_dev_shell_compile(
false,
true,
ChangeLevel::Patch
));
}
#[test]
fn dev_native_compile_plan_executes_command_and_records_timing() {
let tmp = tempfile::tempdir().unwrap();
let program = std::env::current_exe()
.unwrap()
.to_string_lossy()
.to_string();
let plan = DevNativeCompile {
cwd: ".".to_string(),
program,
args: vec!["--help".to_string()],
inputs: vec![],
artifacts: vec![],
result: None,
};
let result = execute_dev_native_compile_plan(tmp.path(), &plan);
assert_eq!(result.status, "ok");
assert_eq!(result.exit_code, Some(0));
}
#[test]
fn dev_native_compile_plan_records_missing_tool_as_skipped() {
let tmp = tempfile::tempdir().unwrap();
let plan = DevNativeCompile {
cwd: ".".to_string(),
program: "definitely-not-a-nativ-tool".to_string(),
args: vec![],
inputs: vec![],
artifacts: vec![],
result: None,
};
let result = execute_dev_native_compile_plan(tmp.path(), &plan);
assert_eq!(result.status, "skipped");
assert!(result.stderr_excerpt.is_some());
}
#[test]
fn dev_native_compile_result_serializes_into_refresh_target() {
let tmp = tempfile::tempdir().unwrap();
let program = std::env::current_exe()
.unwrap()
.to_string_lossy()
.to_string();
let mut targets = vec![DevRefreshTarget {
target: "ios",
generated_files: vec!["build/ios/Home.swift".to_string()],
served_files: vec!["/files/build/ios/Home.swift".to_string()],
native_compile: Some(DevNativeCompile {
cwd: ".".to_string(),
program,
args: vec!["--help".to_string()],
inputs: vec!["build/ios/Home.swift".to_string()],
artifacts: vec![],
result: None,
}),
}];
execute_dev_native_compile_plans(tmp.path(), &mut targets);
let json = serde_json::to_value(&targets[0]).unwrap();
assert_eq!(
json["native_compile"]["result"]["status"].as_str(),
Some("ok")
);
assert!(
json["native_compile"]["result"]["duration_ms"]
.as_u64()
.is_some()
);
}
#[test]
fn ios_simctl_install_args_use_reload_flag_when_requested() {
let app = Path::new("/tmp/Demo.app");
assert_eq!(
simctl_install_args(app, true),
vec![
"simctl",
"install",
"--bundle-id-kill-and-reload",
"booted",
"/tmp/Demo.app"
]
);
assert_eq!(
simctl_install_args(app, false),
vec!["simctl", "install", "booted", "/tmp/Demo.app"]
);
}
#[test]
fn dev_shell_build_emits_native_dev_helpers() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Shell\"\n\n[build]\nios = true\nandroid = true\n",
)
.unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(
src_dir.join("app.nativ"),
"app Shell:\n start: Home\n\nscreen Home:\n text \"Hello\"\n",
)
.unwrap();
let results =
run_nativ_build(tmp.path(), true, true, ChangeLevel::App, &[], false, true).unwrap();
let generated = results
.iter()
.flat_map(|result| result.generated_files.iter())
.map(|path| path.to_string_lossy())
.collect::<Vec<_>>();
assert!(
generated
.iter()
.any(|path| path.ends_with("NativDevMenuView.swift")),
"dev-shell build should emit Swift dev menu: {generated:?}"
);
assert!(
generated
.iter()
.any(|path| path.ends_with("NativDevMenu.kt")),
"dev-shell build should emit Compose dev menu: {generated:?}"
);
}
#[test]
fn dev_refresh_manifest_records_changed_screens_and_generated_files() {
let tmp = tempfile::tempdir().unwrap();
let ios_file = tmp.path().join("build").join("ios").join("Home.swift");
let android_file = tmp
.path()
.join("build")
.join("android")
.join("screens")
.join("HomeScreen.kt");
std::fs::create_dir_all(ios_file.parent().unwrap()).unwrap();
std::fs::create_dir_all(android_file.parent().unwrap()).unwrap();
std::fs::write(&ios_file, "struct HomeView {}").unwrap();
std::fs::write(&android_file, "fun HomeScreen() {}").unwrap();
let changed = vec!["Home".to_string()];
let results = vec![
nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![ios_file],
},
nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![android_file],
},
];
let path =
write_dev_refresh_manifest(tmp.path(), ChangeLevel::Patch, &changed, &results, 7)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(json["version"].as_u64(), Some(5));
assert_eq!(json["sequence"].as_u64(), Some(7));
assert_eq!(json["status"].as_str(), Some("ok"));
assert_eq!(json["change"].as_str(), Some("patch"));
assert_eq!(json["changed_screens"][0].as_str(), Some("Home"));
assert_eq!(json["generated_file_count"].as_u64(), Some(2));
assert_eq!(json["targets"][0]["target"].as_str(), Some("ios"));
assert_eq!(
json["targets"][0]["generated_files"][0].as_str(),
Some("build/ios/Home.swift")
);
assert_eq!(
json["targets"][0]["served_files"][0].as_str(),
Some("/files/build/ios/Home.swift")
);
assert_eq!(
json["targets"][0]["native_compile"]["program"].as_str(),
Some("xcrun")
);
assert_eq!(
json["targets"][0]["native_compile"]["cwd"].as_str(),
Some(".")
);
assert_eq!(
json["targets"][0]["native_compile"]["args"][2].as_str(),
Some("swiftc")
);
let ios_args = json["targets"][0]["native_compile"]["args"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(ios_args.contains(&"-emit-library"), "{ios_args:?}");
assert!(ios_args.contains(&"-module-name"), "{ios_args:?}");
assert!(ios_args.contains(&"NativDevPatch7"), "{ios_args:?}");
let ios_inputs = json["targets"][0]["native_compile"]["inputs"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(
ios_inputs.contains(&"build/ios/Home.swift"),
"{ios_inputs:?}"
);
assert!(
ios_inputs.contains(&"build/ios/.nativ-dev-patch/NativDevPatchFactory7.swift"),
"{ios_inputs:?}"
);
assert_eq!(
json["targets"][0]["native_compile"]["artifacts"][0].as_str(),
Some("build/ios/.nativ-dev-patch/libNativDevPatch7.dylib")
);
assert_eq!(json["targets"][1]["target"].as_str(), Some("android"));
assert_eq!(
json["targets"][1]["generated_files"][0].as_str(),
Some("build/android/screens/HomeScreen.kt")
);
assert_eq!(
json["targets"][1]["served_files"][0].as_str(),
Some("/files/build/android/screens/HomeScreen.kt")
);
assert_eq!(
json["targets"][1]["native_compile"]["program"].as_str(),
Some("gradle")
);
assert_eq!(
json["targets"][1]["native_compile"]["cwd"].as_str(),
Some("build/android")
);
assert_eq!(
json["targets"][1]["native_compile"]["args"][0].as_str(),
Some("assembleDebug")
);
let android_inputs = json["targets"][1]["native_compile"]["inputs"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(
android_inputs.contains(&"build/android/screens/HomeScreen.kt"),
"{android_inputs:?}"
);
assert!(
android_inputs.contains(
&"build/android/app/src/main/java/com/example/app/NativDevPatchFactory7.kt"
),
"{android_inputs:?}"
);
assert_eq!(
json["targets"][1]["native_compile"]["artifacts"][0].as_str(),
Some("build/android/app/build/outputs/apk/debug/app-debug.apk")
);
assert!(json["error"].is_null());
assert!(json["timestamp_ms"].as_u64().is_some());
}
#[test]
fn dev_refresh_target_serves_native_artifacts_only_when_compile_runs() {
let tmp = tempfile::tempdir().unwrap();
let ios_file = tmp.path().join("build").join("ios").join("Home.swift");
let android_file = tmp
.path()
.join("build")
.join("android")
.join("app")
.join("src")
.join("main")
.join("java")
.join("com")
.join("example")
.join("app")
.join("screens")
.join("HomeScreen.kt");
std::fs::create_dir_all(ios_file.parent().unwrap()).unwrap();
std::fs::create_dir_all(android_file.parent().unwrap()).unwrap();
std::fs::write(
&ios_file,
"import SwiftUI\nstruct HomeView: View { var body: some View { Text(\"Hi\") } }",
)
.unwrap();
std::fs::write(&android_file, "@Composable fun HomeScreen() {}").unwrap();
let ios_result = nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![ios_file],
};
let android_result = nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![android_file],
};
let without_compile =
dev_refresh_target(tmp.path(), ChangeLevel::Patch, &ios_result, 9, false);
let with_compile = dev_refresh_target(tmp.path(), ChangeLevel::Patch, &ios_result, 9, true);
let android_without_compile =
dev_refresh_target(tmp.path(), ChangeLevel::Patch, &android_result, 9, false);
let android_with_compile =
dev_refresh_target(tmp.path(), ChangeLevel::Patch, &android_result, 9, true);
assert_eq!(
without_compile.served_files,
vec!["/files/build/ios/Home.swift"]
);
assert!(
with_compile
.served_files
.contains(&"/files/build/ios/.nativ-dev-patch/libNativDevPatch9.dylib".to_string())
);
assert!(
!android_without_compile
.served_files
.iter()
.any(|path| path.ends_with(".apk"))
);
assert!(android_with_compile.served_files.contains(
&"/files/build/android/app/build/outputs/apk/debug/app-debug.apk".to_string()
));
}
#[test]
fn dev_shell_seeds_refresh_url_and_auto_poll() {
let tmp = tempfile::tempdir().unwrap();
let swift = tmp
.path()
.join("build")
.join("ios")
.join("NativDevMenuView.swift");
let kotlin = tmp
.path()
.join("build")
.join("android")
.join("app")
.join("src")
.join("main")
.join("java")
.join("com")
.join("example")
.join("app")
.join("NativDevMenu.kt");
std::fs::create_dir_all(swift.parent().unwrap()).unwrap();
std::fs::create_dir_all(kotlin.parent().unwrap()).unwrap();
std::fs::write(
&swift,
"@State private var refreshURL = \"\"\n@State private var refreshStatus = \"Enter the nativ dev refresh URL, then fetch.\"\n@State private var autoPollRefresh = false",
)
.unwrap();
std::fs::write(
&kotlin,
"var refreshUrl by remember { mutableStateOf(\"\") }\nvar refreshStatus by remember { mutableStateOf(\"Enter the nativ dev refresh URL, then fetch.\") }\nvar autoRefresh by remember { mutableStateOf(false) }",
)
.unwrap();
let results = vec![
nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![swift.clone()],
},
nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![kotlin.clone()],
},
];
seed_dev_shell_refresh_url(tmp.path(), &results, "http://127.0.0.1:123/__nativ_refresh")
.unwrap();
let swift = std::fs::read_to_string(swift).unwrap();
let kotlin = std::fs::read_to_string(kotlin).unwrap();
assert!(
swift.contains(
"@State private var refreshURL = \"http://127.0.0.1:123/__nativ_refresh\""
)
);
assert!(swift.contains("@State private var autoPollRefresh = true"));
assert!(kotlin.contains(
"var refreshUrl by remember { mutableStateOf(\"http://127.0.0.1:123/__nativ_refresh\") }"
));
assert!(kotlin.contains("var autoRefresh by remember { mutableStateOf(true) }"));
}
#[test]
fn dev_refresh_manifest_ios_compile_plan_includes_existing_swift_dependencies() {
let tmp = tempfile::tempdir().unwrap();
let ios_dir = tmp.path().join("build").join("ios");
std::fs::create_dir_all(&ios_dir).unwrap();
let home = ios_dir.join("Home.swift");
let model = ios_dir.join("Todo.swift");
std::fs::write(&home, "struct HomeView {}").unwrap();
std::fs::write(&model, "struct Todo {}").unwrap();
let results = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![home],
}];
let path = write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Patch,
&["Home".to_string()],
&results,
1,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let args = json["targets"][0]["native_compile"]["args"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(args.contains(&"build/ios/Home.swift"), "{args:?}");
assert!(args.contains(&"build/ios/Todo.swift"), "{args:?}");
}
#[test]
fn ios_patch_factory_uses_app_root_screen_view() {
let tmp = tempfile::tempdir().unwrap();
let ios_dir = tmp.path().join("build").join("ios");
std::fs::create_dir_all(&ios_dir).unwrap();
std::fs::write(
ios_dir.join("TodoAppApp.swift"),
"@main\nstruct TodoAppApp: App { var body: some Scene { WindowGroup {\nNativDevMenuView {\nHomeView()\n}\n} } }",
)
.unwrap();
std::fs::write(ios_dir.join("Home.swift"), "struct HomeView {}").unwrap();
ios_native_compile_plan(
tmp.path(),
&[
"build/ios/TodoAppApp.swift".to_string(),
"build/ios/Home.swift".to_string(),
],
3,
)
.unwrap();
let factory = std::fs::read_to_string(
ios_dir
.join(".nativ-dev-patch")
.join("NativDevPatchFactory3.swift"),
)
.unwrap();
assert!(factory.contains("HomeView()"), "{factory}");
assert!(!factory.contains("TodoAppAppView()"), "{factory}");
}
#[test]
fn dev_refresh_manifest_compile_plan_respects_custom_output_directory() {
let tmp = tempfile::tempdir().unwrap();
let ios_dir = tmp.path().join("dist").join("ios");
let android_dir = tmp.path().join("dist").join("android");
std::fs::create_dir_all(&ios_dir).unwrap();
std::fs::create_dir_all(android_dir.join("screens")).unwrap();
let ios_file = ios_dir.join("Home.swift");
let android_file = android_dir.join("screens").join("HomeScreen.kt");
std::fs::write(&ios_file, "struct HomeView {}").unwrap();
std::fs::write(&android_file, "fun HomeScreen() {}").unwrap();
let results = vec![
nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![ios_file],
},
nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![android_file],
},
];
let path = write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Patch,
&["Home".to_string()],
&results,
1,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let ios_inputs = json["targets"][0]["native_compile"]["inputs"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(
ios_inputs.contains(&"dist/ios/Home.swift"),
"{ios_inputs:?}"
);
assert!(
ios_inputs.contains(&"dist/ios/.nativ-dev-patch/NativDevPatchFactory1.swift"),
"{ios_inputs:?}"
);
assert_eq!(
json["targets"][0]["native_compile"]["artifacts"][0].as_str(),
Some("dist/ios/.nativ-dev-patch/libNativDevPatch1.dylib")
);
assert_eq!(
json["targets"][1]["native_compile"]["cwd"].as_str(),
Some("dist/android")
);
assert_eq!(
json["targets"][1]["native_compile"]["args"][1].as_str(),
Some("-Pnativ.changed=screens/HomeScreen.kt")
);
}
#[test]
fn dev_refresh_manifest_android_compile_plan_includes_existing_kotlin_dependencies() {
let tmp = tempfile::tempdir().unwrap();
let android_dir = tmp.path().join("build").join("android");
std::fs::create_dir_all(android_dir.join("screens")).unwrap();
std::fs::create_dir_all(android_dir.join("models")).unwrap();
let screen = android_dir.join("screens").join("HomeScreen.kt");
let model = android_dir.join("models").join("Todo.kt");
std::fs::write(&screen, "fun HomeScreen() {}").unwrap();
std::fs::write(&model, "data class Todo(val title: String)").unwrap();
let results = vec![nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![screen],
}];
let path = write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Patch,
&["Home".to_string()],
&results,
1,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let inputs = json["targets"][0]["native_compile"]["inputs"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(
inputs.contains(&"build/android/screens/HomeScreen.kt"),
"{inputs:?}"
);
assert!(
inputs.contains(&"build/android/models/Todo.kt"),
"{inputs:?}"
);
}
#[test]
fn dev_refresh_manifest_android_compile_plan_prefers_project_gradle_wrapper() {
let tmp = tempfile::tempdir().unwrap();
let android_dir = tmp.path().join("build").join("android");
std::fs::create_dir_all(android_dir.join("screens")).unwrap();
let wrapper_name = if cfg!(windows) {
"gradlew.bat"
} else {
"gradlew"
};
std::fs::write(android_dir.join(wrapper_name), "").unwrap();
let screen = android_dir.join("screens").join("HomeScreen.kt");
std::fs::write(&screen, "fun HomeScreen() {}").unwrap();
let results = vec![nativ_pipeline::BuildResult {
target: Target::Android,
generated_files: vec![screen],
}];
let path = write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Patch,
&["Home".to_string()],
&results,
1,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
json["targets"][0]["native_compile"]["program"].as_str(),
Some(if cfg!(windows) {
".\\gradlew.bat"
} else {
"./gradlew"
})
);
}
#[test]
fn dev_refresh_manifest_omits_native_compile_for_non_patch_changes() {
let tmp = tempfile::tempdir().unwrap();
let ios_file = tmp.path().join("build").join("ios").join("Home.swift");
let results = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![ios_file],
}];
let path = write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Screen,
&["Home".to_string()],
&results,
1,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert!(json["targets"][0]["native_compile"].is_null());
}
#[test]
fn dev_refresh_manifest_encodes_served_file_urls() {
let tmp = tempfile::tempdir().unwrap();
let generated = tmp
.path()
.join("build")
.join("ios")
.join("Debug App")
.join("Home 100%.swift");
let results = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![generated],
}];
let path =
write_dev_refresh_manifest(tmp.path(), ChangeLevel::Patch, &[], &results, 8).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
json["targets"][0]["generated_files"][0].as_str(),
Some("build/ios/Debug App/Home 100%.swift")
);
assert_eq!(
json["targets"][0]["served_files"][0].as_str(),
Some("/files/build/ios/Debug%20App/Home%20100%25.swift")
);
}
#[test]
fn dev_refresh_error_manifest_replaces_stale_success() {
let tmp = tempfile::tempdir().unwrap();
let changed = vec!["Home".to_string()];
let success = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![tmp.path().join("build").join("ios").join("Home.swift")],
}];
write_dev_refresh_manifest(tmp.path(), ChangeLevel::Patch, &changed, &success, 1).unwrap();
let path = write_dev_refresh_error_manifest(
tmp.path(),
ChangeLevel::Patch,
&changed,
"Parse error src/Home.nativ:2:3: expected expression",
2,
)
.unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(json["version"].as_u64(), Some(5));
assert_eq!(json["sequence"].as_u64(), Some(2));
assert_eq!(json["status"].as_str(), Some("error"));
assert_eq!(json["change"].as_str(), Some("patch"));
assert_eq!(json["changed_screens"][0].as_str(), Some("Home"));
assert_eq!(json["generated_file_count"].as_u64(), Some(0));
assert_eq!(json["targets"].as_array().unwrap().len(), 0);
assert!(json["error"].as_str().unwrap().contains("Parse error"));
assert!(json["timestamp_ms"].as_u64().is_some());
}
#[test]
fn dev_shell_file_path_stays_inside_build_tree() {
let project = Path::new("/tmp/project");
assert_eq!(
dev_shell_file_path(project, "/files/build/ios/Home.swift"),
Some(project.join("build").join("ios").join("Home.swift"))
);
assert_eq!(
dev_shell_file_path(project, "/files/build/ios/Home%20100%25.swift"),
Some(project.join("build").join("ios").join("Home 100%.swift"))
);
assert!(dev_shell_file_path(project, "/files/src/Home.nativ").is_none());
assert!(dev_shell_file_path(project, "/files/build/../nativ.toml").is_none());
assert!(dev_shell_file_path(project, "/files/build/%zz.swift").is_none());
assert!(dev_shell_file_path(project, "/__nativ_refresh").is_none());
}
#[test]
fn dev_shell_file_path_allows_configured_output_directory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Demo\"\n\n[output]\ndirectory = \"dist\"\n",
)
.unwrap();
assert_eq!(
dev_shell_file_path(tmp.path(), "/files/dist/ios/Home.swift"),
Some(tmp.path().join("dist").join("ios").join("Home.swift"))
);
assert!(dev_shell_file_path(tmp.path(), "/files/src/Home.nativ").is_none());
}
#[test]
fn local_ip_hint_returns_displayable_value() {
assert!(!local_ip_hint().is_empty());
}
#[test]
fn dev_shell_server_serves_manifest_and_generated_files() {
let tmp = tempfile::tempdir().unwrap();
let changed = vec!["Home".to_string()];
let generated = tmp.path().join("build").join("ios").join("Home.swift");
std::fs::create_dir_all(generated.parent().unwrap()).unwrap();
std::fs::write(&generated, "struct Home: View {}").unwrap();
let results = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![generated],
}];
write_dev_refresh_manifest(tmp.path(), ChangeLevel::Patch, &changed, &results, 11).unwrap();
let addr = start_dev_shell_server(tmp.path(), 0).unwrap();
let manifest = http_get(addr, "/__nativ_refresh");
assert!(manifest.starts_with("HTTP/1.1 200 OK"), "{manifest}");
assert!(
manifest.contains("Access-Control-Allow-Origin: *"),
"{manifest}"
);
assert!(manifest.contains(r#""sequence": 11"#), "{manifest}");
assert!(manifest.contains(r#""status": "ok""#), "{manifest}");
let options = http_request(addr, "OPTIONS", "/__nativ_refresh");
assert!(options.starts_with("HTTP/1.1 204 No Content"), "{options}");
assert!(
options.contains("Access-Control-Allow-Methods: GET, OPTIONS"),
"{options}"
);
let post = http_request(addr, "POST", "/__nativ_refresh");
assert!(
post.starts_with("HTTP/1.1 405 Method Not Allowed"),
"{post}"
);
let file = http_get(addr, "/files/build/ios/Home.swift");
assert!(file.starts_with("HTTP/1.1 200 OK"), "{file}");
assert!(file.contains("struct Home"), "{file}");
let spaced = tmp.path().join("build").join("ios").join("Home 100%.swift");
std::fs::write(&spaced, "struct Home100: View {}").unwrap();
let spaced_file = http_get(addr, "/files/build/ios/Home%20100%25.swift");
assert!(spaced_file.starts_with("HTTP/1.1 200 OK"), "{spaced_file}");
assert!(spaced_file.contains("struct Home100"), "{spaced_file}");
let binary = tmp.path().join("build").join("ios").join("bundle.bin");
std::fs::write(&binary, [0_u8, 159, 255, 10]).unwrap();
let binary_response = http_get_bytes(addr, "/files/build/ios/bundle.bin");
assert!(
binary_response.starts_with(b"HTTP/1.1 200 OK"),
"{}",
String::from_utf8_lossy(&binary_response)
);
let body_start = binary_response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.unwrap();
let headers = String::from_utf8_lossy(&binary_response[..body_start]);
assert!(headers.contains("Content-Length: 4"), "{headers}");
assert_eq!(&binary_response[body_start..], &[0_u8, 159, 255, 10]);
let traversal = http_get(addr, "/files/build/../nativ.toml");
assert!(traversal.starts_with("HTTP/1.1 404"), "{traversal}");
let invalid_escape = http_get(addr, "/files/build/ios/%zz.swift");
assert!(
invalid_escape.starts_with("HTTP/1.1 404"),
"{invalid_escape}"
);
}
#[test]
fn dev_shell_server_serves_configured_output_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Demo\"\n\n[output]\ndirectory = \"dist\"\n",
)
.unwrap();
let generated = tmp.path().join("dist").join("ios").join("Home.swift");
std::fs::create_dir_all(generated.parent().unwrap()).unwrap();
std::fs::write(&generated, "struct Home: View {}").unwrap();
let results = vec![nativ_pipeline::BuildResult {
target: Target::Ios,
generated_files: vec![generated],
}];
write_dev_refresh_manifest(
tmp.path(),
ChangeLevel::Patch,
&["Home".to_string()],
&results,
12,
)
.unwrap();
let addr = start_dev_shell_server(tmp.path(), 0).unwrap();
let manifest = http_get(addr, "/__nativ_refresh");
assert!(
manifest.contains("/files/dist/ios/Home.swift"),
"{manifest}"
);
let file = http_get(addr, "/files/dist/ios/Home.swift");
assert!(file.starts_with("HTTP/1.1 200 OK"), "{file}");
assert!(file.contains("struct Home"), "{file}");
}
fn http_get(addr: SocketAddr, path: &str) -> String {
http_request(addr, "GET", path)
}
fn http_get_bytes(addr: SocketAddr, path: &str) -> Vec<u8> {
http_request_bytes(addr, "GET", path)
}
fn http_request(addr: SocketAddr, method: &str, path: &str) -> String {
String::from_utf8_lossy(&http_request_bytes(addr, method, path)).to_string()
}
fn http_request_bytes(addr: SocketAddr, method: &str, path: &str) -> Vec<u8> {
let addr = SocketAddr::from(([127, 0, 0, 1], addr.port()));
for _ in 0..50 {
let Ok(mut stream) = TcpStream::connect(addr) else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
if write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: localhost\r\n\r\n"
)
.is_err()
{
std::thread::sleep(Duration::from_millis(10));
continue;
}
if stream.flush().is_err() {
std::thread::sleep(Duration::from_millis(10));
continue;
}
let mut response = Vec::new();
if stream.read_to_end(&mut response).is_ok() && !response.is_empty() {
return response;
}
std::thread::sleep(Duration::from_millis(10));
}
Vec::new()
}
#[test]
fn no_change_returns_patch_no_semantic_change() {
let source = "screen Home:\n text \"Hello\"\n";
let ast = parse(source).unwrap();
let (level, detail) = classify_change(&ast, &ast);
assert_eq!(level, ChangeLevel::Patch);
assert_eq!(detail, "no semantic change");
}
#[test]
fn text_value_change_is_patch() {
let old = parse("screen Home:\n text \"Hello\"\n").unwrap();
let new = parse("screen Home:\n text \"World\"\n").unwrap();
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::Patch);
assert!(
detail.contains("Home"),
"detail should name the screen: {detail}"
);
}
#[test]
fn modifier_value_change_is_patch() {
let old = parse("screen Home:\n text \"Hi\"\n font: 28\n").unwrap();
let new = parse("screen Home:\n text \"Hi\"\n font: 32\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(
level,
ChangeLevel::Patch,
"changing a modifier value is a Patch"
);
}
#[test]
fn element_kind_change_is_screen() {
let old = parse("screen Home:\n text \"Hi\"\n").unwrap();
let new = parse("screen Home:\n button \"Hi\"\n").unwrap();
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::Screen);
assert!(
detail.contains("Home"),
"detail should name the screen: {detail}"
);
}
#[test]
fn new_element_in_body_is_screen() {
let old = parse("screen Home:\n text \"Hi\"\n").unwrap();
let new = parse("screen Home:\n text \"Hi\"\n text \"There\"\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(
level,
ChangeLevel::Screen,
"adding an element is a Screen change"
);
}
#[test]
fn removed_element_from_body_is_screen() {
let old = parse("screen Home:\n text \"A\"\n text \"B\"\n").unwrap();
let new = parse("screen Home:\n text \"A\"\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(
level,
ChangeLevel::Screen,
"removing an element is a Screen change"
);
}
#[test]
fn new_screen_declaration_is_app() {
let old = parse("screen Home:\n text \"hi\"\n").unwrap();
let new = parse("screen Home:\n text \"hi\"\nscreen About:\n text \"about\"\n").unwrap();
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App);
assert!(
detail.contains("+About"),
"detail should mention the new screen: {detail}"
);
}
#[test]
fn removed_screen_declaration_is_app() {
let old = parse("screen Home:\n text \"hi\"\nscreen About:\n text \"about\"\n").unwrap();
let new = parse("screen Home:\n text \"hi\"\n").unwrap();
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App);
assert!(
detail.contains("-About"),
"detail should mention removed screen: {detail}"
);
}
#[test]
fn model_change_is_app() {
let old = parse("model Todo:\n title: text\n").unwrap();
let new = parse("model Todo:\n title: text\n done: boolean\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App, "model field change is App");
}
#[test]
fn new_model_is_app() {
let old = parse("model Todo:\n title: text\n").unwrap();
let new = parse("model Todo:\n title: text\nmodel User:\n name: text\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App, "new model is App");
}
#[test]
fn app_declaration_change_is_app() {
let old = parse("app MyApp:\n navigation:\n tabs:\n Home\n").unwrap();
let new =
parse("app MyApp:\n navigation:\n tabs:\n Home\n Profile\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App, "nav tab change is App");
}
#[test]
fn component_structural_change_is_screen() {
let old = parse("component TodoRow(todo):\n text \"default\"\n").unwrap();
let new =
parse("component TodoRow(todo):\n text todo.title\n toggle todo.done\n").unwrap();
let (level, _detail) = classify_change(&old, &new);
assert_eq!(
level,
ChangeLevel::Screen,
"component body change is Screen"
);
}
#[test]
fn new_component_is_app() {
let span = Span { line: 1, col: 1 };
let old = NativFile {
app: None,
models: vec![],
screens: vec![],
components: vec![ComponentDecl {
name: "TodoRow".to_string(),
params: vec![Param {
name: "todo".to_string(),
type_annotation: None,
default_value: None,
span: span.clone(),
}],
body: vec![Statement::UiElement(UiElement {
kind: UiElementKind::Text,
args: vec![Expr::StringLit("default".to_string())],
modifiers: vec![],
body: vec![],
span: span.clone(),
})],
span: span.clone(),
}],
apis: vec![],
ml_models: vec![],
native_modules: vec![],
};
let new = NativFile {
app: None,
models: vec![],
screens: vec![],
components: vec![
ComponentDecl {
name: "TodoRow".to_string(),
params: vec![Param {
name: "todo".to_string(),
type_annotation: None,
default_value: None,
span: span.clone(),
}],
body: vec![Statement::UiElement(UiElement {
kind: UiElementKind::Text,
args: vec![Expr::StringLit("default".to_string())],
modifiers: vec![],
body: vec![],
span: span.clone(),
})],
span: span.clone(),
},
ComponentDecl {
name: "Header".to_string(),
params: vec![],
body: vec![Statement::UiElement(UiElement {
kind: UiElementKind::Text,
args: vec![Expr::StringLit("header".to_string())],
modifiers: vec![],
body: vec![],
span: span.clone(),
})],
span: span.clone(),
},
],
apis: vec![],
ml_models: vec![],
native_modules: vec![],
};
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::App);
assert!(
detail.contains("+Header"),
"detail should mention new component: {detail}"
);
}
#[test]
fn batch_with_multiple_files_upgrades_to_app() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("src").join("screens");
std::fs::create_dir_all(&src_dir).unwrap();
let path1 = src_dir.join("Home.nativ");
let path2 = src_dir.join("About.nativ");
std::fs::write(&path1, "screen Home:\n text \"Hello\"\n").unwrap();
std::fs::write(&path2, "screen About:\n text \"About\"\n").unwrap();
let mut cache: HashMap<PathBuf, NativFile> = HashMap::new();
cache.insert(
path1.clone(),
parse("screen Home:\n text \"Hello\"\n").unwrap(),
);
cache.insert(
path2.clone(),
parse("screen About:\n text \"About\"\n").unwrap(),
);
std::fs::write(&path1, "screen Home:\n text \"Updated\"\n").unwrap();
let paths = vec![&path1, &path2];
let results = classify_batch(&paths, &mut cache, false);
assert_eq!(results.len(), 2);
for r in &results {
assert_eq!(
r.level,
ChangeLevel::App,
"{} should be App-level because multiple files changed",
r.path.display()
);
assert!(
r.detail.contains("multiple files changed"),
"detail should mention multi-file: {}",
r.detail
);
}
}
#[test]
fn duplicate_watcher_events_for_same_file_stay_patch() {
let cwd = std::env::current_dir().unwrap();
let tmp = tempfile::tempdir_in(&cwd).unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let relative_project = tmp.path().strip_prefix(&cwd).unwrap();
let path = src_dir.join("app.nativ");
std::fs::write(&path, "screen Home:\n text \"Hello\"\n").unwrap();
let mut cache: HashMap<PathBuf, NativFile> = HashMap::new();
let initial_path = relative_project.join("src").join("app.nativ");
cache.insert(
dev_cache_path(&initial_path),
parse("screen Home:\n text \"Hello\"\n").unwrap(),
);
std::fs::write(&path, "screen Home:\n text \"World\"\n").unwrap();
let paths = vec![&path, &path];
let results = classify_batch(&paths, &mut cache, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].level, ChangeLevel::Patch);
}
#[test]
fn env_file_change_is_app() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".nativ.env");
std::fs::write(&path, "API_URL=https://api.example.com\n").unwrap();
let paths = vec![&path];
let mut cache: HashMap<PathBuf, NativFile> = HashMap::new();
let results = classify_batch(&paths, &mut cache, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].level, ChangeLevel::App);
assert_eq!(results[0].detail, "env changed");
}
#[test]
fn single_patch_in_batch_stays_patch() {
let mut cache: HashMap<PathBuf, NativFile> = HashMap::new();
let path = PathBuf::from("src/screens/Home.nativ");
cache.insert(
path.clone(),
parse("screen Home:\n text \"Hello\"\n").unwrap(),
);
let _paths = [&path];
let result = super::classify_change(
&parse("screen Home:\n text \"Hello\"\n").unwrap(),
&parse("screen Home:\n text \"World\"\n").unwrap(),
);
assert_eq!(result.0, ChangeLevel::Patch);
}
#[test]
fn text_patch_before_parameterized_screen_does_not_change_params() {
let old =
parse("screen Home:\n text \"Hello\"\n\nscreen Detail(todo):\n text todo.title\n")
.unwrap();
let new =
parse("screen Home:\n text \"Hello!\"\n\nscreen Detail(todo):\n text todo.title\n")
.unwrap();
let (level, detail) = classify_change(&old, &new);
assert_eq!(level, ChangeLevel::Patch, "{detail}");
}
#[test]
fn same_signature_for_same_structure() {
let a = parse("screen A:\n text \"Hello\"\n button \"Go\"\n").unwrap();
let b = parse("screen A:\n text \"World\"\n button \"Stop\"\n").unwrap();
assert_eq!(
body_signature(&a.screens[0].body),
body_signature(&b.screens[0].body),
"text+button structure is same regardless of values"
);
}
#[test]
fn different_signature_for_different_kinds() {
let a = parse("screen A:\n text \"Hello\"\n").unwrap();
let b = parse("screen A:\n button \"Go\"\n").unwrap();
assert_ne!(
body_signature(&a.screens[0].body),
body_signature(&b.screens[0].body),
"text vs button should have different signatures"
);
}
#[test]
fn different_signature_for_different_count() {
let a = parse("screen A:\n text \"Hello\"\n").unwrap();
let b = parse("screen A:\n text \"Hello\"\n text \"World\"\n").unwrap();
assert_ne!(
body_signature(&a.screens[0].body),
body_signature(&b.screens[0].body),
"different element counts should have different signatures"
);
}
#[test]
fn element_diff_count_detects_additions() {
let old = parse("screen A:\n text \"A\"\n").unwrap();
let new = parse("screen A:\n text \"A\"\n text \"B\"\n").unwrap();
assert_eq!(
element_diff_count(&old.screens[0].body, &new.screens[0].body),
1
);
}
#[test]
fn element_diff_count_detects_removals() {
let old = parse("screen A:\n text \"A\"\n text \"B\"\n").unwrap();
let new = parse("screen A:\n text \"A\"\n").unwrap();
assert_eq!(
element_diff_count(&old.screens[0].body, &new.screens[0].body),
1
);
}
#[test]
fn element_diff_count_zero_for_value_changes() {
let old = parse("screen A:\n text \"A\"\n").unwrap();
let new = parse("screen A:\n text \"B\"\n").unwrap();
assert_eq!(
element_diff_count(&old.screens[0].body, &new.screens[0].body),
0
);
}
#[test]
fn param_summary_empty() {
assert_eq!(param_summary(&[]), "none");
}
#[test]
fn param_summary_with_types() {
let params = vec![
Param {
name: "title".to_string(),
type_annotation: Some(TypeAnnotation::Text),
default_value: None,
span: Span { line: 1, col: 1 },
},
Param {
name: "count".to_string(),
type_annotation: Some(TypeAnnotation::Number),
default_value: None,
span: Span { line: 1, col: 1 },
},
];
let summary = param_summary(¶ms);
assert!(summary.contains("title"));
assert!(summary.contains("count"));
}
#[test]
fn name_diff_shows_additions_and_removals() {
let old_names = ["Home", "About"];
let new_names = ["Home", "Profile"];
let diff = name_diff_from_vecs(&old_names, &new_names);
assert!(diff.contains("-About"));
assert!(diff.contains("+Profile"));
}
#[test]
fn name_diff_empty_when_unchanged() {
let old_names = ["Home", "About"];
let new_names = ["Home", "About"];
let diff = name_diff_from_vecs(&old_names, &new_names);
assert_eq!(diff, "names unchanged");
}
#[test]
fn screen_decl_implements_named() {
let screen = ScreenDecl {
name: "TestScreen".to_string(),
params: vec![],
body: vec![],
span: Span { line: 1, col: 1 },
};
assert_eq!(screen.name(), "TestScreen");
}
#[test]
fn component_decl_implements_named() {
let component = ComponentDecl {
name: "TestComponent".to_string(),
params: vec![],
body: vec![],
span: Span { line: 1, col: 1 },
};
assert_eq!(component.name(), "TestComponent");
}
#[test]
fn full_patch_classification_cycle() {
let source = "component Row(item):\n text item.title\n font: 16\n";
let ast1 = parse(source).unwrap();
let modified = source.replace("16", "18");
let ast2 = parse(&modified).unwrap();
let (level, detail) = classify_change(&ast1, &ast2);
assert_eq!(level, ChangeLevel::Patch);
assert!(detail.contains("Row"), "{detail}");
}
#[test]
fn full_screen_classification_cycle() {
let source = "screen List:\n column:\n text \"Item 1\"\n";
let ast1 = parse(source).unwrap();
let modified = source.replace(
" text \"Item 1\"",
" text \"Item 1\"\n text \"Item 2\"",
);
let ast2 = parse(&modified).unwrap();
let (level, _detail) = classify_change(&ast1, &ast2);
assert_eq!(level, ChangeLevel::Screen);
}
#[test]
fn full_app_classification_cycle() {
let source = "screen Home:\n text \"Hi\"\n";
let ast1 = parse(source).unwrap();
let modified = format!("{source}\nscreen About:\n text \"About\"\n");
let ast2 = parse(&modified).unwrap();
let (level, _detail) = classify_change(&ast1, &ast2);
assert_eq!(level, ChangeLevel::App);
}
}