use clap::Args;
use nativ_compiler::ast::*;
use nativ_config::NativConfig;
use nativ_pipeline::Target;
use notify::{RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
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,
}
pub fn run(args: DevArgs, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
let project_dir = Path::new(&args.dir);
let config = NativConfig::load(&project_dir.join("nativ.toml"))?;
let initial_targets = nativ_pipeline::resolve_targets(args.ios, args.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 args.ios || args.android {
let mut parts = Vec::new();
if args.ios {
parts.push("iOS simulator (xcodebuild)");
}
if args.android {
parts.push("Android emulator (gradle)");
}
parts.join(", ")
} else {
"disabled (use --ios / --android)".to_string()
}
);
let mut last_native_build: HashMap<&str, Instant> = HashMap::new();
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,
args.ios,
args.android,
overall_level,
&changed_screens,
&mut last_native_build,
verbose,
);
}
Ok(())
}
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,
) {
let start = Instant::now();
match run_nativ_build(
project_dir,
ios_flag,
android_flag,
level,
changed_screens,
verbose,
) {
Ok(file_count) => {
let elapsed = start.elapsed();
println!(
" Build OK: {} files generated ({:.2}s)",
file_count,
elapsed.as_secs_f64()
);
let patch = format!(
r#"{{"version":1,"change":"{}","files":{},"ts":"{:?}"}}"#,
level.as_str(),
file_count,
std::time::SystemTime::now(),
);
let output_dir = project_dir.join("build");
let _ = std::fs::create_dir_all(&output_dir);
let _ = std::fs::write(output_dir.join(".nativ-dev-refresh.json"), &patch);
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);
} 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);
} else {
println!(" [Android] native build skipped (cooldown)");
}
}
}
Err(e) => {
eprintln!(" Build failed:\n{}", indent_error(&e.to_string()));
}
}
}
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,
) -> Result<usize, 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(
project_dir,
&config,
&targets,
&level.to_string(),
changed_screens,
)?;
let total: usize = results.iter().map(|r| r.generated_files.len()).sum();
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(total)
}
fn run_xcodebuild(project_dir: &Path) {
let ios_project = project_dir.join("build").join("ios").join("App.xcodeproj");
if !ios_project.exists() {
println!(
" [iOS] Xcode project not found at {}; skipping",
ios_project.display()
);
return;
}
println!(" [iOS] Building for simulator...");
let result = std::process::Command::new("xcrun")
.args([
"xcodebuild",
"build",
"-project",
&ios_project.to_string_lossy(),
"-scheme",
"App",
"-destination",
"platform=iOS Simulator,name=iPhone 16",
"CODE_SIGNING_ALLOWED=NO",
"-quiet",
])
.output();
match result {
Ok(output) => {
if output.status.success() {
println!(" [iOS] Simulator build succeeded");
} 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 run_gradle_assemble(project_dir: &Path) {
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");
} 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(path.to_path_buf(), ast);
Ok(true)
}
Err(e) => {
eprintln!(
" warning: parse error in {}: {}",
path.display(),
e.concise_message()
);
Ok(false)
}
}
}
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();
for &path in 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;
}
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 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 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,
FreeCall,
MlModelDecl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum UiElementKindDiscriminant {
Text,
Button,
Image,
Toggle,
TextField,
Spinner,
Divider,
Spacer,
Input,
}
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,
}
}
}
#[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::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 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 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![],
};
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![],
};
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 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 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);
}
}