use std::path::{Path, PathBuf};
use std::process::Command;
const HOT_RUSTFLAGS: &str = "-C prefer-dynamic -C link-dead-code";
fn hot_env(cmd: &mut Command, target: &Path) {
cmd.env("RUSTFLAGS", HOT_RUSTFLAGS)
.env("CARGO_TARGET_DIR", target)
.env("RUSTC_BOOTSTRAP", "1");
}
pub fn run(debounce_ms: u64) -> Result<(), String> {
let app_dir = std::env::current_dir().map_err(|e| e.to_string())?;
let lib_name = lib_crate_name(&app_dir)
.ok_or("could not determine the library crate name from Cargo.toml")?;
if !exports_dev_root(&app_dir) {
return Err(format!(
"NOT_TIER2_READY: {lib_name} has no `__rsc_dev_root` export \
(Tier-2 hot-reload entry). Add it to src/lib.rs, or scaffold a new app."
));
}
let target = app_dir.join("target").join("rsc-hot");
std::fs::create_dir_all(&target).map_err(|e| e.to_string())?;
println!("rsc dev — Tier 2 dylib hot-reload (desktop)");
println!(" Collecting native link flags…");
let link_flags = collect_native_link_flags(&app_dir, &target)?;
println!(" Building the app module (dylib)…");
build_module(&app_dir, &target, &link_flags)?;
let module = target.join("debug").join(format!("lib{lib_name}.dylib"));
if !module.exists() {
return Err(format!("module dylib not produced at {}", module.display()));
}
println!(" Building the dev host…");
let host_bin = build_host(&app_dir, &target)?;
let dyld = dyld_search_path(&target)?;
println!(" Launching…\n");
let mut child = Command::new(&host_bin)
.arg(&module)
.env("DYLD_FALLBACK_LIBRARY_PATH", &dyld)
.spawn()
.map_err(|e| format!("failed to launch dev host: {e}"))?;
watch_and_rebuild(&app_dir, &target, &link_flags, &mut child, std::time::Duration::from_millis(debounce_ms));
Ok(())
}
fn lib_crate_name(app_dir: &Path) -> Option<String> {
let toml = std::fs::read_to_string(app_dir.join("Cargo.toml")).ok()?;
let mut in_lib = false;
for line in toml.lines() {
let t = line.trim();
if t.starts_with('[') {
in_lib = t == "[lib]";
continue;
}
if in_lib && t.starts_with("name") {
if let Some(v) = toml_str_value(t) {
return Some(v.replace('-', "_"));
}
}
}
let mut in_pkg = false;
for line in toml.lines() {
let t = line.trim();
if t.starts_with('[') {
in_pkg = t == "[package]";
continue;
}
if in_pkg && t.starts_with("name") {
if let Some(v) = toml_str_value(t) {
return Some(v.replace('-', "_"));
}
}
}
None
}
fn toml_str_value(line: &str) -> Option<String> {
let after = line.split_once('=')?.1.trim();
Some(after.trim_matches('"').to_string())
}
fn exports_dev_root(app_dir: &Path) -> bool {
std::fs::read_to_string(app_dir.join("src").join("lib.rs"))
.map(|s| s.contains("__rsc_dev_root"))
.unwrap_or(false)
}
fn rosace_dep_for_host(app_dir: &Path) -> Result<String, String> {
let toml = std::fs::read_to_string(app_dir.join("Cargo.toml"))
.map_err(|e| format!("read Cargo.toml: {e}"))?;
for line in toml.lines() {
let t = line.trim();
if t.starts_with("rosace ") || t.starts_with("rosace=") {
if let Some(path) = t.split_once("path").and_then(|(_, r)| {
r.trim_start_matches([' ', '=']).split('"').nth(1)
}) {
return Ok(format!(
"rosace = {{ path = \"{path}\", features = [\"rsc-hot\"] }}"
));
}
if let Some(v) = toml_str_value(t) {
return Ok(format!(
"rosace = {{ version = \"{v}\", features = [\"rsc-hot\"] }}"
));
}
}
}
Err("no `rosace` dependency found in the app's Cargo.toml".into())
}
fn collect_native_link_flags(app_dir: &Path, target: &Path) -> Result<String, String> {
let mut cmd = Command::new("cargo");
cmd.current_dir(app_dir).args([
"build", "--lib", "--features", "rosace/rsc-hot",
"--message-format=json-render-diagnostics",
]);
hot_env(&mut cmd, target);
let out = cmd
.output()
.map_err(|e| format!("cargo metadata build failed to start: {e}"))?;
if !out.status.success() {
return Err(format!(
"metadata build failed:\n{}",
String::from_utf8_lossy(&out.stderr)
));
}
let mut flags: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
if !line.contains("\"build-script-executed\"") {
continue;
}
for path in json_string_array(line, "linked_paths") {
let flag = format!("-L {}", normalize_link_kv(&path, "native"));
if seen.insert(flag.clone()) { flags.push(flag); }
}
for lib in json_string_array(line, "linked_libs") {
let flag = format!("-l {}", lib);
if seen.insert(flag.clone()) { flags.push(flag); }
}
}
Ok(flags.join(" "))
}
fn json_string_array(line: &str, key: &str) -> Vec<String> {
let needle = format!("\"{key}\":[");
let Some(start) = line.find(&needle) else { return Vec::new() };
let rest = &line[start + needle.len()..];
let Some(end) = rest.find(']') else { return Vec::new() };
rest[..end]
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches('"');
if s.is_empty() { None } else { Some(s.to_string()) }
})
.collect()
}
fn normalize_link_kv(v: &str, default_kind: &str) -> String {
if v.contains('=') { v.to_string() } else { format!("{default_kind}={v}") }
}
fn build_module(app_dir: &Path, target: &Path, link_flags: &str) -> Result<(), String> {
let mut cmd = Command::new("cargo");
cmd.current_dir(app_dir).args([
"rustc", "--lib", "--crate-type", "dylib",
"--features", "rosace/rsc-hot",
"--", "-Zshare-generics=off",
]);
for tok in link_flags.split_whitespace() {
cmd.arg(tok);
}
hot_env(&mut cmd, target);
let status = cmd
.status()
.map_err(|e| format!("cargo rustc failed to start: {e}"))?;
if !status.success() {
return Err("building the app module dylib failed".into());
}
Ok(())
}
fn build_host(app_dir: &Path, target: &Path) -> Result<PathBuf, String> {
let host_dir = target.join("host");
std::fs::create_dir_all(host_dir.join("src")).map_err(|e| e.to_string())?;
let rosace_dep = rosace_dep_for_host(app_dir)?;
let cargo_toml = format!(
"[package]\nname = \"rsc-dev-host\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
[workspace]\n\n[[bin]]\nname = \"rsc-dev-host\"\npath = \"src/main.rs\"\n\n\
[dependencies]\n{rosace_dep}\n"
);
std::fs::write(host_dir.join("Cargo.toml"), cargo_toml).map_err(|e| e.to_string())?;
std::fs::write(
host_dir.join("src").join("main.rs"),
"fn main() {\n \
let module = std::env::args().nth(1).expect(\"usage: rsc-dev-host <module.dylib>\");\n \
rosace::dev_host::run(\"ROSACE dev (hot)\", 960, 640, module.into());\n}\n",
)
.map_err(|e| e.to_string())?;
let mut cmd = Command::new("cargo");
cmd.current_dir(&host_dir).arg("build");
hot_env(&mut cmd, target); let status = cmd
.status()
.map_err(|e| format!("cargo build (host) failed to start: {e}"))?;
if !status.success() {
return Err("building the dev host failed".into());
}
Ok(target.join("debug").join("rsc-dev-host"))
}
fn dyld_search_path(target: &Path) -> Result<String, String> {
let std_dir = Command::new("rustc")
.args(["--print", "target-libdir"])
.output()
.map_err(|e| format!("rustc --print target-libdir: {e}"))?;
let std_dir = String::from_utf8_lossy(&std_dir.stdout).trim().to_string();
let dbg = target.join("debug");
Ok(format!(
"{}:{}:{std_dir}",
dbg.display(),
dbg.join("deps").display()
))
}
fn watch_and_rebuild(
app_dir: &Path,
target: &Path,
link_flags: &str,
child: &mut std::process::Child,
debounce: std::time::Duration,
) {
use rosace_hot_reload::FileWatcher;
use std::sync::mpsc::RecvTimeoutError;
let (watcher, rx) = FileWatcher::new();
watcher.watch(app_dir.join("src"));
let _watcher = watcher; println!(
" [hot-reload] watching src/ — edit any screen and save (Ctrl-C to stop; debounce {}ms)\n",
debounce.as_millis()
);
loop {
let first = loop {
match rx.recv_timeout(std::time::Duration::from_millis(300)) {
Ok(ev) => break ev,
Err(RecvTimeoutError::Timeout) => {
if app_closed(child) {
return;
}
}
Err(RecvTimeoutError::Disconnected) => return, }
};
if app_closed(child) {
return;
}
let mut touched = file_name(&first.path);
let mut burst = 1usize;
loop {
match rx.recv_timeout(debounce) {
Ok(ev) => {
burst += 1;
touched = file_name(&ev.path);
}
Err(RecvTimeoutError::Timeout) => break, Err(RecvTimeoutError::Disconnected) => return, }
}
if app_closed(child) {
return;
}
if burst > 1 {
println!(" [hot-reload] {burst} edits (…{touched}) → rebuilding module…");
} else {
println!(" [hot-reload] {touched} changed → rebuilding module…");
}
match build_module(app_dir, target, link_flags) {
Ok(()) => println!(" [hot-reload] rebuilt — host will swap it live"),
Err(e) => eprintln!(" [hot-reload] rebuild failed, kept previous version: {e}"),
}
}
}
fn app_closed(child: &mut std::process::Child) -> bool {
match child.try_wait() {
Ok(Some(status)) => {
report_app_exit(status);
true
}
_ => false,
}
}
fn report_app_exit(status: std::process::ExitStatus) {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
let name = match sig {
11 => "SIGSEGV (segfault — invalid memory access)",
6 => "SIGABRT (abort — e.g. an unwrap/assert or double-panic)",
10 => "SIGBUS (bad memory access)",
4 => "SIGILL (illegal instruction)",
9 => "SIGKILL (killed)",
15 => "SIGTERM (terminated)",
2 => "SIGINT (interrupted)",
_ => "signal",
};
eprintln!(" 💥 app CRASHED — killed by {name} [{sig}]. Stopping dev server.");
eprintln!(" (see any ROSACE panic/crash report above for the cause)");
return;
}
}
match status.code() {
Some(0) | None => println!(" App closed. Stopping dev server."),
Some(code) => eprintln!(" ⚠️ app exited with code {code}. Stopping dev server."),
}
}
fn file_name(p: &Path) -> String {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}