#[cfg(unix)]
use std::os::unix::process::ExitStatusExt as _;
#[cfg(windows)]
use std::os::windows::process::ExitStatusExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use notify::{Config, PollWatcher, RecursiveMode, Watcher as _};
use sha2::Digest as _;
const DEVTOOLS_PORT: u16 = 9229;
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const STARTUP_IGNORE: Duration = Duration::from_secs(30);
const RELOAD_WAIT_MAX: Duration = Duration::from_secs(3);
const RELOAD_POLL_INTERVAL: Duration = Duration::from_millis(200);
const RS_DEBOUNCE: Duration = Duration::from_millis(500);
const HTML_DEBOUNCE: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DevArgs {
pub package: Option<String>,
pub extra_cargo_args: Vec<String>,
pub verbose: bool,
pub clean: bool,
pub preserve_state: bool,
}
pub fn parse_dev_command(raw: &[String]) -> DevArgs {
let mut out = DevArgs::default();
for (i, arg) in raw.iter().enumerate() {
if arg == "--verbose" {
out.verbose = true;
} else if arg == "--clean" {
out.clean = true;
} else if arg == "--preserve-state" {
out.preserve_state = true;
} else if i == 0 && !arg.starts_with('-') {
out.package = Some(arg.clone());
} else {
out.extra_cargo_args.push(arg.clone());
}
}
out
}
pub fn resolve_package_root(package: Option<&str>, cwd: &Path) -> PathBuf {
let Some(name) = package else {
return cwd.to_path_buf();
};
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(cwd)
.output();
let Ok(output) = output else {
eprintln!("light dev: `cargo metadata` не запустился — слежу за cwd");
return cwd.to_path_buf();
};
if !output.status.success() {
eprintln!(
"light dev: `cargo metadata` завершился с ошибкой — слежу за cwd: {}",
String::from_utf8_lossy(&output.stderr)
);
return cwd.to_path_buf();
}
let Ok(meta) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
eprintln!("light dev: не удалось разобрать вывод `cargo metadata` — слежу за cwd");
return cwd.to_path_buf();
};
let Some(packages) = meta.get("packages").and_then(|p| p.as_array()) else {
eprintln!("light dev: в выводе `cargo metadata` нет полей `packages` — слежу за cwd");
return cwd.to_path_buf();
};
let found = packages
.iter()
.find(|pkg| pkg.get("name").and_then(|n| n.as_str()) == Some(name));
match found
.and_then(|pkg| pkg.get("manifest_path"))
.and_then(|m| m.as_str())
{
Some(manifest_path) => PathBuf::from(manifest_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf()),
None => {
eprintln!("light dev: пакет `{name}` не найден в воркспейсе — слежу за cwd");
cwd.to_path_buf()
}
}
}
pub fn resolve_workspace_root(cwd: &Path) -> Option<PathBuf> {
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let meta: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
meta.get("workspace_root")
.and_then(|r| r.as_str())
.map(PathBuf::from)
}
pub fn detect_package_from_cwd(cwd: &Path) -> Option<String> {
let mut dir = cwd.to_path_buf();
loop {
let manifest = dir.join("Cargo.toml");
if manifest.is_file()
&& let Ok(text) = std::fs::read_to_string(&manifest)
&& let Some(name) = parse_package_name(&text)
{
return Some(name);
}
if !dir.pop() {
break;
}
}
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let meta: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let packages = meta.get("packages").and_then(|p| p.as_array())?;
if packages.len() == 1 {
return packages[0]
.get("name")
.and_then(|n| n.as_str())
.map(String::from);
}
let cwd_str = cwd.to_string_lossy().replace('\\', "/");
let mut best: Option<(usize, String)> = None;
for pkg in packages {
let manifest = pkg.get("manifest_path").and_then(|m| m.as_str())?;
let manifest_norm = manifest.replace('\\', "/");
let common = common_path_len(&cwd_str, &manifest_norm);
let name = pkg
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
if best.as_ref().is_none_or(|(d, _)| *d <= common) {
best = Some((common, name));
}
}
best.map(|(_, name)| name).filter(|n| !n.is_empty())
}
fn parse_package_name(toml_text: &str) -> Option<String> {
let mut in_package = false;
for line in toml_text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
if !in_package {
continue;
}
}
if in_package && trimmed.starts_with("name") {
let rest = trimmed.split_once('=')?.1.trim();
let rest = rest.strip_prefix('"')?.strip_suffix('"')?;
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
None
}
fn common_path_len(a: &str, b: &str) -> usize {
let a_parts: Vec<&str> = a.split('/').collect();
let b_parts: Vec<&str> = b.split('/').collect();
a_parts
.iter()
.zip(b_parts.iter())
.take_while(|(x, y)| x == y)
.count()
}
pub fn watch_paths(root: &std::path::Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for dir in ["src", "templates"] {
let p = root.join(dir);
if p.is_dir() {
paths.push(p);
}
}
let manifest = root.join("Cargo.toml");
if manifest.exists() {
paths.push(manifest);
}
paths
}
pub fn state_file_path(root: &std::path::Path) -> PathBuf {
match std::env::var_os("LIGHT_STATE_FILE") {
Some(var) if !var.is_empty() => PathBuf::from(var),
_ => root.join(".light").join("state.json"),
}
}
fn apply_state_env(cmd: &mut std::process::Command, root: &std::path::Path) {
let path = state_file_path(root);
eprintln!("light dev: state file for child: {:?}", path);
cmd.env("LIGHT_STATE_FILE", &path);
}
fn apply_window_env(cmd: &mut std::process::Command, root: &std::path::Path) {
let path = window_file_path(root);
eprintln!("light dev: window file for child: {:?}", path);
cmd.env("LIGHT_WINDOW_FILE", &path);
}
pub fn window_file_path(root: &std::path::Path) -> PathBuf {
match std::env::var_os("LIGHT_WINDOW_FILE") {
Some(var) if !var.is_empty() => PathBuf::from(var),
_ => root.join(".light").join("window.json"),
}
}
pub fn request_save_state(port: u16) -> std::io::Result<()> {
use std::io::Write;
use std::net::TcpStream;
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
stream.write_all(b"SAVE_STATE\n")?;
Ok(())
}
pub fn request_reload_template(port: u16) -> std::io::Result<()> {
use std::io::Write;
use std::net::TcpStream;
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
stream.write_all(b"RELOAD_TEMPLATE\n")?;
Ok(())
}
fn request_save_all(port: u16) {
use std::io::BufRead;
use std::net::TcpStream;
use std::time::Duration;
let result = (|| -> Result<(), String> {
let stream = TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.map_err(|e| e.to_string())?;
stream
.set_write_timeout(Some(Duration::from_millis(500)))
.map_err(|e| e.to_string())?;
{
use std::io::Write as _;
let mut w = &stream;
w.write_all(b"SAVE_ALL\n").map_err(|e| e.to_string())?;
}
let mut reader = std::io::BufReader::new(&stream);
let mut line = String::new();
let bytes_read = reader.read_line(&mut line).map_err(|e| e.to_string())?;
if bytes_read == 0 {
return Err("connection closed before response".to_owned());
}
let reply = line.trim();
if reply != "ok" {
return Err(format!("unexpected reply: {reply}"));
}
Ok(())
})();
match result {
Ok(()) => {
eprintln!("[SAVE_ALL] sent by supervisor, replied ok");
}
Err(e) => {
eprintln!(
"light dev: SAVE_ALL timeout/error: {e} \
(falling back to TerminateProcess without graceful save)"
);
}
}
}
fn is_port_open(port: u16) -> bool {
use std::net::TcpStream;
TcpStream::connect(("127.0.0.1", port)).is_ok()
}
const FILE_HASH_RETRY_INTERVAL: Duration = Duration::from_millis(50);
const FILE_HASH_MAX_ATTEMPTS: u32 = 3;
pub fn file_hash(path: &Path, verbose: bool) -> Option<u64> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut attempts = 0u32;
let buf = loop {
match std::fs::read(path) {
Ok(b) => break Some(b),
Err(e) => {
attempts += 1;
if attempts >= FILE_HASH_MAX_ATTEMPTS {
break None;
}
if verbose {
eprintln!(
"light dev: hash({path:?}) read attempt {attempts} failed: {e} \
(повторю через {:?}, всего до {FILE_HASH_MAX_ATTEMPTS:?} попыток)",
FILE_HASH_RETRY_INTERVAL
);
}
thread::sleep(FILE_HASH_RETRY_INTERVAL);
}
}
};
let result: Option<u64> = buf.map(|b| {
let mut hasher = DefaultHasher::new();
b.hash(&mut hasher);
hasher.finish()
});
if verbose {
eprintln!("light dev: hash({:?}) = {:?}", path, result);
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Html,
Source,
}
pub fn classify_change(path: &std::path::Path) -> Option<ChangeKind> {
let file_name = path.file_name()?.to_string_lossy();
if file_name == "Cargo.toml" {
return Some(ChangeKind::Source);
}
match path.extension()?.to_string_lossy().as_ref() {
"html" => Some(ChangeKind::Html),
"rs" => Some(ChangeKind::Source),
_ => None,
}
}
#[derive(Debug, Default)]
pub struct WatchState {
pub is_starting: bool,
pub pending_html: bool,
pub pending_source: bool,
}
impl WatchState {
pub fn starting() -> Self {
Self {
is_starting: true,
pending_html: false,
pending_source: false,
}
}
pub fn has_pending(&self) -> bool {
self.pending_html || self.pending_source
}
pub fn clear_pending(&mut self) {
self.pending_html = false;
self.pending_source = false;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchAction {
ReloadHtml,
Restart,
ApplyPending,
None,
}
pub fn handle_watch_event(state: &mut WatchState, kind: Option<ChangeKind>) -> WatchAction {
let Some(kind) = kind else {
return WatchAction::None;
};
if state.has_pending() && !state.is_starting {
state.clear_pending();
return WatchAction::ApplyPending;
}
if state.is_starting {
match kind {
ChangeKind::Html => state.pending_html = true,
ChangeKind::Source => state.pending_source = true,
}
WatchAction::None
} else {
match kind {
ChangeKind::Html => WatchAction::ReloadHtml,
ChangeKind::Source => WatchAction::Restart,
}
}
}
pub fn resolve_startup_completion(state: &WatchState) -> Option<ChangeKind> {
if state.pending_source {
Some(ChangeKind::Source)
} else if state.pending_html {
Some(ChangeKind::Html)
} else {
None
}
}
pub fn wait_port_available(port: u16, max: Duration, interval: Duration) -> bool {
let start = Instant::now();
loop {
if is_port_open(port) {
return true;
}
if start.elapsed() >= max {
return false;
}
thread::sleep(interval);
}
}
pub fn request_reload_template_with_retry(port: u16) -> std::result::Result<(), std::io::Error> {
if let Ok(()) = request_reload_template(port) {
return Ok(());
}
eprintln!(
"light dev: RELOAD_TEMPLATE не удался (app недоступен?) — \
жду до {:?}, чтобы повторить (не запуская пересборку)…",
RELOAD_WAIT_MAX
);
if wait_port_available(port, RELOAD_WAIT_MAX, RELOAD_POLL_INTERVAL) {
eprintln!("light dev: порт DevTools снова доступен — повторяю RELOAD_TEMPLATE");
request_reload_template(port)
} else {
eprintln!(
"light dev: порт DevTools {} так и не открылся за {:?} — \
HTML-чнейдж будет потерян, до следующего изменения",
port, RELOAD_WAIT_MAX
);
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"port unavailable after reload wait",
))
}
}
pub fn kill_child(child: &mut Child) {
if let Err(e) = child.kill() {
eprintln!("light dev: не удалось завершить дочерний процесс: {e}");
}
let _ = child.wait();
thread::sleep(Duration::from_millis(500));
}
pub fn bin_name(package: &str) -> String {
package.to_string()
}
pub fn fresh_bin_path(build_target: &Path, package: &str) -> PathBuf {
let mut p = build_target.join("debug").join(bin_name(package));
#[cfg(windows)]
{
p.set_extension("exe");
}
p
}
pub fn run_exe_path(run_dir: &Path, bin: &str) -> PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let mut p = run_dir.join(format!("{bin}-{ts}"));
#[cfg(windows)]
{
p.set_extension("exe");
}
p
}
pub fn cleanup_run_dir(run_dir: &Path, keep: &Path) {
if let Ok(entries) = std::fs::read_dir(run_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path == keep {
continue;
}
if path.extension().and_then(|e| e.to_str()) == Some("exe") {
let _ = std::fs::remove_file(&path);
}
}
}
}
pub fn copy_fresh_to_run(
build_target: &Path,
run_dir: &Path,
bin: &str,
) -> std::result::Result<PathBuf, std::io::Error> {
std::fs::create_dir_all(run_dir)?;
let fresh = fresh_bin_path(build_target, bin);
let dest = run_exe_path(run_dir, bin);
std::fs::copy(&fresh, &dest)?;
Ok(dest)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebuildOutcome {
Success,
Failed(i32),
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RebuildStateMachine {
pub in_progress: bool,
pub started_at: Option<Instant>,
}
impl RebuildStateMachine {
pub fn start(&mut self) {
self.in_progress = true;
self.started_at = Some(Instant::now());
}
pub fn poll_outcome(&mut self, exit_code: i32) -> RebuildOutcome {
self.in_progress = false;
if exit_code == 0 {
RebuildOutcome::Success
} else {
eprintln!(
"light dev: rebuild FAILED (exit {exit_code}), previous version kept running"
);
RebuildOutcome::Failed(exit_code)
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
pub fn build_info_path(target_root: &Path) -> PathBuf {
target_root.join(".light").join("build-info.json")
}
pub fn save_build_info(path: &Path, version: &str, lock_hash: &str) {
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("light dev: не удалось создать каталог для build-info: {e}");
return;
}
let v = serde_json::json!({
"light-fw-core-version": version,
"cargo-lock-hash": lock_hash,
});
match serde_json::to_string_pretty(&v) {
Ok(s) => {
if let Err(e) = std::fs::write(path, s) {
eprintln!("light dev: не удалось записать build-info.json: {e}");
}
}
Err(e) => eprintln!("light dev: не удалось сериализовать build-info: {e}"),
}
}
pub fn read_fw_core_version_from_lock(lock_path: &Path) -> Option<String> {
let data = std::fs::read_to_string(lock_path).ok()?;
parse_fw_core_version_from_lock_text(&data)
}
fn parse_fw_core_version_from_lock_text(text: &str) -> Option<String> {
let start = text.find("name = \"light-fw-core\"")?;
let after = &text[start..];
let end = after.find("\n\n").unwrap_or(after.len());
let block = &after[..end];
let ver_start = block.find("version = \"")? + "version = \"".len();
let rest = &block[ver_start..];
let ver_end = rest.find('"')?;
Some(rest[..ver_end].to_string())
}
pub fn read_fw_core_version_from_workspace_toml(toml_path: &Path) -> Option<String> {
let data = std::fs::read_to_string(toml_path).ok()?;
parse_workspace_package_version(&data)
}
fn parse_workspace_package_version(text: &str) -> Option<String> {
let doc: toml_edit::DocumentMut = text.parse().ok()?;
let ver = doc
.get("workspace")?
.get("package")?
.get("version")?
.as_str()?
.to_string();
Some(ver)
}
pub fn clean_dev_cache(target_root: &Path, package: Option<&str>) -> std::vec::Vec<PathBuf> {
let mut removed = Vec::new();
let hot_build = target_root.join("target").join("hot-build");
remove_path_quietly(&hot_build, &mut removed);
let hot_run = target_root.join("target").join("hot-run");
remove_path_quietly(&hot_run, &mut removed);
let state = target_root.join(".light").join("state.json");
remove_path_quietly(&state, &mut removed);
let build_info = build_info_path(target_root);
remove_path_quietly(&build_info, &mut removed);
if let Some(pkg) = package {
let deps = target_root.join("target").join("debug").join("deps");
if let Ok(entries) = std::fs::read_dir(&deps) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(pkg) {
remove_path_quietly(&entry.path(), &mut removed);
}
}
}
}
removed
}
fn remove_path_quietly(path: &Path, removed: &mut Vec<PathBuf>) {
if !path.exists() {
return;
}
let kind = std::fs::symlink_metadata(path).map(|m| m.file_type()).ok();
if matches!(kind, Some(t) if t.is_dir()) {
if std::fs::remove_dir_all(path).is_ok() {
removed.push(path.to_path_buf());
}
} else if std::fs::remove_file(path).is_ok() {
removed.push(path.to_path_buf());
}
}
pub fn prune_stale_run_exes(run_dir: &Path, keep: &Path) -> usize {
let now = SystemTime::now();
let today: u128 = now
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() / 86_400)
.unwrap_or(0) as u128;
let mut pruned = 0usize;
let Ok(entries) = std::fs::read_dir(run_dir) else {
return 0;
};
for entry in entries.flatten() {
let path = entry.path();
if path == keep {
continue;
}
let is_exe = path.extension().and_then(|e| e.to_str()) == Some("exe");
if !is_exe {
continue;
}
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let mtime = meta.modified().ok();
let old_by_age = mtime
.and_then(|t| now.duration_since(t).ok())
.map(|d| d >= STALE_RUN_AGE)
.unwrap_or(false);
let stale_by_date = {
let file = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
let stem = file.strip_suffix(".exe").unwrap_or(file);
match stem.rfind('-') {
None => false,
Some(dash_pos) => {
let ts_str = &stem[dash_pos + 1..];
ts_str
.parse::<u128>()
.map(|ts| (ts / 86_400_000) != today)
.unwrap_or(false)
}
}
};
if (old_by_age || stale_by_date) && std::fs::remove_file(&path).is_ok() {
pruned += 1;
}
}
pruned
}
const STALE_RUN_AGE: Duration = Duration::from_secs(3600);
pub fn check_cli_version_mismatch(
installed: &str,
toml_path: &Path,
lock_path: &Path,
) -> Option<(String, String)> {
let project = read_fw_core_version_from_workspace_toml(toml_path)
.or_else(|| read_fw_core_version_from_lock(lock_path))?;
if project == installed {
None
} else {
Some((installed.to_string(), project))
}
}
pub fn run(args: &DevArgs) -> std::result::Result<i32, std::io::Error> {
let cwd = std::env::current_dir()?;
let resolved = args
.package
.clone()
.or_else(|| detect_package_from_cwd(&cwd));
let Some(pkg_name) = resolved else {
eprintln!(
"light dev: cannot determine package; use `light dev <name>` or run inside crate directory"
);
return Ok(1);
};
let package_root = resolve_package_root(args.package.as_deref(), &cwd);
let target_root = resolve_workspace_root(&cwd).unwrap_or_else(|| {
eprintln!(
"light dev: `cargo metadata` недоступен — target_dir из package_root {:?}",
package_root
);
package_root.clone()
});
eprintln!("light dev: workspace_root = {:?}", target_root);
let build_target = target_root.join("target").join("hot-build");
let run_dir = target_root.join("target").join("hot-run");
let bin = bin_name(&pkg_name);
eprintln!(
"light dev: build_target = {:?}, run_dir = {:?}",
build_target, run_dir
);
if args.clean {
let removed = clean_dev_cache(&target_root, Some(pkg_name.as_str()));
eprintln!("light dev: cleaned cache (hot-build, hot-run, state, deps, build-info)");
let _ = removed.len();
}
let lock_path = target_root.join("Cargo.lock");
let toml_path = target_root.join("Cargo.toml");
let info_path = build_info_path(&target_root);
let current_version = read_fw_core_version_from_workspace_toml(&toml_path)
.or_else(|| read_fw_core_version_from_lock(&lock_path));
if let Some(current_version) = ¤t_version {
let saved = std::fs::read_to_string(&info_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| {
v.get("light-fw-core-version")
.and_then(|x| x.as_str())
.map(String::from)
});
if saved.as_deref() != Some(current_version.as_str()) {
let hot_build = target_root.join("target").join("hot-build");
if hot_build.exists() {
if std::fs::remove_dir_all(&hot_build).is_ok() {
eprintln!(
"light dev: framework version changed ({} → {}) → hot-build cache invalidated",
saved.as_deref().unwrap_or("?"),
current_version
);
} else {
eprintln!("light dev: не удалось удалить target/hot-build после смены версии");
}
} else {
eprintln!(
"light dev: framework version changed ({} → {}) → hot-build cache invalidated",
saved.as_deref().unwrap_or("?"),
current_version
);
}
let lock_hash = std::fs::read_to_string(&lock_path)
.ok()
.map(|s| hex::encode(sha2::Sha256::digest(s.as_bytes())))
.unwrap_or_default();
save_build_info(&info_path, current_version, &lock_hash);
}
} else {
eprintln!(
"light dev: light-fw-core не найден в Cargo.toml/Cargo.lock — skip auto-invalidation"
);
}
let pruned = prune_stale_run_exes(&run_dir, Path::new("/nonexistent"));
if pruned > 0 {
eprintln!("light dev: pruned {pruned} stale executables from hot-run");
}
if let Some((installed, project)) =
check_cli_version_mismatch(env!("CARGO_PKG_VERSION"), &toml_path, &lock_path)
{
eprintln!(
"⚠ light dev: installed CLI version {installed} differs from project {project}. Run `cargo install --path crates/light-cli`"
);
}
{
eprintln!(
"light dev: warm-up build (cargo build --target-dir {:?})…",
build_target
);
let mut cmd = Command::new("cargo");
cmd.arg("build");
cmd.args(["-p", &pkg_name, "--features", "light/dev-mode"]);
cmd.args(["--target-dir", &build_target.to_string_lossy()]);
cmd.args(&args.extra_cargo_args);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd.status();
match status {
Ok(s) if s.success() => eprintln!("light dev: warm-up build succeeded"),
Ok(s) => {
eprintln!(
"light dev: warm-up build FAILED (exit {:?}) — exit 1",
s.code()
);
return Ok(1);
}
Err(e) => {
eprintln!("light dev: не удалось запустить `cargo build`: {e} — exit 1");
return Ok(1);
}
}
let fresh_bin = fresh_bin_path(&build_target, &bin);
if std::fs::metadata(&fresh_bin).is_err() {
eprintln!(
"light dev: BUG — build succeeded but binary not at {:?}. \
Search in {:?}...",
fresh_bin,
build_target.join("debug")
);
return Ok(1);
}
}
let first_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
Ok(p) => p,
Err(e) => {
eprintln!("light dev: не удалось скопировать бинарник: {e} — exit 1");
return Ok(1);
}
};
eprintln!(
"light dev: spawning {:?} (build {:?})",
first_run_exe,
fresh_bin_path(&build_target, &bin)
);
for stale in [
state_file_path(&package_root),
window_file_path(&package_root),
] {
if std::fs::remove_file(&stale).is_ok() {
eprintln!("light dev: cold start — cleared stale {:?}", stale);
}
}
let mut child_cmd = Command::new(&first_run_exe);
child_cmd.stdin(Stdio::inherit());
child_cmd.stdout(Stdio::inherit());
child_cmd.stderr(Stdio::inherit());
apply_state_env(&mut child_cmd, &package_root);
apply_window_env(&mut child_cmd, &package_root);
let child = match child_cmd.spawn() {
Ok(c) => c,
Err(e) => {
eprintln!(
"light dev: не удалось запустить {:?}: {e} — exit 1",
first_run_exe
);
return Ok(1);
}
};
let child_arc = Arc::new(Mutex::new(child));
let rebuild_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
let mut sm = RebuildStateMachine::default();
let (restart_tx, restart_rx) = mpsc::channel::<(Instant, ChangeKind)>();
let done_flag = Arc::new(AtomicBool::new(false));
let (state_tx, state_rx) = mpsc::channel::<WatchState>();
let _ = state_tx.send(WatchState::starting());
let watch_paths_copy = watch_paths(&package_root);
eprintln!("light dev: watching {:?}", watch_paths_copy);
let verbose = args.verbose;
let done_flag_watch = done_flag.clone();
let watcher = thread::spawn(move || {
if watch_paths_copy.is_empty() {
eprintln!("light dev: нет путей для наблюдения (нет src/, templates/, Cargo.toml)");
return;
}
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let config = Config::default().with_poll_interval(POLL_INTERVAL);
let mut watcher = match PollWatcher::new(
move |res| {
let _ = tx.send(res);
},
config,
) {
Ok(w) => w,
Err(e) => {
eprintln!("light dev: не удалось создать файловый наблюдатель: {e}");
return;
}
};
for path in &watch_paths_copy {
if let Err(e) = watcher.watch(path, RecursiveMode::NonRecursive) {
eprintln!("light dev: не удалось следить за {}: {e}", path.display());
}
}
let mut state = match state_rx.recv() {
Ok(s) => s,
Err(_) => return,
};
let mut start_at = Instant::now();
let mut last_html: Option<Instant> = None;
let mut last_rs: Option<Instant> = None;
let mut last_hashes: std::collections::HashMap<PathBuf, u64> =
std::collections::HashMap::new();
let mut pending_debounce: std::collections::VecDeque<PathBuf> =
std::collections::VecDeque::new();
let apply_pending = |state: &mut WatchState,
tx: &mpsc::Sender<(Instant, ChangeKind)>|
-> Option<ChangeKind> {
let kind = resolve_startup_completion(state)?;
let _ = tx.send((Instant::now(), kind));
state.clear_pending();
Some(kind)
};
loop {
if done_flag_watch.load(Ordering::Relaxed) {
break;
}
if let Ok(new_state) = state_rx.try_recv() {
state = new_state;
start_at = Instant::now();
last_html = None;
last_rs = None;
pending_debounce.clear();
continue;
}
if state.is_starting {
let port_open = is_port_open(DEVTOOLS_PORT);
let deadline_reached = start_at.elapsed() >= STARTUP_IGNORE;
if port_open || deadline_reached {
let reason = if port_open {
"порт DevTools открыт"
} else {
&format!("дедлайн {:?} истёк", STARTUP_IGNORE)
};
eprintln!(
"light dev: окно игнорирования снято ({reason}), \
очередь pending (HTML={}, Source={})",
state.pending_html, state.pending_source
);
if let Some(kind) = apply_pending(&mut state, &restart_tx) {
match kind {
ChangeKind::Html => last_html = Some(Instant::now()),
ChangeKind::Source => last_rs = Some(Instant::now()),
}
}
state.is_starting = false;
}
continue;
}
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(Ok(event)) => {
for path in &event.paths {
if !watch_paths_copy.iter().any(|w| path.starts_with(w)) {
continue;
}
let kind_log = classify_change(path);
if verbose {
eprintln!(
"light dev: notify event path={path:?} kind={kind_log:?} \
(queued for debounce)"
);
}
if !pending_debounce.contains(path) {
pending_debounce.push_back(path.clone());
}
}
if event
.paths
.iter()
.any(|p| watch_paths_copy.iter().any(|w| p.starts_with(w)))
{
let has_html = event.paths.iter().any(|p| {
watch_paths_copy.iter().any(|w| p.starts_with(w))
&& matches!(classify_change(p), Some(ChangeKind::Html))
});
let has_src = event.paths.iter().any(|p| {
watch_paths_copy.iter().any(|w| p.starts_with(w))
&& matches!(classify_change(p), Some(ChangeKind::Source))
});
if has_html {
last_html = Some(Instant::now());
}
if has_src {
last_rs = Some(Instant::now());
}
}
}
Ok(Err(e)) => {
eprintln!("light dev: ошибка наблюдателя: {e}");
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if !state.is_starting && !state.has_pending() {
let html_fired =
matches!(last_html, Some(t) if t.elapsed() >= HTML_DEBOUNCE);
let rs_fired = matches!(last_rs, Some(t) if t.elapsed() >= RS_DEBOUNCE);
if html_fired || rs_fired {
if html_fired {
last_html = None;
}
if rs_fired {
last_rs = None;
}
if verbose {
eprintln!(
"light dev: debounce fired, {} paths pending",
pending_debounce.len()
);
}
let mut clean_events: std::collections::VecDeque<PathBuf> =
std::collections::VecDeque::new();
while let Some(path) = pending_debounce.pop_front() {
match file_hash(&path, verbose) {
Some(hash) => {
match last_hashes.get(&path) {
Some(&last) if last == hash => {
if verbose {
eprintln!(
"light dev: duplicate notify event for \
{path:?} (hash unchanged) — ignored"
);
}
}
_ => {
last_hashes.insert(path.clone(), hash);
clean_events.push_back(path);
}
}
}
None => {
if verbose {
eprintln!(
"light dev: file_hash returned None for \
{path:?} (treated as real change, no dedup)"
);
}
clean_events.push_back(path);
}
}
}
if verbose {
eprintln!(
"light dev: {} clean events after hash-dedup",
clean_events.len()
);
}
if let Some(first) = clean_events.pop_front() {
let kind = classify_change(&first);
eprintln!("light dev: dispatching {kind:?} change for {first:?}");
match handle_watch_event(&mut state, kind) {
WatchAction::None => {
}
WatchAction::ApplyPending => {
if let Some(akind) = apply_pending(&mut state, &restart_tx)
{
eprintln!(
"light dev: применяю pending (kind={akind:?})"
);
match akind {
ChangeKind::Html => {
last_html = Some(Instant::now());
}
ChangeKind::Source => {
last_rs = Some(Instant::now());
}
}
}
}
WatchAction::ReloadHtml => {
eprintln!(
"light dev: HTML change → RELOAD_TEMPLATE \
(без пересборки)"
);
let _ = restart_tx.send((Instant::now(), ChangeKind::Html));
last_html = Some(Instant::now());
}
WatchAction::Restart => {
eprintln!(
"light dev: Source change → RESTART \
(пересборка)"
);
let _ =
restart_tx.send((Instant::now(), ChangeKind::Source));
last_rs = Some(Instant::now());
}
}
} else {
if verbose {
eprintln!(
"light dev: все события отфильтрованы hash-дедупом — \
action не требуется"
);
}
}
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
});
loop {
enum Ev {
Restart(ChangeKind),
RebuildDone(i32),
Exited(std::process::ExitStatus),
}
let ev = loop {
if let Ok((_ts, kind)) = restart_rx.try_recv() {
break Ev::Restart(kind);
}
let rebuild_done = rebuild_arc.lock().ok().and_then(|mut g| match g.as_mut() {
Some(c) => c.try_wait().ok().flatten(),
None => None,
});
if let Some(status) = rebuild_done {
let code = status.code().unwrap_or(1);
if let Ok(mut g) = rebuild_arc.lock() {
*g = None;
}
break Ev::RebuildDone(code);
}
let child_exit = match child_arc.lock() {
Ok(mut g) => match g.try_wait() {
Ok(Some(s)) => Some(Ok(s)),
Ok(None) => None, Err(e) => Some(Err(e)),
},
Err(_) => None,
};
match child_exit {
Some(Ok(s)) => break Ev::Exited(s),
Some(Err(e)) => {
eprintln!("light dev: ошибка ожидания дочернего процесса: {e}");
break Ev::Exited(ExitStatus::from_raw(1));
}
None => {
if child_arc.lock().is_err() {
eprintln!("light dev: child_arc poisoned (пропуск опроса)");
}
}
}
thread::sleep(Duration::from_millis(50));
};
match ev {
Ev::Exited(status) => {
done_flag.store(true, Ordering::Relaxed);
let _ = watcher.join();
if let Ok(mut g) = rebuild_arc.lock()
&& let Some(mut r) = g.take()
{
let _ = r.kill();
let _ = r.wait();
}
let code = status.code().unwrap_or(1);
if code == 0 {
for stale in [
state_file_path(&package_root),
window_file_path(&package_root),
] {
if std::fs::remove_file(&stale).is_ok() {
eprintln!("light dev: removed {:?}", stale);
}
}
eprintln!("light dev: user close — state + geometry removed");
eprintln!(
"light dev: app exited cleanly (window closed by user or session end)"
);
} else {
eprintln!("light dev: app crashed (exit code {code})");
}
return Ok(code);
}
Ev::RebuildDone(exit_code) => {
let outcome = sm.poll_outcome(exit_code);
if let RebuildOutcome::Success = outcome {
let took = sm
.started_at
.map(|t| Instant::now().saturating_duration_since(t))
.unwrap_or_default();
eprintln!("light dev: rebuild succeeded (took {took:?}), swapping…");
if let Err(e) = request_save_state(DEVTOOLS_PORT) {
eprintln!(
"light dev: не удалось отправить SAVE_STATE перед swap: {e} \
(состояние может быть не сохранено)"
);
} else {
eprintln!(
"light dev: state saved to {:?}",
state_file_path(&package_root)
);
}
thread::sleep(Duration::from_millis(200));
let new_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
Ok(p) => p,
Err(e) => {
eprintln!(
"light dev: не удалось скопировать свежий бинарник: {e} \
(old остаётся)"
);
continue;
}
};
eprintln!("light dev: copied fresh build to {:?}", new_run_exe);
request_save_all(DEVTOOLS_PORT);
let old_pid = child_arc.lock().map(|g| g.id()).unwrap_or(0);
if let Ok(mut g) = child_arc.lock() {
kill_child(&mut g);
} else {
eprintln!("light dev: child_arc poisoned — не смогла убить old");
}
let mut new_cmd = Command::new(&new_run_exe);
new_cmd.stdin(Stdio::inherit());
new_cmd.stdout(Stdio::inherit());
new_cmd.stderr(Stdio::inherit());
apply_state_env(&mut new_cmd, &package_root);
apply_window_env(&mut new_cmd, &package_root);
new_cmd.env("LIGHT_SWAP", "1");
new_cmd.env("LIGHT_RESUME_FROM_SWAP", "1");
new_cmd.env("LIGHT_PRESERVE_STATE", "1");
if args.preserve_state {
eprintln!("light dev: .rs changed → state preserved (--preserve-state)");
} else {
eprintln!("light dev: .rs changed → state preserved (default)");
}
eprintln!("light dev: clean exit during swap — state.json kept");
match new_cmd.spawn() {
Ok(c) => {
let new_pid = c.id();
if let Ok(mut g) = child_arc.lock() {
*g = c;
} else {
eprintln!(
"light dev: child_arc poisoned — не записала новый process"
);
}
cleanup_run_dir(&run_dir, &new_run_exe);
let pruned = prune_stale_run_exes(&run_dir, &new_run_exe);
if pruned > 0 {
eprintln!(
"light dev: pruned {pruned} stale executables from hot-run"
);
}
let _ = state_tx.send(WatchState::starting());
eprintln!(
"light dev: spawning {:?} (build {:?})",
new_run_exe,
fresh_bin_path(&build_target, &bin)
);
eprintln!("light dev: swap done (pid {old_pid} -> {new_pid})");
}
Err(e) => {
eprintln!("light dev: не удалось запустить новое приложение: {e}");
done_flag.store(true, Ordering::Relaxed);
let _ = watcher.join();
return Ok(1);
}
}
}
}
Ev::Restart(kind) => match kind {
ChangeKind::Html => {
eprintln!("light dev: HTML change → RELOAD_TEMPLATE (без пересборки)…");
if let Err(e) = request_reload_template_with_retry(DEVTOOLS_PORT) {
eprintln!(
"light dev: RELOAD_TEMPLATE так и не был доставлен: {e} \
(HTML-чнейдж потерян до следующего изменения)"
);
} else {
eprintln!("light dev: RELOAD_TEMPLATE доставлен — продолжаю watch");
}
}
ChangeKind::Source => {
let had_in_flight = {
let old = rebuild_arc.lock().ok().and_then(|mut g| g.take());
old.map(|mut c| {
let _ = c.kill();
let _ = c.wait();
true
})
.unwrap_or(false)
};
if had_in_flight {
eprintln!("light dev: cancelled in-flight rebuild (new save)");
}
eprintln!("light dev: rebuild started (old app still running)");
sm.start();
let mut cmd = Command::new("cargo");
cmd.arg("build");
cmd.args(["-p", &pkg_name, "--features", "light/dev-mode"]);
cmd.args(["--target-dir", &build_target.to_string_lossy()]);
cmd.args(&args.extra_cargo_args);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
match cmd.spawn() {
Ok(c) => {
if let Ok(mut g) = rebuild_arc.lock() {
*g = Some(c);
} else {
eprintln!(
"light dev: rebuild_arc poisoned — не записала новый build"
);
}
}
Err(e) => {
eprintln!("light dev: не удалось запустить `cargo build`: {e}");
sm.poll_outcome(1);
}
}
}
},
}
}
}
#[cfg(test)]
mod tests {
use super::{
ChangeKind, RebuildOutcome, RebuildStateMachine, WatchAction, WatchState, bin_name,
build_info_path, classify_change, clean_dev_cache, cleanup_run_dir, copy_fresh_to_run,
file_hash, fresh_bin_path, handle_watch_event, parse_dev_command,
parse_workspace_package_version, prune_stale_run_exes, read_fw_core_version_from_lock,
read_fw_core_version_from_workspace_toml, resolve_package_root, resolve_startup_completion,
run_exe_path, save_build_info, wait_port_available,
};
use std::io::Write as _;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[test]
fn watch_event_irrelevant_does_nothing() {
let mut s = WatchState::starting();
assert_eq!(handle_watch_event(&mut s, None), WatchAction::None);
assert!(s.is_starting);
assert!(!s.pending_html);
assert!(!s.pending_source);
}
#[test]
fn html_during_start_pending_as_html() {
let mut s = WatchState::starting();
assert_eq!(
handle_watch_event(&mut s, Some(ChangeKind::Html)),
WatchAction::None
);
assert!(s.is_starting);
assert!(s.pending_html);
assert!(!s.pending_source);
assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Html));
}
#[test]
fn source_during_start_pending_as_source() {
let mut s = WatchState::starting();
assert_eq!(
handle_watch_event(&mut s, Some(ChangeKind::Source)),
WatchAction::None
);
assert!(s.is_starting);
assert!(!s.pending_html);
assert!(s.pending_source);
assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
}
#[test]
fn html_after_start_emits_reload() {
let mut s = WatchState {
is_starting: false,
pending_html: false,
pending_source: false,
};
assert_eq!(
handle_watch_event(&mut s, Some(ChangeKind::Html)),
WatchAction::ReloadHtml
);
assert!(!s.is_starting);
assert!(!s.pending_html);
assert!(!s.pending_source);
}
#[test]
fn source_after_start_emits_restart() {
let mut s = WatchState {
is_starting: false,
pending_html: false,
pending_source: false,
};
assert_eq!(
handle_watch_event(&mut s, Some(ChangeKind::Source)),
WatchAction::Restart
);
}
#[test]
fn startup_priority_source_over_html() {
let mut s = WatchState::starting();
let _ = handle_watch_event(&mut s, Some(ChangeKind::Html));
let _ = handle_watch_event(&mut s, Some(ChangeKind::Source));
assert!(s.pending_html && s.pending_source);
assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
}
#[test]
fn startup_no_pending_returns_none() {
let s = WatchState::starting();
assert_eq!(resolve_startup_completion(&s), None);
}
#[test]
fn event_with_stale_pending_returns_apply_pending() {
let mut s = WatchState {
is_starting: false,
pending_html: true,
pending_source: false,
};
assert_eq!(
handle_watch_event(&mut s, Some(ChangeKind::Source)),
WatchAction::ApplyPending
);
assert!(!s.pending_html && !s.pending_source);
}
#[test]
fn wait_port_available_becomes_open_within_max() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let start = Instant::now();
assert!(wait_port_available(
port,
Duration::from_millis(500),
Duration::from_millis(20)
));
assert!(
start.elapsed() < Duration::from_millis(500),
"wait_port_available вернул слишком поздно"
);
}
#[test]
fn wait_port_available_never_opens_times_out() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
drop(listener);
let result =
wait_port_available(port, Duration::from_millis(80), Duration::from_millis(10));
assert!(
!result,
"wait_port_available должен вернуть false для закрытого порта"
);
}
#[test]
fn package_root_without_package_is_cwd() {
let root = resolve_package_root(None, std::path::Path::new("/tmp/workspace"));
assert_eq!(root, std::path::PathBuf::from("/tmp/workspace"));
}
#[test]
fn parse_empty() {
let args = parse_dev_command(&[]);
assert!(args.package.is_none());
assert!(args.extra_cargo_args.is_empty());
}
#[test]
fn parse_package_only() {
let args = parse_dev_command(&["my-app".into()]);
assert_eq!(args.package.as_deref(), Some("my-app"));
assert!(args.extra_cargo_args.is_empty());
}
#[test]
fn parse_package_and_extra() {
let args = parse_dev_command(&[
"my-app".into(),
"--release".into(),
"--".into(),
"arg0".into(),
]);
assert_eq!(args.package.as_deref(), Some("my-app"));
assert_eq!(
args.extra_cargo_args,
vec!["--release".to_owned(), "--".to_owned(), "arg0".to_owned()]
);
}
#[test]
fn parse_multiple_positionals_first_is_package() {
let args = parse_dev_command(&["crate-a".into(), "crate-b".into(), "flag".into()]);
assert_eq!(args.package.as_deref(), Some("crate-a"));
assert_eq!(
args.extra_cargo_args,
vec!["crate-b".to_owned(), "flag".to_owned()]
);
}
#[test]
fn parse_flags_only_no_package() {
let args = parse_dev_command(&["--release".into(), "--quiet".into()]);
assert!(args.package.is_none());
assert_eq!(
args.extra_cargo_args,
vec!["--release".to_owned(), "--quiet".to_owned()]
);
}
#[test]
fn parse_verbose_flag() {
let args = parse_dev_command(&["my-app".into(), "--verbose".into(), "--release".into()]);
assert!(args.verbose);
assert_eq!(args.package.as_deref(), Some("my-app"));
assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
}
#[test]
fn parse_verbose_only() {
let args = parse_dev_command(&["--verbose".into()]);
assert!(args.verbose);
assert!(args.package.is_none());
assert!(args.extra_cargo_args.is_empty());
}
#[test]
fn parse_preserve_state_flag() {
let args = parse_dev_command(&[
"my-app".into(),
"--preserve-state".into(),
"--release".into(),
]);
assert!(args.preserve_state);
assert_eq!(args.package.as_deref(), Some("my-app"));
assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
}
#[test]
fn parse_preserve_state_default_false() {
let args = parse_dev_command(&[]);
assert!(!args.preserve_state);
}
#[test]
fn classify_html_file() {
assert_eq!(
classify_change(Path::new("templates/app.html")),
Some(ChangeKind::Html)
);
}
#[test]
fn classify_rs_file() {
assert_eq!(
classify_change(Path::new("src/main.rs")),
Some(ChangeKind::Source)
);
}
#[test]
fn classify_cargo_toml() {
assert_eq!(
classify_change(Path::new("Cargo.toml")),
Some(ChangeKind::Source)
);
}
#[test]
fn classify_unknown_extension_returns_none() {
assert_eq!(classify_change(Path::new("assets/logo.png")), None);
assert_eq!(classify_change(Path::new("data.json")), None);
}
#[test]
fn classify_no_extension_returns_none() {
assert_eq!(classify_change(Path::new("README")), None);
}
#[test]
fn file_hash_same_content_same_hash() {
let dir = std::env::temp_dir().join("light_dev_test_file_hash");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("sample.html");
let _ = std::fs::remove_file(&path);
let mut f = std::fs::File::create(&path).expect("create file");
f.write_all(b"a").expect("write 'a'");
f.sync_all().expect("sync");
drop(f);
let hash1 = file_hash(&path, false).expect("hash1 must be Some");
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("reopen");
f.set_len(0).expect("truncate");
f.write_all(b"a").expect("rewrite 'a'");
f.sync_all().expect("sync");
drop(f);
let hash2 = file_hash(&path, false).expect("hash2 must be Some");
assert_eq!(
hash1, hash2,
"одинаковое содержимое должно давать одинаковый хэш"
);
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("reopen");
f.set_len(0).expect("truncate");
f.write_all(b"b").expect("write 'b'");
f.sync_all().expect("sync");
drop(f);
let hash3 = file_hash(&path, false).expect("hash3 must be Some");
assert_ne!(hash1, hash3, "разное содержимое должно дать разный хэш");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn file_hash_missing_file_returns_none() {
let dir = std::env::temp_dir().join("light_dev_test_file_hash_missing");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("does_not_exist.html");
let _ = std::fs::remove_file(&path);
assert_eq!(file_hash(&path, false), None);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn dedup_after_debounce() {
let dir = std::env::temp_dir().join("light_dev_test_dedup_after_debounce");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("sample.html");
let _ = std::fs::remove_file(&path);
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(b"a").expect("write 'a'");
f.sync_all().expect("sync");
drop(f);
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("reopen-append");
f.write_all(b"b").expect("append 'b'");
f.sync_all().expect("sync");
drop(f);
let final_hash = file_hash(&path, false).expect("hash of 'ab'");
let dup_hash = file_hash(&path, false).expect("hash on duplicate event");
assert_eq!(
final_hash, dup_hash,
"повторное событие того же содержимого должно дать одинаковый хэш \
(и быть отброшено hash-фильтром)"
);
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("rewrite");
f.set_len(0).expect("truncate");
f.write_all(b"xyz").expect("write 'xyz'");
f.sync_all().expect("sync");
drop(f);
let new_hash = file_hash(&path, false).expect("hash of 'xyz'");
assert_ne!(
final_hash, new_hash,
"новое содержимое даёт новый хэш → пройдёт hash-фильтр"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn file_hash_retries_on_permission_denied() {
let dir = std::env::temp_dir().join("light_dev_test_file_hash_retry");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("missing.html");
let _ = std::fs::remove_file(&path);
let start = Instant::now();
let result = file_hash(&path, false);
let elapsed = start.elapsed();
assert_eq!(
result, None,
"несуществующий файл → None после всех ретраев"
);
assert!(
elapsed >= Duration::from_millis(100),
"ожидаю минимум 2 × 50 мс ретраев, получил {elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(1),
"ретраи должны завершиться быстро (< 1 с), получили {elapsed:?}"
);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn rebuild_started_flag() {
let mut sm = RebuildStateMachine::default();
assert!(!sm.in_progress, "начальное состояние: in_progress=false");
assert!(
sm.started_at.is_none(),
"начальное состояние: started_at=None"
);
sm.start();
assert!(sm.in_progress, "после start(): in_progress=true");
assert!(
sm.started_at.is_some(),
"после start(): started_at=Some(Instant)"
);
}
#[test]
fn rebuild_success_triggers_swap() {
let mut sm = RebuildStateMachine::default();
sm.start();
let Some(started) = sm.started_at else {
panic!("expected started_at to be set after start()");
};
let outcome = sm.poll_outcome(0);
assert_eq!(outcome, RebuildOutcome::Success);
assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
assert_eq!(
sm.started_at,
Some(started),
"started_at сохраняется для лога"
);
}
#[test]
fn rebuild_failure_keeps_old() {
let mut sm = RebuildStateMachine::default();
sm.start();
let Some(started) = sm.started_at else {
panic!("expected started_at to be set after start()");
};
let outcome = sm.poll_outcome(2);
assert_eq!(outcome, RebuildOutcome::Failed(2));
assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
assert_eq!(sm.started_at, Some(started), "started_at сохраняется");
}
#[test]
fn new_save_cancels_in_flight() {
let mut sm = RebuildStateMachine::default();
sm.start();
assert!(sm.in_progress);
let first_start = sm.started_at.unwrap();
sm.start();
assert!(sm.in_progress, "после нового start(): in_progress=true");
assert_ne!(
sm.started_at,
Some(first_start),
"started_at должен быть обновлён (новый запуск)"
);
}
#[test]
fn bin_name_preserves_dashes() {
assert_eq!(bin_name("my-app"), "my-app");
assert_eq!(bin_name("my-app-x"), "my-app-x");
assert_eq!(bin_name("simple"), "simple");
}
#[test]
fn fresh_bin_path_layout() {
let base = std::env::temp_dir();
let build_target = base.join("hot-build");
let p = fresh_bin_path(&build_target, "my-app");
let s = p.to_string_lossy().to_string();
assert!(s.contains("hot-build"), "path contains hot-build: {s}");
assert!(
s.ends_with("my-app.exe") || s.ends_with("my-app"),
"correct bin name: {s}"
);
if cfg!(windows) {
assert!(s.ends_with(".exe"), "windows .exe, got: {s}");
}
}
#[test]
fn run_exe_path_has_timestamp_and_extension() {
let run_dir = std::env::temp_dir().join("hot-run");
let p = run_exe_path(&run_dir, "my-app");
let file = p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
assert!(
file.starts_with("my-app-"),
"filename should start with bin + dash, got: {file}"
);
if cfg!(windows) {
assert!(file.ends_with(".exe"), "windows extension, got: {file}");
} else {
assert!(!file.ends_with(".exe"), "no .exe on unix, got: {file}");
}
}
#[test]
fn copy_and_cleanup_run_dir() {
let tmp = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!(
".light-dev-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let build_root = tmp.join("hot-build");
let build_target = build_root.join("debug");
let run_dir = tmp.join("hot-run");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&build_target).expect("create build dir");
let bin = "test-app";
let mut fresh = build_target.join(bin);
#[cfg(windows)]
{
fresh.set_extension("exe");
}
std::fs::write(&fresh, b"fake").expect("write fake binary");
let copied = copy_fresh_to_run(&build_root, &run_dir, bin).expect("copy should succeed");
assert!(copied.exists(), "copy should exist: {:?}", copied);
let name = copied
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
assert!(name.starts_with("test-app-"), "name: {name}");
let mut stale = run_dir.join("test-app-oldstale");
#[cfg(windows)]
{
stale.set_extension("exe");
}
std::fs::write(&stale, b"old").expect("write stale");
cleanup_run_dir(&run_dir, &copied);
assert!(!stale.exists(), "stale should be removed: {:?}", stale);
assert!(copied.exists(), "current should remain: {:?}", copied);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn build_info_round_trip() {
let tmp = test_tmp_dir("build_info");
std::fs::create_dir_all(&tmp).expect("create tmp");
let info = build_info_path(&tmp);
save_build_info(&info, "0.14.0", "abc123hash");
let content = std::fs::read_to_string(&info).expect("read build-info.json");
assert!(content.contains("0.14.0"), "version in file: {content}");
assert!(content.contains("abc123hash"), "hash in file: {content}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn read_fw_core_version_from_lock_text() {
let tmp = test_tmp_dir("lock_parse");
std::fs::create_dir_all(&tmp).expect("create tmp");
let lock = tmp.join("Cargo.lock");
let lock_content = r#"
[[package]]
name = "light-fw-core"
version = "0.14.0"
dependencies = [
"serde",
]
[[package]]
name = "other-crate"
version = "1.0.0"
"#;
std::fs::write(&lock, lock_content).expect("write fake lock");
let version = read_fw_core_version_from_lock(&lock).expect("should find version");
assert_eq!(version, "0.14.0");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn clean_dev_cache_removes_expected_paths() {
let tmp = test_tmp_dir("clean_cache");
let target = tmp.join("target");
let hot_build = target.join("hot-build");
let hot_run = target.join("hot-run");
let state_file = tmp.join(".light").join("state.json");
let deps = target.join("debug").join("deps");
let pkg_dep = deps.join("example-devtools-demo-abcdef.exe");
std::fs::create_dir_all(&hot_build).expect("create hot-build");
std::fs::create_dir_all(&hot_run).expect("create hot-run");
std::fs::create_dir_all(tmp.join(".light")).expect("create .light");
std::fs::create_dir_all(&deps).expect("create deps");
std::fs::write(&state_file, "{}").expect("write state");
std::fs::write(&pkg_dep, b"fake").expect("write dep");
assert!(hot_build.exists());
assert!(hot_run.exists());
assert!(state_file.exists());
assert!(pkg_dep.exists());
let removed = clean_dev_cache(&tmp, Some("example-devtools-demo"));
assert!(!hot_build.exists(), "hot-build should be removed");
assert!(!hot_run.exists(), "hot-run should be removed");
assert!(!state_file.exists(), "state.json should be removed");
assert!(!pkg_dep.exists(), "deps artifact should be removed");
let removed_strs: Vec<String> = removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
removed_strs.iter().any(|s| s.contains("hot-build")),
"removed should contain hot-build: {removed_strs:?}"
);
assert!(
removed_strs.iter().any(|s| s.contains("hot-run")),
"removed should contain hot-run: {removed_strs:?}"
);
assert!(
removed_strs.iter().any(|s| s.contains("state.json")),
"removed should contain state.json: {removed_strs:?}"
);
assert!(
removed_strs
.iter()
.any(|s| s.contains("example-devtools-demo")),
"removed should contain dep artifact: {removed_strs:?}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn prune_stale_run_exes_respects_keep_and_age() {
let tmp = test_tmp_dir("prune_stale");
let run_dir = tmp.join("hot-run");
std::fs::create_dir_all(&run_dir).expect("create run dir");
let active = run_exe_path(&run_dir, "test-app");
std::fs::write(&active, b"active").expect("write active");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let yesterday_ms = now_ms.saturating_sub(86_400_000); let old_name = format!("test-app-{yesterday_ms}.exe");
let old_exe = run_dir.join(&old_name);
std::fs::write(&old_exe, b"old").expect("write old");
let pruned = prune_stale_run_exes(&run_dir, &active);
assert!(
pruned >= 1,
"should prune at least 1 stale: pruned={pruned}"
);
assert!(active.exists(), "active should remain: {:?}", active);
assert!(!old_exe.exists(), "old should be removed: {:?}", old_exe);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn parse_workspace_package_version_basic() {
let toml_text = r#"
[workspace]
resolver = "2"
members = ["crates/core"]
[workspace.package]
version = "0.15.0"
edition = "2024"
license = "MIT"
[workspace.dependencies]
serde = "1.0"
"#;
let ver = parse_workspace_package_version(toml_text).expect("should parse");
assert_eq!(ver, "0.15.0");
}
#[test]
fn parse_workspace_package_version_missing_section() {
let toml_text = r#"
[package]
name = "solo-crate"
version = "1.0.0"
"#;
assert!(parse_workspace_package_version(toml_text).is_none());
}
#[test]
fn workspace_toml_read_and_fallback() {
let tmp = test_tmp_dir("toml_fallback");
let _ = std::fs::create_dir_all(&tmp);
let toml_path = tmp.join("Cargo.toml");
let _ = std::fs::write(
&toml_path,
"[workspace.package]\nversion = \"0.16.0\"\nedition = \"2024\"\n",
);
let ver = read_fw_core_version_from_workspace_toml(&toml_path).expect("should read toml");
assert_eq!(ver, "0.16.0");
let missing = tmp.join("nonexistent/Cargo.toml");
assert!(read_fw_core_version_from_workspace_toml(&missing).is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn clean_dev_cache_removes_build_info() {
let tmp = test_tmp_dir("clean_build_info");
let _ = std::fs::create_dir_all(tmp.join("target/hot-build"));
let _ = std::fs::create_dir_all(tmp.join(".light"));
let bi = build_info_path(&tmp);
let _ = std::fs::write(&bi, "{}");
let removed = clean_dev_cache(&tmp, Some("myapp"));
assert!(!bi.exists(), "build-info.json should be removed");
let removed_strs: Vec<_> = removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
removed_strs.iter().any(|s| s.contains("build-info")),
"removed should contain build-info: {removed_strs:?}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
fn test_tmp_dir(label: &str) -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!(
".light-dev-test-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
label
))
}
}