use nativ_config::NativConfig;
use std::path::Path;
use std::process::{Command, Output};
fn nativ(cwd: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_nativ"))
.args(args)
.current_dir(cwd)
.output()
.expect("failed to spawn nativ binary")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
#[test]
fn init_lowercase_name_scaffolds_parseable_project() {
let tmp = tempfile::tempdir().unwrap();
assert!(nativ(tmp.path(), &["init", "my-app"]).status.success());
let app_nativ = std::fs::read_to_string(tmp.path().join("my-app/src/app.nativ")).unwrap();
assert!(
app_nativ.contains("app MyApp:"),
"app declaration must be PascalCased:\n{app_nativ}"
);
assert!(
app_nativ.contains("name: \"my-app\""),
"display name must keep the original spelling:\n{app_nativ}"
);
let check = nativ(tmp.path(), &["check", "--dir", "my-app"]);
assert!(
check.status.success(),
"scaffolded lowercase project failed check.\nstdout: {}\nstderr: {}",
stdout(&check),
stderr(&check)
);
}
#[test]
fn init_scaffolds_a_complete_project() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["init", "Demo"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(stdout(&output).contains("Project created: Demo/"));
let project = tmp.path().join("Demo");
for file in [
"nativ.toml",
".gitignore",
"src/app.nativ",
"src/screens/Home.nativ",
"i18n/en.json",
] {
assert!(project.join(file).is_file(), "missing file: {file}");
}
for dir in [
"assets",
"i18n",
"src/screens",
"src/components",
"src/models",
] {
assert!(project.join(dir).is_dir(), "missing directory: {dir}");
}
let config = NativConfig::load(&project.join("nativ.toml")).unwrap();
assert_eq!(config.app.name, "Demo");
assert_eq!(config.app.bundle_id.as_deref(), Some("com.example.demo"));
assert!(config.build.ios);
assert!(config.build.android);
let check = nativ(tmp.path(), &["check", "--dir", "Demo"]);
assert!(
check.status.success(),
"generated project failed check.\nstdout: {}\nstderr: {}",
stdout(&check),
stderr(&check)
);
assert!(stdout(&check).contains("No errors found"));
}
#[test]
fn init_verbose_logs_created_paths() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["--verbose", "init", "Demo"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
let out = stdout(&output);
assert!(out.contains("Creating project: Demo"));
assert!(out.contains("Dir:"));
}
#[test]
fn init_rejects_names_with_spaces() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["init", "bad name"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("spaces"));
assert!(!tmp.path().join("bad name").exists());
}
#[test]
fn init_rejects_names_with_invalid_characters() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["init", "bad!name"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("letters, numbers"));
}
#[test]
fn init_refuses_to_overwrite_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("taken")).unwrap();
let output = nativ(tmp.path(), &["init", "taken"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("already exists"));
}
#[test]
fn build_compiles_scaffolded_project_for_ios() {
let tmp = tempfile::tempdir().unwrap();
assert!(nativ(tmp.path(), &["init", "Demo"]).status.success());
let output = nativ(
tmp.path(),
&["--verbose", "build", "--ios", "--dir", "Demo"],
);
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(out.contains("Compiling: Demo -> iOS"));
assert!(out.contains("Build complete"));
let ios_dir = tmp.path().join("Demo").join("build").join("ios");
assert!(ios_dir.is_dir(), "build/ios not created");
let has_swift = walk_files(&ios_dir)
.iter()
.any(|p| p.extension().is_some_and(|e| e == "swift"));
assert!(has_swift, "no .swift files generated under build/ios");
}
#[test]
fn build_quiet_suppresses_progress_output() {
let tmp = tempfile::tempdir().unwrap();
assert!(nativ(tmp.path(), &["init", "Demo"]).status.success());
let output = nativ(tmp.path(), &["--quiet", "build", "--ios", "--dir", "Demo"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(!stdout(&output).contains("Compiling"));
assert!(!stdout(&output).contains("Build complete"));
}
#[test]
fn build_fails_without_config_file() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["build"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("nativ.toml"));
}
#[test]
fn build_fails_when_all_targets_disabled() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("nativ.toml"),
"[app]\nname = \"demo\"\n\n[build]\nios = false\nandroid = false\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
let output = nativ(tmp.path(), &["build"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("No target platform"));
}
#[test]
fn check_fails_on_source_with_parse_error() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"demo\"\n").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("src").join("app.nativ"),
"screen Bad:\n\ttext \"tabs\"\n",
)
.unwrap();
let output = nativ(tmp.path(), &["check"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("Error"));
}
#[test]
fn check_reports_collected_semantic_errors() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"demo\"\n").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("src").join("home.nativ"),
"screen Home:\n on tap:\n go to Home\n",
)
.unwrap();
let output = nativ(tmp.path(), &["check"]);
assert!(!output.status.success());
assert!(
stderr(&output).contains("errors found"),
"stderr: {}",
stderr(&output)
);
}
#[test]
fn check_fails_without_config_file() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["check"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("nativ.toml"));
}
#[test]
fn version_prints_crate_version() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["version"]);
assert!(output.status.success());
assert!(stdout(&output).contains(&format!("nativ {}", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn preview_renders_a_self_contained_html_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("home.nativ"),
"screen Home:\n text \"Welcome\", big\n button \"Start\"\n",
)
.unwrap();
let output = nativ(tmp.path(), &["preview", "home.nativ"]);
assert!(
output.status.success(),
"preview should exit 0.\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let html_path = tmp.path().join("home.preview.html");
assert!(html_path.exists(), "preview HTML was not written");
let html = std::fs::read_to_string(&html_path).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"), "not a full document");
assert!(html.contains("<style>"), "CSS must be inlined");
assert!(html.contains("class=\"phone\""), "phone frame missing");
assert!(html.contains("Home"), "screen name missing");
assert!(html.contains("Welcome"), "text content missing");
assert!(html.contains("<button class=\"el btn\""), "button missing");
}
#[test]
fn preview_honors_explicit_out_path() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("home.nativ"),
"screen Home:\n text \"Hi\"\n",
)
.unwrap();
let out = tmp.path().join("custom.html");
let output = nativ(
tmp.path(),
&["preview", "home.nativ", "--out", out.to_str().unwrap()],
);
assert!(output.status.success(), "preview should exit 0");
assert!(out.exists(), "preview did not honor --out");
}
fn project_with_sources(tmp: &Path, files: &[(&str, &str)]) {
std::fs::create_dir_all(tmp.join("src")).unwrap();
for (name, content) in files {
std::fs::write(tmp.join("src").join(name), content).unwrap();
}
}
#[test]
fn format_rewrites_messy_file_and_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
project_with_sources(
tmp.path(),
&[(
"home.nativ",
"screen Home:\n text \"Hi\" ,red \n\n\n\n text \"a,b:c\"\n",
)],
);
let output = nativ(tmp.path(), &["format"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(
stdout(&output).contains("reformatted"),
"{}",
stdout(&output)
);
let formatted = std::fs::read_to_string(tmp.path().join("src/home.nativ")).unwrap();
assert_eq!(
formatted,
"screen Home:\n text \"Hi\", red\n\n text \"a,b:c\"\n"
);
let second = nativ(tmp.path(), &["format"]);
assert!(second.status.success(), "stderr: {}", stderr(&second));
assert!(
stdout(&second).contains("unchanged"),
"second run should report unchanged: {}",
stdout(&second)
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("src/home.nativ")).unwrap(),
formatted
);
}
#[test]
fn format_check_exits_nonzero_without_writing() {
let tmp = tempfile::tempdir().unwrap();
let messy = "screen Home:\n text \"Hi\" ,red\n";
project_with_sources(tmp.path(), &[("home.nativ", messy)]);
let check = nativ(tmp.path(), &["format", "--check"]);
assert!(!check.status.success(), "--check must fail on a messy file");
assert!(
stdout(&check).contains("would reformat"),
"{}",
stdout(&check)
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("src/home.nativ")).unwrap(),
messy
);
assert!(nativ(tmp.path(), &["format"]).status.success());
let recheck = nativ(tmp.path(), &["format", "--check"]);
assert!(
recheck.status.success(),
"--check should pass after formatting.\nstdout: {}\nstderr: {}",
stdout(&recheck),
stderr(&recheck)
);
}
#[test]
fn format_fails_on_unparseable_file() {
let tmp = tempfile::tempdir().unwrap();
project_with_sources(
tmp.path(),
&[("bad.nativ", "screen Bad:\n\ttext \"tabs\"\n")],
);
let output = nativ(tmp.path(), &["format"]);
assert!(!output.status.success());
assert!(
stderr(&output).contains("could not be formatted"),
"stderr: {}",
stderr(&output)
);
}
#[test]
fn format_handles_fixture_corpus_in_temp_copy() {
let fixtures = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("valid");
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let mut copied = 0;
for entry in std::fs::read_dir(&fixtures).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "nativ") {
std::fs::copy(&path, src_dir.join(path.file_name().unwrap())).unwrap();
copied += 1;
}
}
assert!(copied > 0, "no fixtures found at {}", fixtures.display());
let output = nativ(tmp.path(), &["format"]);
assert!(
output.status.success(),
"format failed on fixture corpus.\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
for path in walk_files(&src_dir) {
let content = std::fs::read_to_string(&path).unwrap();
nativ_compiler::parse(&content)
.unwrap_or_else(|e| panic!("{} no longer parses after format: {e}", path.display()));
}
let check = nativ(tmp.path(), &["format", "--check"]);
assert!(
check.status.success(),
"format is not idempotent over the fixture corpus.\nstdout: {}",
stdout(&check)
);
}
#[test]
fn watch_fails_cleanly_on_missing_project_dir() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["watch", "--dir", "no-such-dir"]);
assert!(!output.status.success(), "watch should exit non-zero");
assert!(
stderr(&output).contains("nativ.toml"),
"stderr should point at the missing config: {}",
stderr(&output)
);
}
#[test]
fn unknown_command_fails_with_usage_error() {
let tmp = tempfile::tempdir().unwrap();
let output = nativ(tmp.path(), &["frobnicate"]);
assert!(!output.status.success());
}
fn walk_files(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walk_files(&path));
} else {
files.push(path);
}
}
}
files
}
#[test]
fn check_fails_on_undefined_screen_with_clear_message() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"demo\"\n").unwrap();
project_with_sources(
tmp.path(),
&[(
"home.nativ",
"screen Home:\n button \"Go\":\n go to Missing\n",
)],
);
let output = nativ(tmp.path(), &["check"]);
assert!(!output.status.success(), "check must exit non-zero");
let err = stderr(&output);
assert!(
err.contains("Screen 'Missing' is not defined."),
"stderr must name the undefined screen: {err}"
);
assert!(
err.contains("home.nativ") && err.contains(":2:3:"),
"stderr must point at file:line:col: {err}"
);
assert!(err.contains("1 errors found"), "stderr: {err}");
}
#[test]
fn check_suggests_closest_screen_name_for_typos() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"demo\"\n").unwrap();
project_with_sources(
tmp.path(),
&[(
"app.nativ",
"screen Home:\n button \"Go\":\n go to Setings\n\nscreen Settings:\n text \"s\"\n",
)],
);
let output = nativ(tmp.path(), &["check"]);
assert!(!output.status.success());
assert!(
stderr(&output).contains("Did you mean 'Settings'?"),
"stderr: {}",
stderr(&output)
);
}
#[test]
fn build_refuses_to_generate_on_semantic_errors() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("nativ.toml"), "[app]\nname = \"demo\"\n").unwrap();
project_with_sources(
tmp.path(),
&[(
"home.nativ",
"screen Home:\n button \"Go\":\n go to Missing\n",
)],
);
let output = nativ(tmp.path(), &["build", "--ios"]);
assert!(!output.status.success(), "build must exit non-zero");
let err = stderr(&output);
assert!(
err.contains("no code was generated"),
"stderr must say the build was refused: {err}"
);
assert!(
err.contains("Screen 'Missing' is not defined."),
"stderr must list the semantic error: {err}"
);
assert!(
!tmp.path().join("build").exists(),
"no output directory may be created"
);
}