use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc, RwLock};
use std::time::{Duration, Instant};
use console::style;
use crepuscularity_core::context::{TemplateContext, TemplateValue};
use crepuscularity_native::{
plan_hot_reload, render_template_to_ir, to_json, HotReloadEnvelope, HotReloadMessage,
};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use serde_json::Value;
use crate::{native, ui};
const DEFAULT_PORT: u16 = 4001;
const DEFAULT_TEMPLATE: &str = "views/main.crepus";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MobilePlatform {
Ios,
Android,
All,
}
#[derive(Debug)]
struct MobileDevArgs {
dir: PathBuf,
port: u16,
platform: MobilePlatform,
template: PathBuf,
ctx_file: Option<PathBuf>,
vars: Vec<(String, String)>,
}
struct MobileDevState {
sequence: AtomicU64,
root: PathBuf,
platform: MobilePlatform,
template_path: PathBuf,
ctx: TemplateContext,
last_template: RwLock<String>,
last_ir_json: RwLock<String>,
last_event: RwLock<HotReloadEnvelope>,
}
pub fn run(args: &[String]) {
match args.first().map(|s| s.as_str()) {
Some("--help") | Some("-h") | None => print_mobile_usage(),
Some("new") => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or_else(|| {
ui::error("Usage: crepus mobile new <name>");
});
native::run(&["new".to_string(), name.to_string()]);
}
Some("sync") => {
let forwarded = with_default_template_arg("sync", &args[1..]);
native::run(&forwarded);
}
Some("codegen") => {
run_codegen(&args[1..]).unwrap_or_else(|e| ui::error(&e));
}
Some("build") => run_build(&args[1..]),
Some("run") => run_mobile_app(&args[1..]),
Some("doctor") => run_doctor(&args[1..]),
Some("dev") => {
let parsed = parse_dev_args(&args[1..]).unwrap_or_else(|e| ui::error(&e));
run_dev(parsed).unwrap_or_else(|e| ui::error(&e));
}
Some(other) => ui::error(&format!("unknown mobile command: {other}")),
}
}
fn run_doctor(args: &[String]) {
let platform = parse_platform_arg(args).unwrap_or(MobilePlatform::All);
let mut ok = true;
eprintln!("{}", style("crepus mobile doctor").cyan().bold());
ok &= doctor_command("cargo", &["--version"]);
match platform {
MobilePlatform::Ios | MobilePlatform::All => {
ok &= doctor_rust_target("aarch64-apple-ios");
ok &= doctor_command("xcodebuild", &["-version"]);
ok &= doctor_command("xcodegen", &["--version"]);
ok &= doctor_command("xcrun", &["simctl", "list", "runtimes"]);
}
_ => {}
}
match platform {
MobilePlatform::Android | MobilePlatform::All => {
ok &= doctor_rust_target("aarch64-linux-android");
ok &= doctor_command("java", &["-version"]);
ok &= doctor_java17();
ok &= doctor_java_home();
ok &= doctor_command("gradle", &["--version"]);
ok &= doctor_android_sdk();
ok &= doctor_android_ndk();
}
_ => {}
}
if !ok {
std::process::exit(1);
}
}
fn doctor_command(command: &str, args: &[&str]) -> bool {
match Command::new(command).args(args).output() {
Ok(out) if out.status.success() => {
eprintln!("{} {command}", style("✓").green());
true
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
eprintln!("{} {command}: {}", style("✗").red(), stderr.trim());
false
}
Err(e) => {
eprintln!("{} {command}: {e}", style("✗").red());
false
}
}
}
fn doctor_rust_target(target: &str) -> bool {
match Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
{
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.lines().any(|line| line.trim() == target) {
eprintln!("{} rust target {target}", style("✓").green());
true
} else {
eprintln!(
"{} rust target {target}: install with `rustup target add {target}`",
style("✗").red()
);
false
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
eprintln!("{} rustup: {}", style("✗").red(), stderr.trim());
false
}
Err(e) => {
eprintln!("{} rustup: {e}", style("✗").red());
false
}
}
}
fn doctor_java17() -> bool {
if let Ok(java_home) = std::env::var("JAVA_HOME") {
let java = PathBuf::from(&java_home).join("bin/java");
if java_version_at_least(&java, 17) {
eprintln!("{} Java 17 {}", style("✓").green(), java_home);
true
} else {
eprintln!(
"{} Java 17: JAVA_HOME does not point to Java 17+",
style("✗").red()
);
false
}
} else {
match Command::new("/usr/libexec/java_home")
.args(["-v", "17"])
.output()
{
Ok(out) if out.status.success() => {
let path = String::from_utf8_lossy(&out.stdout);
eprintln!("{} Java 17 {}", style("✓").green(), path.trim());
true
}
_ if java_version_at_least(Path::new("java"), 17) => {
eprintln!("{} Java 17 java on PATH", style("✓").green());
true
}
_ => {
eprintln!(
"{} Java 17: install openjdk@17 and expose it to Gradle",
style("✗").red()
);
false
}
}
}
}
fn java_version_at_least(java: &Path, major: u32) -> bool {
let Ok(out) = Command::new(java).arg("-version").output() else {
return false;
};
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
text.split(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
.find_map(parse_java_major)
.is_some_and(|found| found >= major)
}
fn parse_java_major(raw: &str) -> Option<u32> {
if raw.is_empty() {
return None;
}
let first = raw.split('.').next()?;
first.parse().ok()
}
fn doctor_java_home() -> bool {
match std::env::var("JAVA_HOME") {
Ok(raw) => {
let path = PathBuf::from(&raw);
if path.join("bin/java").exists() {
eprintln!("{} JAVA_HOME {}", style("✓").green(), path.display());
true
} else {
eprintln!(
"{} JAVA_HOME {} does not contain bin/java",
style("✗").red(),
path.display()
);
false
}
}
Err(_) => {
eprintln!(
"{} JAVA_HOME not set; Gradle will use java from PATH",
style("✓").green()
);
true
}
}
}
fn doctor_android_sdk() -> bool {
let sdk = std::env::var("ANDROID_HOME")
.or_else(|_| std::env::var("ANDROID_SDK_ROOT"))
.ok()
.map(PathBuf::from);
match sdk {
Some(path) if path.join("platforms").exists() => {
eprintln!("{} Android SDK {}", style("✓").green(), path.display());
true
}
Some(path) => {
eprintln!(
"{} Android SDK {} missing platforms/",
style("✗").red(),
path.display()
);
false
}
None => {
eprintln!(
"{} Android SDK: set ANDROID_HOME or ANDROID_SDK_ROOT",
style("✗").red()
);
false
}
}
}
fn doctor_android_ndk() -> bool {
if let Ok(path) = std::env::var("ANDROID_NDK_HOME").map(PathBuf::from) {
if android_ndk_clang(&path).is_some() {
eprintln!("{} Android NDK {}", style("✓").green(), path.display());
return true;
}
}
let sdk = std::env::var("ANDROID_HOME")
.or_else(|_| std::env::var("ANDROID_SDK_ROOT"))
.ok()
.map(PathBuf::from);
if let Some(sdk) = sdk {
let ndk = sdk.join("ndk");
if let Some(path) = latest_android_ndk(&ndk) {
eprintln!("{} Android NDK {}", style("✓").green(), path.display());
return true;
}
}
eprintln!(
"{} Android NDK: install via Android Studio SDK Manager or set ANDROID_NDK_HOME",
style("✗").red()
);
false
}
fn latest_android_ndk(ndk_dir: &Path) -> Option<PathBuf> {
let mut entries = ndk_dir
.read_dir()
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && android_ndk_clang(path).is_some())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn android_ndk_clang(ndk: &Path) -> Option<PathBuf> {
let prebuilt = ndk.join("toolchains/llvm/prebuilt");
prebuilt
.read_dir()
.ok()?
.flatten()
.map(|entry| {
entry
.path()
.join("bin")
.join("aarch64-linux-android26-clang")
})
.find(|path| path.exists())
}
fn run_build(args: &[String]) {
let platform = parse_platform_arg(args).unwrap_or(MobilePlatform::All);
let stripped = strip_mobile_only_args(args);
reject_mobile_release_flag(&stripped);
let build_args = stripped;
match platform {
MobilePlatform::Ios => {
let mut forwarded = vec!["build".to_string(), "ios".to_string()];
forwarded.extend(build_args);
native::run(&forwarded);
}
MobilePlatform::Android => {
let mut forwarded = vec!["build".to_string(), "android".to_string()];
forwarded.extend(build_args);
native::run(&forwarded);
}
MobilePlatform::All => {
let mut ios = vec!["build".to_string(), "ios".to_string()];
ios.extend(build_args.clone());
native::run(&ios);
let mut android = vec!["build".to_string(), "android".to_string()];
android.extend(build_args);
native::run(&android);
}
}
}
fn run_mobile_app(args: &[String]) {
let platform = parse_platform_arg(args).unwrap_or(MobilePlatform::Android);
let stripped = strip_mobile_only_args(args);
reject_mobile_release_flag(&stripped);
let run_args = stripped;
match platform {
MobilePlatform::Ios => {
let mut forwarded = vec!["run".to_string(), "ios".to_string()];
forwarded.extend(run_args);
native::run(&forwarded);
}
MobilePlatform::Android => {
let mut forwarded = vec!["run".to_string(), "android".to_string()];
forwarded.extend(run_args);
native::run(&forwarded);
}
MobilePlatform::All => ui::error("crepus mobile run expects --platform ios or android"),
}
}
fn reject_mobile_release_flag(args: &[String]) {
if args.iter().any(|arg| arg == "--release") {
ui::error("crepus mobile build does not use --release; use --configuration Release for iOS or --flavor Release for Android");
}
}
fn run_codegen(args: &[String]) -> Result<(), String> {
let platform = parse_platform_arg(args).unwrap_or(MobilePlatform::All);
let dir = parse_dir_arg(args)?;
let stripped = strip_mobile_codegen_args(args);
let template = stripped
.first()
.filter(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_else(|| DEFAULT_TEMPLATE.to_string());
let extra = if stripped
.first()
.filter(|arg| !arg.starts_with('-'))
.is_some()
{
stripped[1..].to_vec()
} else {
stripped
};
let template_path = resolve_template_path(&dir, Path::new(&template));
match platform {
MobilePlatform::Ios | MobilePlatform::All => {
let mut forwarded = vec![
"codegen".to_string(),
template_path.display().to_string(),
"--platform".to_string(),
"swiftui".to_string(),
"--out".to_string(),
dir.join("ios/Sources/NativeShell/Generated")
.display()
.to_string(),
"--view-name".to_string(),
"CrepusGeneratedView".to_string(),
];
forwarded.extend(extra.clone());
native::run(&forwarded);
}
_ => {}
}
match platform {
MobilePlatform::Android | MobilePlatform::All => {
let out_dir =
dir.join("android/app/src/main/java/dev/crepuscularity/nativeshell/generated");
let mut forwarded = vec![
"codegen".to_string(),
template_path.display().to_string(),
"--platform".to_string(),
"compose".to_string(),
"--out".to_string(),
out_dir.display().to_string(),
"--view-name".to_string(),
"CrepusGeneratedView".to_string(),
];
forwarded.extend(extra);
native::run(&forwarded);
prepend_kotlin_package(&out_dir.join("CrepusGeneratedView.kt"));
}
_ => {}
}
Ok(())
}
fn run_dev(args: MobileDevArgs) -> Result<(), String> {
let root = fs::canonicalize(&args.dir).unwrap_or(args.dir);
let template_path = resolve_template_path(&root, &args.template);
let mut ctx = TemplateContext::new();
if let Some(path) = &args.ctx_file {
load_json_ctx(path, &mut ctx)?;
}
for (key, raw) in args.vars {
ctx.set(key, parse_var_value(&raw));
}
ctx.base_dir = template_path.parent().map(Path::to_path_buf);
let initial_template = fs::read_to_string(&template_path)
.map_err(|e| format!("read {}: {e}", template_path.display()))?;
let ir = render_template_to_ir(&initial_template, &ctx).map_err(|e| e.to_string())?;
let ir_json = to_json(&ir).map_err(|e| format!("serialize IR: {e}"))?;
let event = HotReloadEnvelope {
sequence: 0,
message: HotReloadMessage::FullReload {
ir,
reason: "initial load".to_string(),
},
};
let state = Arc::new(MobileDevState {
sequence: AtomicU64::new(0),
root: root.clone(),
platform: args.platform,
template_path: template_path.clone(),
ctx,
last_template: RwLock::new(initial_template),
last_ir_json: RwLock::new(ir_json),
last_event: RwLock::new(event),
});
sync_outputs(&root, &template_path, args.platform);
start_mobile_watcher(root.clone(), state.clone());
let addr = format!("127.0.0.1:{}", args.port);
let listener = TcpListener::bind(&addr).map_err(|e| format!("bind {addr}: {e}"))?;
eprintln!(
"{} mobile dev server listening on http://{}",
style("crepus").cyan().bold(),
addr
);
eprintln!(
" {} watching {}",
style("→").dim(),
template_path.display()
);
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
let state = state.clone();
std::thread::spawn(move || {
let _ = handle_mobile_dev_stream(&mut stream, &state);
});
}
Err(e) => eprintln!(" {} mobile dev connection error: {e}", style("⚠").yellow()),
}
}
Ok(())
}
fn start_mobile_watcher(root: PathBuf, state: Arc<MobileDevState>) {
let (tx, rx) = mpsc::channel::<PathBuf>();
let watch_root = root.join("views");
std::thread::spawn(move || {
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<Event>| match res {
Ok(event) => {
if is_template_event(&event) {
for path in event.paths {
let _ = tx.send(path);
}
}
}
Err(e) => eprintln!(" {} mobile watcher error: {e}", style("⚠").yellow()),
})
.expect("crepus mobile dev: cannot create file watcher");
let _ = watcher.watch(&watch_root, RecursiveMode::Recursive);
loop {
std::thread::sleep(Duration::from_secs(3600));
}
});
std::thread::spawn(move || {
while rx.recv().is_ok() {
let start = Instant::now();
while start.elapsed() < Duration::from_millis(50) {
if rx.try_recv().is_err() {
std::thread::sleep(Duration::from_millis(10));
}
}
refresh_mobile_state(&state);
}
});
}
fn refresh_mobile_state(state: &MobileDevState) {
let new_template = match fs::read_to_string(&state.template_path) {
Ok(value) => value,
Err(e) => {
store_event(
state,
HotReloadMessage::Error {
message: format!("read {}: {e}", state.template_path.display()),
},
);
return;
}
};
let old_template = state
.last_template
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let rendered_ir = match render_template_to_ir(&new_template, &state.ctx) {
Ok(ir) => ir,
Err(e) => {
store_event(
state,
HotReloadMessage::Error {
message: e.to_string(),
},
);
return;
}
};
let message = plan_hot_reload(&old_template, &new_template, &state.ctx);
match &message {
HotReloadMessage::Patch { .. } | HotReloadMessage::FullReload { ir: _, .. } => {
if let Ok(json) = to_json(&rendered_ir) {
*state
.last_ir_json
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = json;
*state
.last_template
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = new_template;
sync_outputs(&state.root, &state.template_path, state.platform);
}
}
_ => {}
}
store_event(state, message);
}
fn store_event(state: &MobileDevState, message: HotReloadMessage) {
let sequence = state.sequence.fetch_add(1, Ordering::SeqCst) + 1;
let envelope = HotReloadEnvelope { sequence, message };
*state
.last_event
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = envelope;
}
fn handle_mobile_dev_stream(stream: &mut TcpStream, state: &MobileDevState) -> std::io::Result<()> {
let mut buf = [0; 4096];
let n = stream.read(&mut buf)?;
let req = String::from_utf8_lossy(&buf[..n]);
let path = req
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
let response = mobile_dev_response(path, state);
stream.write_all(response.as_bytes())
}
fn mobile_dev_response(path: &str, state: &MobileDevState) -> String {
match path {
"/health" => {
let payload = serde_json::json!({
"ok": true,
"sequence": state.sequence.load(Ordering::SeqCst)
});
http_response("200 OK", "application/json", &payload.to_string())
}
"/ir" => {
let body = state
.last_ir_json
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
http_response("200 OK", "application/json", &body)
}
"/events" => {
let envelope = state
.last_event
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let body = format!(
"event: crepus-mobile\ndata: {}\n\n",
serde_json::to_string(&envelope).unwrap_or_else(|_| "{}".to_string())
);
http_response("200 OK", "text/event-stream", &body)
}
_ => http_response("404 Not Found", "text/plain", "not found"),
}
}
fn http_response(status: &str, content_type: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
fn sync_outputs(root: &Path, template_path: &Path, platform: MobilePlatform) {
let template = template_path
.strip_prefix(root)
.unwrap_or(template_path)
.display()
.to_string();
let mut sync_args = vec![
"sync".to_string(),
template,
"--dir".to_string(),
root.display().to_string(),
"--pretty".to_string(),
];
native::run(&sync_args);
match platform {
MobilePlatform::Ios | MobilePlatform::All => {
sync_args = vec![
"codegen".to_string(),
template_path.display().to_string(),
"--platform".to_string(),
"swiftui".to_string(),
"--out".to_string(),
root.join("ios/Sources/NativeShell/Generated")
.display()
.to_string(),
"--view-name".to_string(),
"CrepusGeneratedView".to_string(),
];
native::run(&sync_args);
}
_ => {}
}
match platform {
MobilePlatform::Android | MobilePlatform::All => {
let out_dir =
root.join("android/app/src/main/java/dev/crepuscularity/nativeshell/generated");
let args = vec![
"codegen".to_string(),
template_path.display().to_string(),
"--platform".to_string(),
"compose".to_string(),
"--out".to_string(),
out_dir.display().to_string(),
"--view-name".to_string(),
"CrepusGeneratedView".to_string(),
];
native::run(&args);
prepend_kotlin_package(&out_dir.join("CrepusGeneratedView.kt"));
}
_ => {}
}
}
fn prepend_kotlin_package(path: &Path) {
let Ok(source) = fs::read_to_string(path) else {
return;
};
if source.starts_with("package ") {
return;
}
let updated = format!("package dev.crepuscularity.nativeshell\n\n{source}");
let _ = fs::write(path, updated);
}
fn with_default_template_arg(command: &str, args: &[String]) -> Vec<String> {
let mut forwarded = vec![command.to_string()];
if args.first().is_none_or(|arg| arg.starts_with('-')) {
forwarded.push(DEFAULT_TEMPLATE.to_string());
}
forwarded.extend(args.iter().cloned());
forwarded
}
fn parse_dev_args(args: &[String]) -> Result<MobileDevArgs, String> {
let mut dir = PathBuf::from(".");
let mut port = DEFAULT_PORT;
let mut platform = MobilePlatform::All;
let mut template = PathBuf::from(DEFAULT_TEMPLATE);
let mut ctx_file = None;
let mut vars = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--dir" => {
i += 1;
dir = args
.get(i)
.map(PathBuf::from)
.ok_or_else(|| "--dir expects a path".to_string())?;
}
"--port" => {
i += 1;
let raw = args
.get(i)
.ok_or_else(|| "--port expects a value".to_string())?;
port = raw
.parse()
.map_err(|_| format!("invalid --port value: {raw}"))?;
}
"--platform" => {
i += 1;
platform = parse_platform_value(
args.get(i)
.ok_or_else(|| "--platform expects ios, android, or all".to_string())?,
)?;
}
"--template" => {
i += 1;
template = args
.get(i)
.map(PathBuf::from)
.ok_or_else(|| "--template expects a file path".to_string())?;
}
"--ctx" => {
i += 1;
ctx_file = args.get(i).map(PathBuf::from);
if ctx_file.is_none() {
return Err("--ctx expects a file path".to_string());
}
}
"--var" => {
i += 1;
let raw = args
.get(i)
.ok_or_else(|| "--var expects key=value".to_string())?;
let Some((key, value)) = raw.split_once('=') else {
return Err(format!("--var expects key=value, got: {raw}"));
};
vars.push((key.to_string(), value.to_string()));
}
other => {
if let Some(value) = other.strip_prefix("--dir=") {
dir = PathBuf::from(value);
} else if let Some(value) = other.strip_prefix("--port=") {
port = value
.parse()
.map_err(|_| format!("invalid --port value: {value}"))?;
} else if let Some(value) = other.strip_prefix("--platform=") {
platform = parse_platform_value(value)?;
} else if other.starts_with('-') {
return Err(format!("unknown option: {other}"));
} else {
return Err(format!("unexpected argument: {other}"));
}
}
}
i += 1;
}
Ok(MobileDevArgs {
dir,
port,
platform,
template,
ctx_file,
vars,
})
}
fn parse_dir_arg(args: &[String]) -> Result<PathBuf, String> {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--dir" {
return args
.get(i + 1)
.map(PathBuf::from)
.ok_or_else(|| "--dir expects a path".to_string());
}
if let Some(value) = arg.strip_prefix("--dir=") {
return Ok(PathBuf::from(value));
}
i += 1;
}
Ok(PathBuf::from("."))
}
fn parse_platform_arg(args: &[String]) -> Option<MobilePlatform> {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--platform" {
return args
.get(i + 1)
.and_then(|raw| parse_platform_value(raw).ok());
}
if let Some(value) = arg.strip_prefix("--platform=") {
return parse_platform_value(value).ok();
}
i += 1;
}
None
}
fn parse_platform_value(raw: &str) -> Result<MobilePlatform, String> {
match raw {
"ios" | "swift" | "swiftui" => Ok(MobilePlatform::Ios),
"android" | "compose" | "kotlin" => Ok(MobilePlatform::Android),
"all" => Ok(MobilePlatform::All),
_ => Err(format!("unknown mobile platform: {raw}")),
}
}
fn strip_mobile_codegen_args(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--platform" || arg == "--dir" {
i += 2;
continue;
}
if arg.starts_with("--platform=") || arg.starts_with("--dir=") {
i += 1;
continue;
}
out.push(arg.clone());
i += 1;
}
out
}
fn strip_mobile_only_args(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--platform" {
i += 2;
continue;
}
if arg.starts_with("--platform=") {
i += 1;
continue;
}
out.push(arg.clone());
i += 1;
}
out
}
fn is_template_event(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event
.paths
.iter()
.any(|path| path.extension().and_then(|e| e.to_str()) == Some("crepus"))
}
fn resolve_template_path(root: &Path, template: &Path) -> PathBuf {
if template.is_absolute() {
return template.to_path_buf();
}
let rooted = root.join(template);
if rooted.exists() {
rooted
} else {
template.to_path_buf()
}
}
fn load_json_ctx(path: &Path, ctx: &mut TemplateContext) -> Result<(), String> {
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let value: Value =
serde_json::from_str(&raw).map_err(|e| format!("context JSON {}: {e}", path.display()))?;
merge_json_ctx(&value, ctx)
}
fn merge_json_ctx(value: &Value, ctx: &mut TemplateContext) -> Result<(), String> {
let Some(obj) = value.as_object() else {
return Err("context must be a JSON object".to_string());
};
for (key, value) in obj {
ctx.set(key.clone(), json_to_template_value(value)?);
}
Ok(())
}
fn json_to_template_value(value: &Value) -> Result<TemplateValue, String> {
match value {
Value::Null => Ok(TemplateValue::Null),
Value::Bool(v) => Ok(TemplateValue::Bool(*v)),
Value::Number(v) => {
if let Some(n) = v.as_i64() {
Ok(TemplateValue::Int(n))
} else if let Some(n) = v.as_f64() {
Ok(TemplateValue::Float(n))
} else {
Err(format!("unsupported number: {v}"))
}
}
Value::String(v) => Ok(TemplateValue::Str(v.clone())),
Value::Array(_) => Err("mobile dev context arrays are not supported yet".to_string()),
Value::Object(_) => Err("context object values are not supported".to_string()),
}
}
fn parse_var_value(raw: &str) -> TemplateValue {
match raw {
"true" => TemplateValue::Bool(true),
"false" => TemplateValue::Bool(false),
"null" => TemplateValue::Null,
_ => raw
.parse::<i64>()
.map(TemplateValue::Int)
.or_else(|_| raw.parse::<f64>().map(TemplateValue::Float))
.unwrap_or_else(|_| TemplateValue::Str(raw.to_string())),
}
}
fn print_mobile_usage() {
eprintln!("{}", style("crepus mobile").cyan().bold());
eprintln!("{}", style("Unified iOS + Android mobile workflow").dim());
eprintln!();
eprintln!("{}", style("COMMANDS").dim());
eprintln!(
" {} {}",
style("new <name> ").green(),
style("scaffold iOS + Android shells with dev runtime").dim()
);
eprintln!(
" {} {}",
style("dev [--platform ios|android|all] [--dir P] ").green(),
style("watch templates and serve View IR hot reload").dim()
);
eprintln!(
" {} {}",
style("build [--platform ios|android|all] ").green(),
style("build native app targets").dim()
);
eprintln!(
" {} {}",
style("run --platform ios|android ").green(),
style("run/install one native target").dim()
);
eprintln!(
" {} {}",
style("doctor [--platform ios|android|all] ").green(),
style("check Rust, Xcode, Java, Android SDK/NDK prerequisites").dim()
);
eprintln!(
" {} {}",
style("sync [views/main.crepus] ").green(),
style("mirror View IR fixtures into native shells").dim()
);
eprintln!(
" {} {}",
style("codegen [file.crepus] --platform ios|android ").green(),
style("write generated SwiftUI or Compose source").dim()
);
}
#[cfg(test)]
mod tests {
use super::*;
use crepuscularity_core::context::TemplateContext;
fn test_state() -> MobileDevState {
let ir = render_template_to_ir("div\n \"Hi\"", &TemplateContext::new()).unwrap();
let ir_json = to_json(&ir).unwrap();
MobileDevState {
sequence: AtomicU64::new(7),
root: PathBuf::from("."),
platform: MobilePlatform::All,
template_path: PathBuf::from("views/main.crepus"),
ctx: TemplateContext::new(),
last_template: RwLock::new("div\n \"Hi\"".to_string()),
last_ir_json: RwLock::new(ir_json),
last_event: RwLock::new(HotReloadEnvelope {
sequence: 7,
message: HotReloadMessage::FullReload {
ir,
reason: "test".to_string(),
},
}),
}
}
fn temp_state(template: &str) -> (tempfile::TempDir, MobileDevState) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_path_buf();
let views = root.join("views");
std::fs::create_dir_all(&views).unwrap();
let template_path = views.join("main.crepus");
std::fs::write(&template_path, template).unwrap();
let ctx = TemplateContext::new();
let ir = render_template_to_ir(template, &ctx).unwrap();
let ir_json = to_json(&ir).unwrap();
(
temp,
MobileDevState {
sequence: AtomicU64::new(0),
root,
platform: MobilePlatform::Ios,
template_path,
ctx,
last_template: RwLock::new(template.to_string()),
last_ir_json: RwLock::new(ir_json),
last_event: RwLock::new(HotReloadEnvelope {
sequence: 0,
message: HotReloadMessage::FullReload {
ir,
reason: "test".to_string(),
},
}),
},
)
}
#[test]
fn health_response_reports_sequence() {
let response = mobile_dev_response("/health", &test_state());
assert!(response.starts_with("HTTP/1.1 200 OK"));
assert!(response.contains("\"ok\":true"));
assert!(response.contains("\"sequence\":7"));
}
#[test]
fn ir_response_returns_latest_ir() {
let response = mobile_dev_response("/ir", &test_state());
assert!(response.contains("\"version\":3"));
assert!(response.contains("\"content\":\"Hi\""));
}
#[test]
fn events_response_streams_hot_reload_envelope() {
let response = mobile_dev_response("/events", &test_state());
assert!(response.contains("Content-Type: text/event-stream"));
assert!(response.contains("event: crepus-mobile"));
assert!(response.contains("\"sequence\":7"));
assert!(response.contains("\"kind\":\"fullReload\""));
}
#[test]
fn refresh_emits_patch_for_literal_change() {
let (_temp, state) = temp_state("div\n \"Hi\"");
std::fs::write(&state.template_path, "div\n \"Bye\"").unwrap();
refresh_mobile_state(&state);
let event = state
.last_event
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(matches!(event.message, HotReloadMessage::Patch { .. }));
assert!(state
.last_ir_json
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains("Bye"));
}
#[test]
fn refresh_error_keeps_last_good_ir() {
let (_temp, state) = temp_state("div\n \"Hi\"");
std::fs::write(&state.template_path, "include missing.crepus").unwrap();
refresh_mobile_state(&state);
let event = state
.last_event
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(matches!(event.message, HotReloadMessage::Error { .. }));
let ir = state
.last_ir_json
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(ir.contains("Hi"));
assert!(!ir.contains("bad indent"));
}
#[test]
fn refresh_render_error_does_not_emit_patch() {
let (_temp, state) = temp_state("div\n \"Hi\"");
std::fs::write(&state.template_path, "div\n include missing.crepus").unwrap();
refresh_mobile_state(&state);
let event = state
.last_event
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(matches!(event.message, HotReloadMessage::Error { .. }));
let ir = state
.last_ir_json
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(ir.contains("Hi"));
assert!(!ir.contains("missing.crepus"));
}
}