use clap::Args;
use nativ_config::NativConfig;
use nativ_pipeline::{self, BuildOptions, Target};
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::time::Instant;
#[derive(Args)]
pub struct BuildArgs {
#[arg(long)]
pub ios: bool,
#[arg(long)]
pub android: bool,
#[arg(long)]
pub web: bool,
#[arg(long)]
pub dev: bool,
#[arg(short, long, default_value = ".")]
pub dir: String,
}
pub fn run(args: BuildArgs, verbose: bool, quiet: bool) -> Result<(), Box<dyn std::error::Error>> {
let project_dir = Path::new(&args.dir);
let config_path = project_dir.join("nativ.toml");
let config = NativConfig::load(&config_path)?;
let selection = if should_prompt_build_targets(&args, quiet) {
prompt_build_targets(&config)?
} else {
build_selection_from_args(&args, &config)
};
let targets = selection.targets;
let web = selection.web;
if targets.is_empty() && !web {
return Err(
"No target platform specified. Enable ios, android, or web in nativ.toml".into(),
);
}
if !quiet {
let mut target_names: Vec<&str> = targets
.iter()
.map(|t| match t {
Target::Ios => "iOS",
Target::Android => "Android",
})
.collect();
if web {
target_names.push("Web");
}
println!(
"Compiling: {} -> {}",
config.app.name,
target_names.join(", ")
);
}
let start = Instant::now();
let results = nativ_pipeline::build_with_options(
project_dir,
&config,
&targets,
BuildOptions { dev: args.dev },
)?;
let mut web_files: Vec<std::path::PathBuf> = Vec::new();
if web {
let output_dir = project_dir.join(&config.output.directory).join("web");
std::fs::create_dir_all(&output_dir)?;
let (html, _screens) = crate::commands::preview::render_web_project(project_dir)?;
let index = output_dir.join("index.html");
std::fs::write(&index, html)?;
web_files.push(index);
}
let elapsed = start.elapsed();
if !quiet {
for result in &results {
let platform = match result.target {
Target::Ios => "iOS",
Target::Android => "Android",
};
println!(
" {platform}: {} files generated",
result.generated_files.len()
);
if verbose {
for file in &result.generated_files {
println!(" -> {}", file.display());
}
}
}
if !web_files.is_empty() {
println!(" Web: {} files generated", web_files.len());
if verbose {
for file in &web_files {
println!(" -> {}", file.display());
}
}
}
let total: usize = results
.iter()
.map(|r| r.generated_files.len())
.sum::<usize>()
+ web_files.len();
println!(
"Build complete: {total} files, {:.2}s",
elapsed.as_secs_f32()
);
}
Ok(())
}
struct BuildSelection {
targets: Vec<Target>,
web: bool,
}
fn build_selection_from_args(args: &BuildArgs, config: &NativConfig) -> BuildSelection {
BuildSelection {
targets: nativ_pipeline::resolve_targets(args.ios, args.android, config),
web: args.web || config.build.web,
}
}
fn should_prompt_build_targets(args: &BuildArgs, quiet: bool) -> bool {
!quiet
&& !args.ios
&& !args.android
&& !args.web
&& io::stdin().is_terminal()
&& io::stdout().is_terminal()
}
fn prompt_build_targets(
config: &NativConfig,
) -> Result<BuildSelection, Box<dyn std::error::Error>> {
println!("Build target:");
println!(" 1) iOS");
println!(" 2) Android");
println!(" 3) iOS + Android");
println!(" 4) Web");
println!(" 5) All");
print!("Select [Enter = nativ.toml defaults]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
parse_build_menu_choice(input.trim(), config)
.ok_or_else(|| "Invalid build target selection".into())
}
fn parse_build_menu_choice(choice: &str, config: &NativConfig) -> Option<BuildSelection> {
let targets = |ios, android| nativ_pipeline::resolve_targets(ios, android, config);
Some(match choice {
"" | "0" => BuildSelection {
targets: nativ_pipeline::resolve_targets(false, false, config),
web: config.build.web,
},
"1" => BuildSelection {
targets: targets(true, false),
web: false,
},
"2" => BuildSelection {
targets: targets(false, true),
web: false,
},
"3" => BuildSelection {
targets: targets(true, true),
web: false,
},
"4" => BuildSelection {
targets: Vec::new(),
web: true,
},
"5" => BuildSelection {
targets: targets(true, true),
web: true,
},
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn args_for(dir: &Path, ios: bool, android: bool) -> BuildArgs {
BuildArgs {
ios,
android,
web: false,
dev: false,
dir: dir.display().to_string(),
}
}
fn args_for_web(dir: &Path) -> BuildArgs {
BuildArgs {
ios: false,
android: false,
web: true,
dev: false,
dir: dir.display().to_string(),
}
}
#[test]
fn build_menu_default_uses_config_targets() {
let config = NativConfig::parse(
"[app]\nname = \"Demo\"\n\n[build]\nios = false\nandroid = true\nweb = true\n",
)
.unwrap();
let selection = parse_build_menu_choice("", &config).unwrap();
assert_eq!(selection.targets, vec![Target::Android]);
assert!(selection.web);
}
#[test]
fn build_menu_can_select_web_only() {
let config = NativConfig::parse("[app]\nname = \"Demo\"\n").unwrap();
let selection = parse_build_menu_choice("4", &config).unwrap();
assert!(selection.targets.is_empty());
assert!(selection.web);
}
#[test]
fn build_menu_rejects_unknown_choice() {
let config = NativConfig::parse("[app]\nname = \"Demo\"\n").unwrap();
assert!(parse_build_menu_choice("x", &config).is_none());
}
#[test]
fn fails_when_config_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let err = run(args_for(tmp.path(), true, false), false, true).unwrap_err();
assert!(err.to_string().contains("nativ.toml"), "{err}");
}
#[test]
fn fails_when_no_target_platform_enabled() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"t\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
let err = run(args_for(tmp.path(), false, false), false, true).unwrap_err();
assert!(err.to_string().contains("No target platform"), "{err}");
}
#[test]
fn fails_when_src_dir_is_missing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"t\"\n").unwrap();
let err = run(args_for(tmp.path(), true, false), false, true).unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn web_build_emits_navigable_app() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Web\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Web\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n text \"Hello web\"\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let index = tmp.path().join("build").join("web").join("index.html");
assert!(index.is_file(), "build/web/index.html not written");
let html = std::fs::read_to_string(&index).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"), "not an HTML document");
assert_eq!(
html.matches("class=\"phone\"").count(),
1,
"single phone frame"
);
assert!(
html.contains("data-screen=\"Home\""),
"screen section missing"
);
assert!(
html.contains("class=\"screen active\" data-screen=\"Home\""),
"start screen must be active:\n{html}"
);
assert!(html.contains("<script>"), "router script missing");
assert!(html.contains("screen-bar"), "screen bar header missing");
}
#[test]
fn web_build_wires_go_to_buttons_to_the_router() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Nav\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Nav\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n button \"Open detail\":\n go to Detail\n",
)
.unwrap();
std::fs::write(
src.join("detail.nativ"),
"screen Detail:\n text \"Detail page\"\n button \"Back\":\n go back\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-goto=\"Detail\""),
"go to button must be wired:\n{html}"
);
assert!(
html.contains("data-back="),
"go back button must be wired:\n{html}"
);
assert!(
html.contains("data-screen=\"Detail\""),
"Detail section missing"
);
assert!(
!html.contains("class=\"screen active\" data-screen=\"Detail\""),
"non-start screen must not be active:\n{html}"
);
}
#[test]
fn web_build_emits_reactive_state_and_bindings() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Count\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Count\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state count = 0\n text \"Taps: {count}\"\n button \"Add\":\n count += 1\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("state.count = 0;"),
"state init missing:\n{html}"
);
assert!(
html.contains("data-bind=") && html.contains("state.count"),
"data-bind on interpolation missing:\n{html}"
);
assert!(
html.contains("data-on-tap=") && html.contains("state.count += 1;"),
"assign on-tap missing:\n{html}"
);
assert!(html.contains("function render()"), "render fn missing");
assert!(html.contains("new Function"), "eval-based runtime missing");
}
#[test]
fn web_build_gates_conditional_branches() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Cond\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Cond\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state count = 0\n if count > 5:\n text \"a lot\"\n else:\n text \"few\"\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-bind-if=\"((state.count > 5))\""),
"then-branch gate missing:\n{html}"
);
assert!(
html.contains("data-bind-if=\"!((state.count > 5))\"") && html.contains("display:none"),
"else-branch gate + hidden missing:\n{html}"
);
assert!(
html.contains("[data-bind-if]"),
"runtime must toggle conditional branches:\n{html}"
);
}
#[test]
fn web_build_wires_textfield_and_toggle_bindings() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Form\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Form\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state email = \"\"\n state done = false\n textfield \"Email\", bind: email\n toggle \"Done\", bind: done\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-bind-input=\"state.email\"") && !html.contains("disabled>"),
"textfield binding missing:\n{html}"
);
assert!(
html.contains("data-on-tap=\"state.done = !(state.done);\""),
"toggle flip missing:\n{html}"
);
assert!(
html.contains("data-bind-toggle=\"state.done\"")
&& html.contains("classList.toggle('is-on'")
&& html.contains(".toggle.is-on .switch"),
"toggle visual state missing:\n{html}"
);
assert!(html.contains("'input'"), "input listener missing");
}
#[test]
fn web_build_renders_each_loop_from_state() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"List\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"List\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"model Item:\n title: text\n\nscreen Home:\n state items: list of Item = [Item(title: \"A\"), Item(title: \"B\")]\n each item in items:\n text item.title\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-each=\"state.items\""),
"each container missing:\n{html}"
);
assert!(
html.contains("data-each-tpl"),
"each template missing:\n{html}"
);
assert!(
html.contains("data-bind=\"__it.title\""),
"loop-var binding must use __it:\n{html}"
);
assert!(html.contains("renderEach"), "each renderer missing");
}
#[test]
fn web_build_wires_list_mutations() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Tags\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Tags\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state tags = []\n button \"Add\":\n tags.add(\"new\")\n button \"Clear\":\n tags.clear()\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("state.tags.push(") && html.contains("data-on-tap="),
"add -> push missing:\n{html}"
);
assert!(
html.contains("state.tags.length = 0;"),
"clear -> length=0 missing:\n{html}"
);
}
#[test]
fn web_build_wires_model_constructor_list_adds() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Todo\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Todo\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"model Item:\n title: text\n done: boolean = false\n\nscreen Home:\n state newTitle = \"A\"\n state items: list of Item = []\n button \"Add\":\n items.add(Item(newTitle))\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("state.items.push({title: state.newTitle, done: false});"),
"constructor add must emit object literal:\n{html}"
);
assert!(
!html.contains("state.items.push(null);"),
"constructor add must not degrade to null:\n{html}"
);
}
#[test]
fn web_build_wires_form_submit_validation() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Signup\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Signup\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state email = \"\"\n state submitted = false\n form:\n input \"Email\", bind: email, required, email\n button \"Submit\":\n submitted = true\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-form-field=\"email\"")
&& html.contains("data-bind-input=\"state.email\"")
&& html.contains("data-required=\"1\"")
&& html.contains("data-email=\"1\"")
&& html.contains("data-error-for=\"email\""),
"form field validation attrs missing:\n{html}"
);
assert!(
html.contains("data-submit-form=\"1\"")
&& html.contains("data-submit-tap=\"state.submitted = true;\"")
&& html.contains("validateForm(form)")
&& html.contains("Invalid email"),
"form submit runtime missing:\n{html}"
);
}
#[test]
fn web_build_posts_form_submit_endpoint() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"Signup\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::write(
src.join("app.nativ"),
"app App:\n name: \"Signup\"\n start: Home\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n state email = \"\"\n form:\n input \"Email\", bind: email, required, email\n button \"Submit\":\n submit to \"/register\"\n",
)
.unwrap();
run(args_for_web(tmp.path()), false, true).expect("web build succeeds");
let html = std::fs::read_to_string(tmp.path().join("build").join("web").join("index.html"))
.unwrap();
assert!(
html.contains("data-submit-endpoint=\"/register\"")
&& html.contains("submitForm(form, endpoint)")
&& html.contains("fetch(endpoint, { method: 'POST'")
&& html.contains("JSON.stringify(formPayload(form))"),
"form endpoint submit missing:\n{html}"
);
}
}