light_fw_cli_core/dev.rs
1//! Горячая перезагрузка приложения Light: супервизор `light dev`.
2//!
3//! Запускает приложение (копию из `target/hot-run/`) как дочерний процесс,
4//! следит за исходниками (`src/`, `templates/`, `Cargo.toml`). Две реакции:
5//! * **HTML** (файлы `*.html`) → команда `RELOAD_TEMPLATE` через TCP-сервер
6//! DevTools на порту 9229: приложение перечитывает HTML-шаблон с диска и
7//! перерисовывает UI **без пересборки**. При неудачной отправки (app умер,
8//! порт недоступен — os error 10061) **не триггерим полную пересборку**:
9//! ждём до 3 с (poll каждые 200 мс), пока TCP снова станет доступен, и
10//! повторяем `RELOAD_TEMPLATE`.
11//! * **Source** (файлы `*.rs` / `Cargo.toml`) → полная пересборка + рестарт:
12//! сохраняем состояние (`SAVE_STATE`), убиваем процесс, перезапускаем.
13//!
14//! Race-защита: во время initial build (окно `is_starting`) изменения не
15//! приводят к немедленным действиям — они накапливаются в `pending_html` /
16//! `pending_source`. После закрытия окна (порт 9229 открылся либо прошёл
17//! дедлайн `STARTUP_IGNORE`) накопленное применяется с приоритетом Source >
18//! Html (пересборка важнее reload). HTML-чнейдж во время initial build
19//! **не** приводит к пустому restart — это ReloadHtml после старта.
20//!
21//! Надёжный мониторинг файлов использует `notify::PollWatcher` — периодическое
22//! опросование файловой системы, которое работает на любых ФС, включая UNC и
23//! сетевые диски, где нативный watcher (`recommended_watcher`) события не ловит.
24//! Дебаунс (100 мс для HTML, 500 мс для Source) координирует отдельный поток,
25//! чтобы кратковременные серии событий не запускали много перезагрузок.
26
27#[cfg(unix)]
28use std::os::unix::process::ExitStatusExt as _;
29#[cfg(windows)]
30use std::os::windows::process::ExitStatusExt as _;
31use std::path::{Path, PathBuf};
32use std::process::{Child, Command, ExitStatus, Stdio};
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{Arc, Mutex, mpsc};
35use std::thread;
36use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
37
38use notify::{Config, PollWatcher, RecursiveMode, Watcher as _};
39use sha2::Digest as _;
40
41const DEVTOOLS_PORT: u16 = 9229;
42/// Интервал опроса файловой системы [`PollWatcher`]. На UNC- и сетевых дисках
43/// нативный watcher события не ловит, поэтому используем периодическое
44/// опросование (500 мс — разумный баланс между задержкой реакции и нагрузкой).
45const POLL_INTERVAL: Duration = Duration::from_millis(500);
46/// Максимум времени, которое наблюдатель игнорирует события, пока приложение
47/// собирается/стартует. Защита от вечной блокировки: если порт 9229 так и не
48/// открылся (например, сборка зависла), по истечении этого дедлайна наблюдение
49/// всё равно возобновляется.
50const STARTUP_IGNORE: Duration = Duration::from_secs(30);
51/// Максимум времени ожидания восстановления TCP-доступа к порту 9229 после
52/// неудачной отправки `RELOAD_TEMPLATE` (приложение могло умереть или
53/// пересобраться). Каждые 200 мс пробуем TCP-подключение; если порт снова
54/// принимает соединения — повторяем `RELOAD_TEMPLATE` (без полной пересборки).
55const RELOAD_WAIT_MAX: Duration = Duration::from_secs(3);
56/// Период опроса TCP при ожидании восстановления после неудачного reload.
57const RELOAD_POLL_INTERVAL: Duration = Duration::from_millis(200);
58/// Дебаунс для изменений `.rs`/`Cargo.toml` (полная пересборка + рестарт).
59const RS_DEBOUNCE: Duration = Duration::from_millis(500);
60/// Дебаунс для изменений `.html` (runtime-перезагрузка шаблона без пересборки).
61const HTML_DEBOUNCE: Duration = Duration::from_millis(100);
62
63/// Разобранные параметры команды `light dev`.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct DevArgs {
66 /// Пакет воркспейса, который запускается (копия из `target/hot-run/`).
67 pub package: Option<String>,
68 /// Дополнительные флаги cargo, передаваемые после имени пакета
69 /// (например, `--release`).
70 pub extra_cargo_args: Vec<String>,
71 /// Включить подробные логи (debounce, hash-dedup, notify events).
72 pub verbose: bool,
73 /// Полный сброс кэша dev-сборки перед стартом (hot-build, hot-run, state,
74 /// deps текущего пакета). Смысл — при смене версии фреймворка, когда
75 /// auto-invalidation уже не помогает.
76 pub clean: bool,
77 /// Сохранять state.json между ВСЕМИ запусками (в т.ч. cold start).
78 /// Эквивалент LIGHT_PRESERVE_STATE=1. По умолчанию false.
79 pub preserve_state: bool,
80 /// ТЗ 0.16.0: запустить в режиме агента — автоматически создавать
81 /// session recorder, логировать все DevTools-команды и генерировать
82 /// summary.md при завершении процесса.
83 pub agent: bool,
84}
85
86/// Чистый разбор сырых аргументов командной строки в [`DevArgs`].
87///
88/// Правила: первый аргумент (если он существует и не начинается с `-`)
89/// считается именем пакета; `--verbose` включает подробные логи; всё
90/// остальное попадает в `extra_cargo_args`. Это изолировано от `clap`,
91/// чтобы тестировать парсинг без запуска бинарника.
92///
93/// # Примеры
94///
95/// ```
96/// use light_fw_cli_core::dev::parse_dev_command;
97///
98/// // `light dev example-devtools-demo --release`
99/// let args = parse_dev_command(&["example-devtools-demo".into(), "--release".into()]);
100/// assert_eq!(args.package.as_deref(), Some("example-devtools-demo"));
101/// assert_eq!(args.extra_cargo_args, vec!["--release"]);
102/// assert!(!args.verbose);
103///
104/// // `light dev --verbose`
105/// let args = parse_dev_command(&["--verbose".into()]);
106/// assert!(args.verbose);
107/// assert_eq!(args.package, None);
108/// assert!(args.extra_cargo_args.is_empty());
109///
110/// // `light dev` (без имени пакета)
111/// let empty: &[String] = &[];
112/// let args = parse_dev_command(empty);
113/// assert_eq!(args.package, None);
114/// assert!(args.extra_cargo_args.is_empty());
115/// ```
116pub fn parse_dev_command(raw: &[String]) -> DevArgs {
117 let mut out = DevArgs::default();
118 for (i, arg) in raw.iter().enumerate() {
119 if arg == "--verbose" {
120 out.verbose = true;
121 } else if arg == "--clean" {
122 // Полный сброс кэша (TZ 0.14.4): не передаём в cargo, а обрабатываем
123 // до warm-up build.
124 out.clean = true;
125 } else if arg == "--preserve-state" {
126 // ТЗ 0.14.3-fix4: сохранять state между всеми запусками.
127 out.preserve_state = true;
128 } else if arg == "--agent" {
129 // ТЗ 0.16.0: режим агента (session recorder + summary.md).
130 out.agent = true;
131 } else if i == 0 && !arg.starts_with('-') {
132 out.package = Some(arg.clone());
133 } else {
134 out.extra_cargo_args.push(arg.clone());
135 }
136 }
137 out
138}
139
140/// Определяет корень пакета, который запускает `light dev`.
141///
142/// Если передан аргумент `package` (например `example-devtools-demo`), корень
143/// находится через `cargo metadata --no-deps` — берётся родительский каталог
144/// `manifest_path` нужного пакета. Это важно: без этого `watch_paths` слежил бы
145/// за корнем воркспейса (где лежат все `examples/*`), а не за конкретным
146/// примером, и изменения в `examples/<name>/` не отслеживались бы.
147///
148/// Если пакет не передан или `cargo metadata` завершился с ошибкой, возвращается
149/// `cwd` (поведение «как раньше» — наблюдение текущего каталога).
150pub fn resolve_package_root(package: Option<&str>, cwd: &Path) -> PathBuf {
151 let Some(name) = package else {
152 return cwd.to_path_buf();
153 };
154 let output = Command::new("cargo")
155 .args(["metadata", "--no-deps", "--format-version", "1"])
156 .current_dir(cwd)
157 .output();
158 let Ok(output) = output else {
159 eprintln!("light dev: `cargo metadata` не запустился — слежу за cwd");
160 return cwd.to_path_buf();
161 };
162 if !output.status.success() {
163 eprintln!(
164 "light dev: `cargo metadata` завершился с ошибкой — слежу за cwd: {}",
165 String::from_utf8_lossy(&output.stderr)
166 );
167 return cwd.to_path_buf();
168 }
169 let Ok(meta) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
170 eprintln!("light dev: не удалось разобрать вывод `cargo metadata` — слежу за cwd");
171 return cwd.to_path_buf();
172 };
173 let Some(packages) = meta.get("packages").and_then(|p| p.as_array()) else {
174 eprintln!("light dev: в выводе `cargo metadata` нет полей `packages` — слежу за cwd");
175 return cwd.to_path_buf();
176 };
177 let found = packages
178 .iter()
179 .find(|pkg| pkg.get("name").and_then(|n| n.as_str()) == Some(name));
180 match found
181 .and_then(|pkg| pkg.get("manifest_path"))
182 .and_then(|m| m.as_str())
183 {
184 Some(manifest_path) => PathBuf::from(manifest_path)
185 .parent()
186 .map(Path::to_path_buf)
187 .unwrap_or_else(|| cwd.to_path_buf()),
188 None => {
189 eprintln!("light dev: пакет `{name}` не найден в воркспейсе — слежу за cwd");
190 cwd.to_path_buf()
191 }
192 }
193}
194
195/// Определяет корень воркспейса через `cargo metadata --no-deps`.
196///
197/// Возвращает `workspace_root` из JSON-вывода. Для одиночного крейта (без
198/// workspace) поле всё равно есть — `workspace_root == package_root`.
199/// Если `cargo metadata` недоступен или вывод не парсится — `None` (fallback
200/// на `package_root` вызывающим).
201pub fn resolve_workspace_root(cwd: &Path) -> Option<PathBuf> {
202 let output = Command::new("cargo")
203 .args(["metadata", "--no-deps", "--format-version", "1"])
204 .current_dir(cwd)
205 .output()
206 .ok()?;
207 if !output.status.success() {
208 return None;
209 }
210 let meta: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
211 meta.get("workspace_root")
212 .and_then(|r| r.as_str())
213 .map(PathBuf::from)
214}
215
216/// Определяет имя пакета, в котором находится пользователь: поднимается вверх
217/// от `cwd`, ищет ближайший `Cargo.toml` с `[package].name`; если не найден —
218/// использует `cargo metadata --no-deps` (одиночный пакет → его имя,
219/// workspace-член → член, чей `manifest_path` ближе всего к `cwd`).
220///
221/// Возвращает `None`, если пакет определить невозможно.
222pub fn detect_package_from_cwd(cwd: &Path) -> Option<String> {
223 // Шаг 1: ближайший Cargo.toml вверх по дереву каталогов.
224 let mut dir = cwd.to_path_buf();
225 loop {
226 let manifest = dir.join("Cargo.toml");
227 if manifest.is_file()
228 && let Ok(text) = std::fs::read_to_string(&manifest)
229 && let Some(name) = parse_package_name(&text)
230 {
231 return Some(name);
232 }
233 if !dir.pop() {
234 break;
235 }
236 }
237
238 // Шаг 2: `cargo metadata --no-deps` в cwd.
239 let output = Command::new("cargo")
240 .args(["metadata", "--no-deps", "--format-version", "1"])
241 .current_dir(cwd)
242 .output()
243 .ok()?;
244 if !output.status.success() {
245 return None;
246 }
247 let meta: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
248 let packages = meta.get("packages").and_then(|p| p.as_array())?;
249 if packages.len() == 1 {
250 return packages[0]
251 .get("name")
252 .and_then(|n| n.as_str())
253 .map(String::from);
254 }
255 // Workspace: выбираем пакет, чей manifest_path ближе всего к cwd
256 // (больше общих компонентов нормализованного пути — ближе).
257 let cwd_str = cwd.to_string_lossy().replace('\\', "/");
258 let mut best: Option<(usize, String)> = None;
259 for pkg in packages {
260 let manifest = pkg.get("manifest_path").and_then(|m| m.as_str())?;
261 let manifest_norm = manifest.replace('\\', "/");
262 let common = common_path_len(&cwd_str, &manifest_norm);
263 let name = pkg
264 .get("name")
265 .and_then(|n| n.as_str())
266 .unwrap_or_default()
267 .to_string();
268 if best.as_ref().is_none_or(|(d, _)| *d <= common) {
269 best = Some((common, name));
270 }
271 }
272 best.map(|(_, name)| name).filter(|n| !n.is_empty())
273}
274
275/// Извлекает `[package].name` из текста `Cargo.toml` (примитивный парсер:
276/// ищет секцию `[package]` и первое `name = "..."` в ней).
277fn parse_package_name(toml_text: &str) -> Option<String> {
278 let mut in_package = false;
279 for line in toml_text.lines() {
280 let trimmed = line.trim();
281 if trimmed.starts_with('[') {
282 in_package = trimmed == "[package]";
283 if !in_package {
284 continue;
285 }
286 }
287 if in_package && trimmed.starts_with("name") {
288 let rest = trimmed.split_once('=')?.1.trim();
289 let rest = rest.strip_prefix('"')?.strip_suffix('"')?;
290 if !rest.is_empty() {
291 return Some(rest.to_string());
292 }
293 }
294 }
295 None
296}
297
298/// Длина общего префикса двух нормализованных (slash-разделителей) путей,
299/// по числу компонентов.
300fn common_path_len(a: &str, b: &str) -> usize {
301 let a_parts: Vec<&str> = a.split('/').collect();
302 let b_parts: Vec<&str> = b.split('/').collect();
303 a_parts
304 .iter()
305 .zip(b_parts.iter())
306 .take_while(|(x, y)| x == y)
307 .count()
308}
309
310/// Строит список путей для наблюдения: `src/` и `templates/` (только если
311/// существуют) плюс `Cargo.toml` (если есть).
312pub fn watch_paths(root: &std::path::Path) -> Vec<PathBuf> {
313 let mut paths = Vec::new();
314 for dir in ["src", "templates"] {
315 let p = root.join(dir);
316 if p.is_dir() {
317 paths.push(p);
318 }
319 }
320 let manifest = root.join("Cargo.toml");
321 if manifest.exists() {
322 paths.push(manifest);
323 }
324 paths
325}
326
327/// Путь файла состояния относительно `root` (по умолчанию `.light/state.json`;
328/// переопределяется переменной окружения `LIGHT_STATE_FILE`).
329pub fn state_file_path(root: &std::path::Path) -> PathBuf {
330 match std::env::var_os("LIGHT_STATE_FILE") {
331 Some(var) if !var.is_empty() => PathBuf::from(var),
332 _ => root.join(".light").join("state.json"),
333 }
334}
335
336/// Устанавливает `LIGHT_STATE_FILE` на `Command`, указывая на абсолютный путь
337/// `root/.light/state.json` (или значение переменной окружения, если задана).
338/// Без этого ребёнок читает относительный `.light/state.json` от своего CWD,
339/// который может не совпадать с корнем проекта.
340fn apply_state_env(cmd: &mut std::process::Command, root: &std::path::Path) {
341 let path = state_file_path(root);
342 eprintln!("light dev: state file for child: {:?}", path);
343 cmd.env("LIGHT_STATE_FILE", &path);
344}
345
346/// Устанавливает `LIGHT_WINDOW_FILE` на `Command`, указывая на абсолютный путь
347/// `root/.light/window.json` (или значение переменной окружения, если задана).
348/// Без этого ребёнок читает относительный `.light/window.json` от своего CWD,
349/// который может не совпадать с корнем пакета → supervisor и child работают
350/// с разными файлами.
351fn apply_window_env(cmd: &mut std::process::Command, root: &std::path::Path) {
352 let path = window_file_path(root);
353 eprintln!("light dev: window file for child: {:?}", path);
354 cmd.env("LIGHT_WINDOW_FILE", &path);
355}
356
357/// Путь файла геометрии окна относительно `root` (по умолчанию
358/// `.light/window.json`; переопределяется `LIGHT_WINDOW_FILE`). Совпадает с
359/// путём, который читает/пишет окно приложения (`light-fw-core::window`).
360pub fn window_file_path(root: &std::path::Path) -> PathBuf {
361 match std::env::var_os("LIGHT_WINDOW_FILE") {
362 Some(var) if !var.is_empty() => PathBuf::from(var),
363 _ => root.join(".light").join("window.json"),
364 }
365}
366
367/// Отправляет команду `SAVE_STATE\n` в TCP-сервер DevTools на
368/// `127.0.0.1:<port>` и закрывает соединение. Ошибки (сервер не запущен,
369/// порт занят) не являются фатальными — просто проигнорируются.
370pub fn request_save_state(port: u16) -> std::io::Result<()> {
371 use std::io::Write;
372 use std::net::TcpStream;
373 let mut stream = TcpStream::connect(("127.0.0.1", port))?;
374 stream.write_all(b"SAVE_STATE\n")?;
375 Ok(())
376}
377
378/// Отправляет команду `RELOAD_TEMPLATE\n` в TCP-сервер DevTools на
379/// `127.0.0.1:<port>` и закрывает соединение. Приложение перечитывает
380/// HTML-шаблон с диска и перерисовывает UI без пересборки. Ошибки не
381/// являются фатальными — просто проигнорируются.
382pub fn request_reload_template(port: u16) -> std::io::Result<()> {
383 use std::io::Write;
384 use std::net::TcpStream;
385 let mut stream = TcpStream::connect(("127.0.0.1", port))?;
386 stream.write_all(b"RELOAD_TEMPLATE\n")?;
387 Ok(())
388}
389
390/// ТЗ 0.15.1-fix3: graceful save перед swap-kill.
391///
392/// Отправляет `SAVE_ALL\n` в TCP-сервер DevTools на `127.0.0.1:<port>`
393/// и **ожидаает** ответа `ok` от UI-потока (до 500 мс). UI-поток синхронно
394/// сохраняет `state.json` + принудительно `window.json` (без дебаунса)
395/// и только затем отвечает. Если таймаут или ошибка — логим и продолжаем
396/// (fallback: TerminateProcess как раньше, debounce-запись может не успеть).
397fn request_save_all(port: u16) {
398 use std::io::BufRead;
399 use std::net::TcpStream;
400 use std::time::Duration;
401
402 let result = (|| -> Result<(), String> {
403 let stream = TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;
404 stream
405 .set_read_timeout(Some(Duration::from_millis(500)))
406 .map_err(|e| e.to_string())?;
407 stream
408 .set_write_timeout(Some(Duration::from_millis(500)))
409 .map_err(|e| e.to_string())?;
410 {
411 use std::io::Write as _;
412 let mut w = &stream;
413 w.write_all(b"SAVE_ALL\n").map_err(|e| e.to_string())?;
414 }
415 let mut reader = std::io::BufReader::new(&stream);
416 let mut line = String::new();
417 let bytes_read = reader.read_line(&mut line).map_err(|e| e.to_string())?;
418 if bytes_read == 0 {
419 return Err("connection closed before response".to_owned());
420 }
421 let reply = line.trim();
422 if reply != "ok" {
423 return Err(format!("unexpected reply: {reply}"));
424 }
425 Ok(())
426 })();
427 match result {
428 Ok(()) => {
429 eprintln!("[SAVE_ALL] sent by supervisor, replied ok");
430 }
431 Err(e) => {
432 eprintln!(
433 "light dev: SAVE_ALL timeout/error: {e} \
434 (falling back to TerminateProcess without graceful save)"
435 );
436 }
437 }
438}
439
440/// Проверяет, открыт ли TCP-порт DevTools (на нём приложение принимает команду
441/// `SAVE_STATE`). `TcpStream::connect` к свободному порту возвращает ошибку
442/// мгновенно (connection refused), так что это дешёвая проверка.
443fn is_port_open(port: u16) -> bool {
444 use std::net::TcpStream;
445 TcpStream::connect(("127.0.0.1", port)).is_ok()
446}
447
448/// Период ожидания между попытками чтения файла при временных ошибках
449/// (`PermissionDenied` / `NotFound`). Редактор может писать файл в два этапа
450/// (append + flush): первое чтение ловит частичный/переходный файл. Повторные
451/// попытки (до 3, каждые 50 мс) дают OS время дописать файл, прежде чем мы
452/// вернём `None`.
453const FILE_HASH_RETRY_INTERVAL: Duration = Duration::from_millis(50);
454/// Максимум попыток чтения файла в [`file_hash`] (1 начальная + 2 повтора).
455const FILE_HASH_MAX_ATTEMPTS: u32 = 3;
456
457/// Хэш содержимого файла (`u64`) для дедупликации событий notify.
458///
459/// Один «логический» записывающий вызов (например, сохранение в редакторе)
460/// генерирует несколько событий `PollWatcher` — `Modify`, `Modify(Data)`,
461/// `Modify(Metadata)`. **Ключевой порядок**: хэширование выполняется ПОСЛЕ
462/// debounce, а не до него — потому что `Add-Content`-подобная запись идёт в
463/// два этапа (append + flush), и первое событие видит неполное содержимое.
464/// Если бы хэш применялся до debounce, hash1 ≠ hash2, и оба события прошли бы
465/// → двойной reload. После debounce содержимое уже финальное, и повторные
466/// события дают одинаковый хэш → отфильтровываются.
467///
468/// При временных ошибках чтения (`PermissionDenied` на частично-записанном
469/// файле, `NotFound` в момент удаления) функция пробует снова до
470/// [`FILE_HASH_MAX_ATTEMPTS`] раз, ожидая [`FILE_HASH_RETRY_INTERVAL`] между
471/// попытками. Если все попытки упали (файл реально недоступен) — возвращает
472/// `None`; событие тогда НЕ дедуплицируется и обрабатывается как изменение
473/// (консервативно: лучше лишний reload, чем пропущенное изменение).
474pub fn file_hash(path: &Path, verbose: bool) -> Option<u64> {
475 use std::collections::hash_map::DefaultHasher;
476 use std::hash::{Hash, Hasher};
477
478 // Читаем файл с ретраями: временные ошибки (частичная запись, PermissionDenied
479 // на переходном состоянии, NotFound в момент атомарного удаления) часто
480 // проходят через 50 мс. Лог на каждую неудачную попытку + итоговый лог по
481 // ТЗ 2.2 ("light dev: hash({:?}) = {:?}").
482 let mut attempts = 0u32;
483 let buf = loop {
484 match std::fs::read(path) {
485 Ok(b) => break Some(b),
486 Err(e) => {
487 attempts += 1;
488 if attempts >= FILE_HASH_MAX_ATTEMPTS {
489 break None;
490 }
491 if verbose {
492 eprintln!(
493 "light dev: hash({path:?}) read attempt {attempts} failed: {e} \
494 (повторю через {:?}, всего до {FILE_HASH_MAX_ATTEMPTS:?} попыток)",
495 FILE_HASH_RETRY_INTERVAL
496 );
497 }
498 thread::sleep(FILE_HASH_RETRY_INTERVAL);
499 }
500 }
501 };
502 let result: Option<u64> = buf.map(|b| {
503 let mut hasher = DefaultHasher::new();
504 b.hash(&mut hasher);
505 hasher.finish()
506 });
507 if verbose {
508 eprintln!("light dev: hash({:?}) = {:?}", path, result);
509 }
510 result
511}
512
513/// Тип изменения файла для классификации hot-reload.
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515pub enum ChangeKind {
516 /// HTML-шаблон: runtime-перезагрузка без пересборки (`RELOAD_TEMPLATE`).
517 Html,
518 /// Rust-исходник или `Cargo.toml`: полная пересборка + рестарт.
519 Source,
520}
521
522/// Классифицирует путь изменённого файла в [`ChangeKind`].
523///
524/// `.html` → [`ChangeKind::Html`]; `.rs`/`Cargo.toml` → [`ChangeKind::Source`].
525/// Любой другой расширение (например, `.json`, `.png`) считается нерелевантным
526/// и классифицируется как `Source` (консервативно: лучше лишняя пересборка).
527pub fn classify_change(path: &std::path::Path) -> Option<ChangeKind> {
528 let file_name = path.file_name()?.to_string_lossy();
529 if file_name == "Cargo.toml" {
530 return Some(ChangeKind::Source);
531 }
532 match path.extension()?.to_string_lossy().as_ref() {
533 "html" => Some(ChangeKind::Html),
534 "rs" => Some(ChangeKind::Source),
535 _ => None,
536 }
537}
538
539/// Живое состояние наблюдателя: окно игнорирования при сборке/старте
540/// (`is_starting`) и накопленные изменения, которые нужно применить сразу
541/// после сброса окна. HTML (runtime-reload) и Source (пересборка)
542/// разделяются отдельными флагами: HTML-чнейдж во время initial build
543/// не должен приводить к полному рестарту приложения.
544#[derive(Debug, Default)]
545pub struct WatchState {
546 /// Игнорировать события (приложение ещё собирается или только стартует).
547 /// Во время этого окна прямые чейджи не вызывают действия — вместо этого
548 /// они накапливаются в [`Self::pending_html`] / [`Self::pending_source`].
549 pub is_starting: bool,
550 /// Зафиксировано изменение HTML-шаблона во время `is_starting`. После
551 /// закрытия окна → `RELOAD_TEMPLATE` (без пересборки).
552 pub pending_html: bool,
553 /// Зафиксировано изменение Rust-исходника/`Cargo.toml` во время
554 /// `is_starting`. После закрытия окна → полная пересборка + рестарт.
555 pub pending_source: bool,
556}
557
558impl WatchState {
559 /// Создаёт новое состояние: окно игнорирования включено, pending-флаги
560 /// сняты.
561 pub fn starting() -> Self {
562 Self {
563 is_starting: true,
564 pending_html: false,
565 pending_source: false,
566 }
567 }
568
569 /// Есть ли накопленное изменение, которое нужно применить после закрытия
570 /// окна `is_starting`?
571 pub fn has_pending(&self) -> bool {
572 self.pending_html || self.pending_source
573 }
574
575 /// Сбрасывает оба pending-флага.
576 pub fn clear_pending(&mut self) {
577 self.pending_html = false;
578 self.pending_source = false;
579 }
580}
581
582/// Результат обработки одного события наблюдателя: тип реакции (или её
583/// отсутствие), либо сигнал, что накопленное изменение требует применения.
584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum WatchAction {
586 /// Отправить `RELOAD_TEMPLATE` (без убийства процесса).
587 ReloadHtml,
588 /// Полная пересборка + рестарт процесса.
589 Restart,
590 /// Применить накопленное изменение (вызывается при закрытии окна
591 /// `is_starting` либо когда pending-флаг оказался снятым ранее).
592 ApplyPending,
593 /// Действия нет (событие нерелевантно или просто запомнено).
594 None,
595}
596
597/// Логика обработки одного события наблюдателя с учётом состояния
598/// [`WatchState`]. Отделена от потоков, чтобы юнит-тестить без запуска
599/// процесса и файлового watcher'а.
600///
601/// Правила (ТЗ 0.14.2-A-fix):
602/// * `Html` + `is_starting` → запоминаем в `pending_html`, без действия;
603/// * `Source` + `is_starting` → запоминаем в `pending_source`, без действия;
604/// * `Html` + `!is_starting` → [`WatchAction::ReloadHtml`];
605/// * `Source` + `!is_starting` → [`WatchAction::Restart`];
606/// * если перед событием были накоплены pending-флаги и окно уже закрыто —
607/// они применяются первым ([`WatchAction::ApplyPending`]): приоритет у
608/// source (рестарт), html (reload) вторым.
609///
610/// Возвращает [`WatchAction::None`], если событие нерелевантно (ни `Html`,
611/// ни `Source`).
612pub fn handle_watch_event(state: &mut WatchState, kind: Option<ChangeKind>) -> WatchAction {
613 let Some(kind) = kind else {
614 return WatchAction::None;
615 };
616 // Если до этого было накоплено изменение, а окно уже закрыто — применяем
617 // его первым (один перезапуск за накопленную серию). Приоритет Source >
618 // Html: источник всегда требует пересборки, HTML может подождать reload.
619 if state.has_pending() && !state.is_starting {
620 state.clear_pending();
621 return WatchAction::ApplyPending;
622 }
623 if state.is_starting {
624 // Изменение во время сборки: не реагируем сейчас, но помним о нём.
625 match kind {
626 ChangeKind::Html => state.pending_html = true,
627 ChangeKind::Source => state.pending_source = true,
628 }
629 WatchAction::None
630 } else {
631 match kind {
632 ChangeKind::Html => WatchAction::ReloadHtml,
633 ChangeKind::Source => WatchAction::Restart,
634 }
635 }
636}
637
638/// Решает, какое действие применить при закрытии окна `is_starting` (порт
639/// DevTools открылся либо прошёл дедлайн `STARTUP_IGNORE`). Приоритет:
640/// Source (полная пересборка) > Html (runtime-reload) > None.
641pub fn resolve_startup_completion(state: &WatchState) -> Option<ChangeKind> {
642 if state.pending_source {
643 Some(ChangeKind::Source)
644 } else if state.pending_html {
645 Some(ChangeKind::Html)
646 } else {
647 None
648 }
649}
650
651/// Ожидает восстановления TCP-доступа к порту `port` в течение `max` (poll
652/// каждые `interval`). Возвращает `true`, если порт принял соединение,
653/// иначе `false`. Используется для повторной отправки `RELOAD_TEMPLATE`
654/// после неудачного (например, app умер) — без полной пересборки.
655pub fn wait_port_available(port: u16, max: Duration, interval: Duration) -> bool {
656 let start = Instant::now();
657 loop {
658 if is_port_open(port) {
659 return true;
660 }
661 if start.elapsed() >= max {
662 return false;
663 }
664 thread::sleep(interval);
665 }
666}
667
668/// Пытается отправить `RELOAD_TEMPLATE`; при неудаче (app умер, порт
669/// недоступен) ждёт до [`RELOAD_WAIT_MAX`], пока TCP снова станет доступен
670/// (poll каждые [`RELOAD_POLL_INTERVAL`]), и повторяет отправку. Не триггерит
671/// полную пересборку: HTML-чнейдж — это runtime-reload, не rebuild.
672///
673/// Возвращает `Ok(())` при успехе (включая повторную попытку), `Err` — если
674/// и после ожидания порт так и не стал доступен.
675pub fn request_reload_template_with_retry(port: u16) -> std::result::Result<(), std::io::Error> {
676 if let Ok(()) = request_reload_template(port) {
677 return Ok(());
678 }
679 // Неудача: приложение могло умереть или зависнуть. Ждём, пока TCP снова
680 // станет доступен, и повторяем RELOAD_TEMPLATE (без пересборки).
681 eprintln!(
682 "light dev: RELOAD_TEMPLATE не удался (app недоступен?) — \
683 жду до {:?}, чтобы повторить (не запуская пересборку)…",
684 RELOAD_WAIT_MAX
685 );
686 if wait_port_available(port, RELOAD_WAIT_MAX, RELOAD_POLL_INTERVAL) {
687 eprintln!("light dev: порт DevTools снова доступен — повторяю RELOAD_TEMPLATE");
688 request_reload_template(port)
689 } else {
690 eprintln!(
691 "light dev: порт DevTools {} так и не открылся за {:?} — \
692 HTML-чнейдж будет потерян, до следующего изменения",
693 port, RELOAD_WAIT_MAX
694 );
695 Err(std::io::Error::new(
696 std::io::ErrorKind::ConnectionRefused,
697 "port unavailable after reload wait",
698 ))
699 }
700}
701
702/// Убивает дочерний процесс и дожидается его завершения.
703///
704/// После `wait()` даём ОС короткую паузу, чтобы освободить хендлы на `.rmeta`
705/// и `.pdb` (Windows AV / антивирус часто держит файл ещё мгновение): без этой
706/// паузы следующий `cargo run` может упасть с `os error 5` (Access Denied).
707pub fn kill_child(child: &mut Child) {
708 if let Err(e) = child.kill() {
709 eprintln!("light dev: не удалось завершить дочерний процесс: {e}");
710 }
711 let _ = child.wait();
712 // Пауза для освобождения файловых хендлов ОС/автовикором.
713 thread::sleep(Duration::from_millis(500));
714}
715
716// ===================== ТЗ 0.14.2-B: seamless rebuild =========================
717
718/// Имя бинарника пакета: **имя пакета без изменений** (cargo не заменяет `-`).
719///
720/// Cargo кладёт бинарник в `target/debug/<package-name>` (двери сохраняются):
721/// `example-devtools-demo` → `example-devtools-demo.exe` (Windows) или
722/// `example-devtools-demo` (unix). Путь к свежему артефакту:
723/// `<build_target>/debug/<package-name>.exe`.
724pub fn bin_name(package: &str) -> String {
725 package.to_string()
726}
727
728/// Свежий артефакт после `cargo build --target-dir <build_target>`:
729/// `<build_target>/debug/<package>.exe` (Windows) / `.exec`-без (unix).
730///
731/// Запущенный процесс НЕ держит этот файл — он запущен из копии в
732/// [`run_exe_path`] (`target/hot-run/`). Поэтому следующая сборка может
733/// перезаписывать свежий бинарник без os error 5.
734pub fn fresh_bin_path(build_target: &Path, package: &str) -> PathBuf {
735 let mut p = build_target.join("debug").join(bin_name(package));
736 #[cfg(windows)]
737 {
738 p.set_extension("exe");
739 }
740 p
741}
742
743/// Копия свежего бинарника в `target/hot-run/`, от которой и запускается
744/// приложение: `<run_dir>/<bin>-<ts>.exe`. Timestamp (`as_millis`) делает имя
745/// уникальным на каждый swap; старые копии чистятся [`cleanup_run_dir`].
746pub fn run_exe_path(run_dir: &Path, bin: &str) -> PathBuf {
747 let ts = SystemTime::now()
748 .duration_since(UNIX_EPOCH)
749 .unwrap_or_default()
750 .as_millis();
751 let mut p = run_dir.join(format!("{bin}-{ts}"));
752 #[cfg(windows)]
753 {
754 p.set_extension("exe");
755 }
756 p
757}
758
759/// Удаляет все `.exe` в `run_dir`, кроме активного (`keep`). Ошибки молча
760/// игнорируются: файл мог быть ещё жив в AV-кэше.
761pub fn cleanup_run_dir(run_dir: &Path, keep: &Path) {
762 if let Ok(entries) = std::fs::read_dir(run_dir) {
763 for entry in entries.flatten() {
764 let path = entry.path();
765 if path == keep {
766 continue;
767 }
768 if path.extension().and_then(|e| e.to_str()) == Some("exe") {
769 let _ = std::fs::remove_file(&path);
770 }
771 }
772 }
773}
774
775/// Копирует свежий бинарник из `<build_target>/debug/` в
776/// `<run_dir>/<bin>-<ts>.exe`. Создаёт `run_dir`, если её нет.
777/// Возвращает путь к копии.
778pub fn copy_fresh_to_run(
779 build_target: &Path,
780 run_dir: &Path,
781 bin: &str,
782) -> std::result::Result<PathBuf, std::io::Error> {
783 std::fs::create_dir_all(run_dir)?;
784 let fresh = fresh_bin_path(build_target, bin);
785 let dest = run_exe_path(run_dir, bin);
786 std::fs::copy(&fresh, &dest)?;
787 Ok(dest)
788}
789
790/// Результат одной фоновой сборки.
791///
792/// Чистый тип: main-loop получает его через `poll_rebuild` и передаёт в
793/// [`RebuildStateMachine`] для принятия решения «swap / keep-old».
794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795pub enum RebuildOutcome {
796 /// Сборка успешна — можно выполнять swap.
797 Success,
798 /// Сборка завершилась с ошибкой (exit code сохранён) — old продолжает.
799 Failed(i32),
800}
801
802/// State-machine: решение о реакции на результат фоновой сборки.
803///
804/// Вынесена отдельно от потоков, чтобы юнит-тестировать без реального cargo.
805/// Все методы чистые: принимают текущий статус и возвращают новый (без side
806/// effects кроме лога в `mark_rebuild_failure`, который нужен по ТЗ 5.3).
807#[derive(Debug, Default, Clone, Copy)]
808pub struct RebuildStateMachine {
809 /// true, если сейчас идёт фоновая сборка.
810 pub in_progress: bool,
811 /// Момент запуска последней активной сборки (для лога «took N s»).
812 pub started_at: Option<Instant>,
813}
814
815impl RebuildStateMachine {
816 /// Запускаем новую фоновую сборку. Если старая ещё идёт — её нужно убить
817 /// (вызывается снаружи; здесь только поднимаем флаг + таймер).
818 pub fn start(&mut self) {
819 self.in_progress = true;
820 self.started_at = Some(Instant::now());
821 }
822
823 /// Обработать результат завершившейся сборки. Возвращает
824 /// [`RebuildOutcome`], по которому main-loop решает, делать ли swap.
825 pub fn poll_outcome(&mut self, exit_code: i32) -> RebuildOutcome {
826 self.in_progress = false;
827 if exit_code == 0 {
828 RebuildOutcome::Success
829 } else {
830 eprintln!(
831 "light dev: rebuild FAILED (exit {exit_code}), previous version kept running"
832 );
833 RebuildOutcome::Failed(exit_code)
834 }
835 }
836
837 /// Сброс после завершённого цикла (для теста).
838 pub fn reset(&mut self) {
839 *self = Self::default();
840 }
841}
842
843// ===================== TZ 0.14.4: умная инвалидация кэша hot-build/hot-run ===
844
845/// Путь файла информации о версии фреймворка, от которой зависит hot-build.
846///
847/// Формат: `{"light-fw-core-version": "0.14.0", "cargo-lock-hash": "..."}`
848/// (JSON, оба поля строки). Лежит в `<target_root>/.light/build-info.json`.
849pub fn build_info_path(target_root: &Path) -> PathBuf {
850 target_root.join(".light").join("build-info.json")
851}
852
853/// Читает и парсит `build-info.json`. Повреждённый файл — `None` (перезапишется),
854/// отсутствующий файл — `None` (первая сборка: кэш не инвалидируется, просто
855/// фиксируем текущую версию). Ошибки записи игнорируются: при неудаче запись
856/// кэш не сломает, но следующая сборка будет повторять попытку записи.
857pub fn save_build_info(path: &Path, version: &str, lock_hash: &str) {
858 if let Some(parent) = path.parent()
859 && let Err(e) = std::fs::create_dir_all(parent)
860 {
861 eprintln!("light dev: не удалось создать каталог для build-info: {e}");
862 return;
863 }
864 let v = serde_json::json!({
865 "light-fw-core-version": version,
866 "cargo-lock-hash": lock_hash,
867 });
868 match serde_json::to_string_pretty(&v) {
869 Ok(s) => {
870 if let Err(e) = std::fs::write(path, s) {
871 eprintln!("light dev: не удалось записать build-info.json: {e}");
872 }
873 }
874 Err(e) => eprintln!("light dev: не удалось сериализовать build-info: {e}"),
875 }
876}
877
878/// Текущая версия `light-fw-core` в `Cargo.lock` проекта. Читаем `Cargo.lock`
879/// напрямую (без запуска `cargo metadata`): файл маленький, а `cargo metadata`
880/// иногда долго и может не сработать без сети.
881pub fn read_fw_core_version_from_lock(lock_path: &Path) -> Option<String> {
882 let data = std::fs::read_to_string(lock_path).ok()?;
883 parse_fw_core_version_from_lock_text(&data)
884}
885
886/// Парсит версию `light-fw-core` из текста `Cargo.lock` (только чистая функция,
887/// без I/O): ищем `[dependencies] name="light-fw-core"` ... `version="..."` и
888/// берём первый попавшийся блок.
889fn parse_fw_core_version_from_lock_text(text: &str) -> Option<String> {
890 // Ищем блок, начинающийся с `name = "light-fw-core"`, затем в нём ищем
891 // первую строку `version = "..."`.
892 let start = text.find("name = \"light-fw-core\"")?;
893 let after = &text[start..];
894 let end = after.find("\n\n").unwrap_or(after.len());
895 let block = &after[..end];
896 let ver_start = block.find("version = \"")? + "version = \"".len();
897 let rest = &block[ver_start..];
898 let ver_end = rest.find('"')?;
899 Some(rest[..ver_end].to_string())
900}
901
902/// Текущая версия фреймворка в проекте. Порядок:
903/// 1. `[workspace.package].version` (workspace-проект).
904/// 2. `[dependencies] light = "0.15"` (обёрточный крейт — версия идентична).
905/// 3. `[dependencies] light-fw-core = "0.15"` (прямая зависимость).
906pub fn read_fw_core_version_from_workspace_toml(toml_path: &Path) -> Option<String> {
907 let data = std::fs::read_to_string(toml_path).ok()?;
908 parse_workspace_package_version(&data).or_else(|| parse_light_dep_version(&data))
909}
910
911/// Парсит `[workspace.package] version = "..."` из текста `Cargo.toml`
912/// (чистая функция, без I/O). Использует `toml_edit` для корректного разбора.
913fn parse_workspace_package_version(text: &str) -> Option<String> {
914 let doc: toml_edit::DocumentMut = text.parse().ok()?;
915 let ver = doc
916 .get("workspace")?
917 .get("package")?
918 .get("version")?
919 .as_str()?
920 .to_string();
921 Some(ver)
922}
923
924/// Парсит версию обёрточного крейта из `[dependencies]` (`light-fw`, затем старый `light`).
925/// Возвращает `None`, если ни одна из зависимостей отсутствует (не workspace-проект
926/// и нет прямой зависимости).
927fn parse_light_dep_version(text: &str) -> Option<String> {
928 let doc: toml_edit::DocumentMut = text.parse().ok()?;
929 let deps = doc.get("dependencies")?.as_table()?;
930 // Первый приоритет: обёрточный крейт `light-fw` (новый ключ), затем
931 // старый `light` для уже опубликованных проектов.
932 if let Some(entry) = deps.get("light-fw") {
933 return extract_dep_version(entry);
934 }
935 // Обратная совместимость: старый обёрточный крейт `light`.
936 if let Some(entry) = deps.get("light") {
937 return extract_dep_version(entry);
938 }
939 // Fallback: прямая зависимость `light-fw-core`.
940 deps.get("light-fw-core").and_then(extract_dep_version)
941}
942
943/// Извлекает версионную строку из TOML-записи зависимости:
944/// `light = "0.15"` → `"0.15"`, `light = { version = "0.15" }` → `"0.15"`.
945fn extract_dep_version(entry: &toml_edit::Item) -> Option<String> {
946 match entry {
947 toml_edit::Item::Value(v) => v.as_str().map(str::to_string),
948 toml_edit::Item::Table(t) => t.get("version")?.as_str().map(str::to_string),
949 _ => None,
950 }
951}
952
953/// Удаляет всё, что нужно для полного сброса кэша dev-сборки (ТЗ 0.14.4):
954/// `target/hot-build`, `target/hot-run`, `.light/state.json`,
955/// `.light/build-info.json` и артефакты зависимостей текущего пакета из
956/// `target/debug/deps/` (только `<package>*`).
957/// Возвращает список удаляемых путей (для логирования и теста).
958pub fn clean_dev_cache(target_root: &Path, package: Option<&str>) -> std::vec::Vec<PathBuf> {
959 let mut removed = Vec::new();
960 // 1) target/hot-build
961 let hot_build = target_root.join("target").join("hot-build");
962 remove_path_quietly(&hot_build, &mut removed);
963 // 2) target/hot-run
964 let hot_run = target_root.join("target").join("hot-run");
965 remove_path_quietly(&hot_run, &mut removed);
966 // 3) .light/state.json
967 let state = target_root.join(".light").join("state.json");
968 remove_path_quietly(&state, &mut removed);
969 // 4) .light/build-info.json
970 let build_info = build_info_path(target_root);
971 remove_path_quietly(&build_info, &mut removed);
972 // 5) target/debug/deps/<package>*
973 if let Some(pkg) = package {
974 let deps = target_root.join("target").join("debug").join("deps");
975 if let Ok(entries) = std::fs::read_dir(&deps) {
976 for entry in entries.flatten() {
977 let name = entry.file_name().to_string_lossy().into_owned();
978 if name.starts_with(pkg) {
979 remove_path_quietly(&entry.path(), &mut removed);
980 }
981 }
982 }
983 }
984 removed
985}
986
987/// Рекурсивно удаляет `path` (если он существует), тихо игнорируя ошибки
988/// (файл мог быть уже занят автовикором или ОС). Успешные удаления
989/// добавляются в `removed`.
990fn remove_path_quietly(path: &Path, removed: &mut Vec<PathBuf>) {
991 if !path.exists() {
992 return;
993 }
994 let kind = std::fs::symlink_metadata(path).map(|m| m.file_type()).ok();
995 if matches!(kind, Some(t) if t.is_dir()) {
996 if std::fs::remove_dir_all(path).is_ok() {
997 removed.push(path.to_path_buf());
998 }
999 } else if std::fs::remove_file(path).is_ok() {
1000 removed.push(path.to_path_buf());
1001 }
1002}
1003
1004/// Убирает из `run_dir` устаревшие копии исполняемых файлов:
1005/// * любой `.exe` с mtime старше `STALE_RUN_AGE`;
1006/// * либо любой `.exe`, чей суффикс-timestamp (после `<bin>-`) **не совпадает**
1007/// с «сегодня» (это защищает от старых артефактов, которые не успели
1008/// подхватить автовикором).
1009///
1010/// Файл `keep` (сейчас работающий) НИКОГДА не удаляется, независимо от age.
1011/// Возвращает число удалённых копий.
1012pub fn prune_stale_run_exes(run_dir: &Path, keep: &Path) -> usize {
1013 let now = SystemTime::now();
1014 let today: u128 = now
1015 .duration_since(UNIX_EPOCH)
1016 .map(|d| d.as_secs() / 86_400)
1017 .unwrap_or(0) as u128;
1018 let mut pruned = 0usize;
1019 let Ok(entries) = std::fs::read_dir(run_dir) else {
1020 return 0;
1021 };
1022 for entry in entries.flatten() {
1023 let path = entry.path();
1024 if path == keep {
1025 continue;
1026 }
1027 let is_exe = path.extension().and_then(|e| e.to_str()) == Some("exe");
1028 if !is_exe {
1029 continue;
1030 }
1031 let meta = match std::fs::metadata(&path) {
1032 Ok(m) => m,
1033 Err(_) => continue,
1034 };
1035 let mtime = meta.modified().ok();
1036 let old_by_age = mtime
1037 .and_then(|t| now.duration_since(t).ok())
1038 .map(|d| d >= STALE_RUN_AGE)
1039 .unwrap_or(false);
1040 // Суффикс timestamp: `<bin>-<ts>.exe` → ts в миллисекундах. День
1041 // считается как floor(ts_ms / 86_400_000); сравним с «сегодня».
1042 let stale_by_date = {
1043 let file = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
1044 let stem = file.strip_suffix(".exe").unwrap_or(file);
1045 match stem.rfind('-') {
1046 None => false,
1047 Some(dash_pos) => {
1048 let ts_str = &stem[dash_pos + 1..];
1049 ts_str
1050 .parse::<u128>()
1051 .map(|ts| (ts / 86_400_000) != today)
1052 .unwrap_or(false)
1053 }
1054 }
1055 };
1056 if (old_by_age || stale_by_date) && std::fs::remove_file(&path).is_ok() {
1057 pruned += 1;
1058 }
1059 }
1060 pruned
1061}
1062
1063/// Лимит возраста (mtime) для старых копий в `target/hot-run`.
1064const STALE_RUN_AGE: Duration = Duration::from_secs(3600);
1065
1066/// Детектирует рассинхрон между установленным CLI (`env!("CARGO_PKG_VERSION")`
1067/// бинарника) и версией `light-fw-core` в проекте (первоисточник —
1068/// `[workspace.package].version` из `Cargo.toml`; fallback — `Cargo.lock`).
1069/// Возвращает `(installed, project)` только если они различаются.
1070/// Сравнивает две версионные строки по `major.minor`: `"0.15.3"` vs `"0.15"` →
1071/// `true` (одна и та же минорная ветка); `"0.14"` vs `"0.15"` → `false`.
1072fn same_minor(a: &str, b: &str) -> bool {
1073 minor_key(a) == minor_key(b)
1074}
1075
1076/// Извлекает `major.minor` из версионной строки: `"0.15.3"` → `"0.15"`,
1077/// `"0.15"` → `"0.15"`.
1078fn minor_key(v: &str) -> String {
1079 let parts: Vec<&str> = v.splitn(3, '.').collect();
1080 if parts.len() >= 2 {
1081 format!("{}.{}", parts[0], parts[1])
1082 } else {
1083 v.to_string()
1084 }
1085}
1086
1087/// Предупреждение при рассинхроне между установленным CLI и версией
1088/// light-fw-core в проекте (сравнение по `major.minor`).
1089pub fn check_cli_version_mismatch(
1090 installed: &str,
1091 toml_path: &Path,
1092 lock_path: &Path,
1093) -> Option<(String, String)> {
1094 let project = read_fw_core_version_from_workspace_toml(toml_path)
1095 .or_else(|| read_fw_core_version_from_lock(lock_path))?;
1096 if same_minor(installed, &project) {
1097 None
1098 } else {
1099 Some((installed.to_string(), project))
1100 }
1101}
1102
1103/// Точка входа команды `light dev`: запускает дочерний процесс и цикл
1104/// наблюдения. Возвращает код завершения приложения (не `anyhow`, чтобы ядро
1105/// CLI не тягло зависимость, которой нет в его манифесте).
1106///
1107/// Не вызывается автоматически из других частей библиотеки — только из
1108/// бинарного `light-fw-cli` (см. `cmd_dev` в `light-cli`).
1109pub fn run(args: &DevArgs) -> std::result::Result<i32, std::io::Error> {
1110 // ТЗ 0.16.0: `--agent` — автоматически создаём session recorder ДО запуска
1111 // приложения и закрываем его (сгенерировав summary.md) при выходе/kill.
1112 // Обёртка гарантирует завершение сессии на любом пути выхода (в т.ч. panic
1113 // через `?`), потому что `run_inner` вызывается здесь единственно.
1114 let cwd = std::env::current_dir()?;
1115 let agent_session = if args.agent {
1116 match crate::session::start_session(&cwd) {
1117 Ok(id) => {
1118 eprintln!("light dev: agent session started → {:?}", id);
1119 Some(id)
1120 }
1121 Err(e) => {
1122 eprintln!("light dev: не удалось создать agent session: {e}");
1123 None
1124 }
1125 }
1126 } else {
1127 None
1128 };
1129 let code = run_inner(args)?;
1130 if let Some(id) = agent_session
1131 && let Ok(summary) = crate::session::stop_session(&cwd, &id)
1132 {
1133 eprintln!("light dev: agent session summary → {:?}", summary);
1134 }
1135 Ok(code)
1136}
1137
1138fn run_inner(args: &DevArgs) -> std::result::Result<i32, std::io::Error> {
1139 let cwd = std::env::current_dir()?;
1140 // Имя пакета: явное значение из CLI или автоопределение по cwd.
1141 let resolved = args
1142 .package
1143 .clone()
1144 .or_else(|| detect_package_from_cwd(&cwd));
1145 let Some(pkg_name) = resolved else {
1146 eprintln!(
1147 "light dev: cannot determine package; use `light dev <name>` or run inside crate directory"
1148 );
1149 return Ok(1);
1150 };
1151 // Корень пакета: если передан `package`, ищем его через `cargo metadata`,
1152 // иначе — текущий каталог. Именно этот корень передаётся в `watch_paths`.
1153 let package_root = resolve_package_root(args.package.as_deref(), &cwd);
1154 // ТЗ 0.14.2-B-fix: для workspace-членов артефакты cargo кладутся в
1155 // workspace_root/target, а не в package_root/target. Берём workspace_root
1156 // (если доступен) как корень для target/hot-build и target/hot-run;
1157 // fallback — package_root (одиночный крейт без workspace).
1158 let target_root = resolve_workspace_root(&cwd).unwrap_or_else(|| {
1159 eprintln!(
1160 "light dev: `cargo metadata` недоступен — target_dir из package_root {:?}",
1161 package_root
1162 );
1163 package_root.clone()
1164 });
1165 eprintln!("light dev: workspace_root = {:?}", target_root);
1166 // ТЗ 0.14.2-B-fix2: две папки — сборка в `target/hot-build` (cargo её
1167 // никогда не блокирует), запуск из копии в `target/hot-run/<bin>-<ts>.exe`
1168 // (Windows не даёт перезаписать запущенный .exe, но копия свободна).
1169 let build_target = target_root.join("target").join("hot-build");
1170 let run_dir = target_root.join("target").join("hot-run");
1171 let bin = bin_name(&pkg_name);
1172 eprintln!(
1173 "light dev: build_target = {:?}, run_dir = {:?}",
1174 build_target, run_dir
1175 );
1176
1177 // === TZ 0.14.4: управление кэшем до Phase A (warm-up build) ===
1178 // Порядок: (a) `--clean` → полный сброс; (b) auto-invalidation по версии
1179 // light-fw-core (build-info.json); (c) age-привка hot-run; (d) warning
1180 // при рассинхроне CLI/core.
1181 if args.clean {
1182 let removed = clean_dev_cache(&target_root, Some(pkg_name.as_str()));
1183 eprintln!("light dev: cleaned cache (hot-build, hot-run, state, deps, build-info)");
1184 let _ = removed.len();
1185 }
1186 // Auto-invalidation: сравниваем версию light-fw-core (первоисточник —
1187 // [workspace.package].version из Cargo.toml, fallback — Cargo.lock) с тем,
1188 // что зафиксировано в build-info.json. При смене — удаляем target/hot-build
1189 // ОДИН РАЗ, чтобы fresh-артефакт пересобрался с новой версией.
1190 let lock_path = target_root.join("Cargo.lock");
1191 let toml_path = target_root.join("Cargo.toml");
1192 let info_path = build_info_path(&target_root);
1193 let current_version = read_fw_core_version_from_workspace_toml(&toml_path)
1194 .or_else(|| read_fw_core_version_from_lock(&lock_path));
1195 if let Some(current_version) = ¤t_version {
1196 let saved = std::fs::read_to_string(&info_path)
1197 .ok()
1198 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
1199 .and_then(|v| {
1200 v.get("light-fw-core-version")
1201 .and_then(|x| x.as_str())
1202 .map(String::from)
1203 });
1204 // Сравниваем major.minor: `"0.15.3"` vs `"0.15"` → same minor, skip;
1205 // `"0.14"` vs `"0.15"` → different minor, invalidate.
1206 if !same_minor(current_version, saved.as_deref().unwrap_or("")) {
1207 let hot_build = target_root.join("target").join("hot-build");
1208 if hot_build.exists() {
1209 if std::fs::remove_dir_all(&hot_build).is_ok() {
1210 eprintln!(
1211 "light dev: framework version changed ({} → {}) → hot-build cache invalidated",
1212 saved.as_deref().unwrap_or("?"),
1213 current_version
1214 );
1215 } else {
1216 eprintln!("light dev: не удалось удалить target/hot-build после смены версии");
1217 }
1218 } else {
1219 eprintln!(
1220 "light dev: framework version changed ({} → {}) → hot-build cache invalidated",
1221 saved.as_deref().unwrap_or("?"),
1222 current_version
1223 );
1224 }
1225 // Записываем текущую версию, чтобы следующая сборка была «чистой».
1226 let lock_hash = std::fs::read_to_string(&lock_path)
1227 .ok()
1228 .map(|s| hex::encode(sha2::Sha256::digest(s.as_bytes())))
1229 .unwrap_or_default();
1230 save_build_info(&info_path, current_version, &lock_hash);
1231 }
1232 } else {
1233 eprintln!(
1234 "light dev: light-fw-core не найден в Cargo.toml/Cargo.lock — skip auto-invalidation"
1235 );
1236 }
1237 // Age-привка hot-run: на старте дочернего процесса ещё нет, но старые
1238 // артефакты (от прошлого запуска) уже лежат в hot-run. Удаляем их, пока
1239 // они не мешают. Возвращаем число удалённых.
1240 let pruned = prune_stale_run_exes(&run_dir, Path::new("/nonexistent"));
1241 if pruned > 0 {
1242 eprintln!("light dev: pruned {pruned} stale executables from hot-run");
1243 }
1244 // Предупреждение при рассинхроне между установленным CLI и версией
1245 // light-fw-core, зафиксированной в проекте.
1246 if let Some((installed, project)) =
1247 check_cli_version_mismatch(env!("CARGO_PKG_VERSION"), &toml_path, &lock_path)
1248 {
1249 eprintln!(
1250 "⚠ light dev: installed CLI version {installed} differs from project {project}. Run `cargo install --path crates/light`"
1251 );
1252 }
1253
1254 // === ФАЗА A: warm-up build в target/hot-build ===
1255 // Блокирующий `cargo build` в отдельный target-dir. При ошибке — сразу
1256 // exit 1 (старого процесса нет, «оставлять живым» нечего).
1257 {
1258 // ТЗ 0.16.1, задача 4: статус на stdout до и после сборки, чтобы
1259 // агент не думал, что «зависло» (раньше выходил только stderr).
1260 println!("⏳ Сборка (cargo build) — target {:?}…", build_target);
1261 let mut cmd = Command::new("cargo");
1262 cmd.arg("build");
1263 cmd.args(["-p", &pkg_name, "--features", "light-fw/dev-mode"]);
1264 cmd.args(["--target-dir", &build_target.to_string_lossy()]);
1265 cmd.args(&args.extra_cargo_args);
1266 cmd.stdin(Stdio::inherit())
1267 .stdout(Stdio::inherit())
1268 .stderr(Stdio::inherit());
1269
1270 // ТЗ 0.17.4: спиннер — каждые 2 с обновляем elapsed-время в stderr,
1271 // чтобы пользователь видел прогресс длительной сборки.
1272 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1273 let spinner = {
1274 use std::io::Write as _;
1275 use std::sync::atomic::Ordering;
1276 let stop2 = stop.clone();
1277 thread::spawn(move || {
1278 let start = Instant::now();
1279 loop {
1280 thread::sleep(Duration::from_secs(2));
1281 if stop2.load(Ordering::Relaxed) {
1282 break;
1283 }
1284 let elapsed = start.elapsed().as_secs();
1285 let mut err = std::io::stderr().lock();
1286 let _ = write!(err, "\r⏳ Сборка (cargo build) ({}с)", elapsed);
1287 let _ = err.flush();
1288 }
1289 let mut err = std::io::stderr().lock();
1290 let _ = write!(err, "\r\x1b[2K");
1291 let _ = err.flush();
1292 })
1293 };
1294
1295 let status = match cmd.spawn() {
1296 Ok(mut child) => {
1297 let s = child.wait();
1298 stop.store(true, std::sync::atomic::Ordering::Relaxed);
1299 let _ = spinner.join();
1300 s
1301 }
1302 Err(e) => {
1303 stop.store(true, std::sync::atomic::Ordering::Relaxed);
1304 let _ = spinner.join();
1305 Err(e)
1306 }
1307 };
1308 match status {
1309 Ok(s) if s.success() => println!("✅ Сборка завершена, запускаю приложение…"),
1310 Ok(s) => {
1311 eprintln!(
1312 "light dev: warm-up build FAILED (exit {:?}) — exit 1",
1313 s.code()
1314 );
1315 return Ok(1);
1316 }
1317 Err(e) => {
1318 eprintln!("light dev: не удалось запустить `cargo build`: {e} — exit 1");
1319 return Ok(1);
1320 }
1321 }
1322 // ТЗ 0.14.2-B-fix 4: после успешного build проверим, что бинарник
1323 // действительно лежит там, где мы его ищем. Если нет — путь расхожился.
1324 let fresh_bin = fresh_bin_path(&build_target, &bin);
1325 if std::fs::metadata(&fresh_bin).is_err() {
1326 eprintln!(
1327 "light dev: BUG — build succeeded but binary not at {:?}. \
1328 Search in {:?}...",
1329 fresh_bin,
1330 build_target.join("debug")
1331 );
1332 return Ok(1);
1333 }
1334 }
1335
1336 // === ФАЗА B: копия в target/hot-run + spawn ===
1337 // Запускаем из копии, а не из hot-build: следующая сборка перезапишет
1338 // fresh-бинарник без os error 5 (запущен процесс с другой, свободной
1339 // копии).
1340 let first_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
1341 Ok(p) => p,
1342 Err(e) => {
1343 eprintln!("light dev: не удалось скопировать бинарник: {e} — exit 1");
1344 return Ok(1);
1345 }
1346 };
1347 eprintln!(
1348 "light dev: spawning {:?} (build {:?})",
1349 first_run_exe,
1350 fresh_bin_path(&build_target, &bin)
1351 );
1352 // ТЗ 0.14.3-fix7: cold start не применяет ни state, ни geometry (см.
1353 // `resumed`/`create_window` в core). Чтобы они гарантированно не подтянули
1354 // устаревшие значения из прошлой сессии, удаляем оба файла до запуска
1355 // первого warm-up бинарника. (При user-close supervisor их тоже удаляет —
1356 // это двойная страховка на случай, если прошлый процесс завершился грубо.)
1357 for stale in [
1358 state_file_path(&package_root),
1359 window_file_path(&package_root),
1360 ] {
1361 if std::fs::remove_file(&stale).is_ok() {
1362 eprintln!("light dev: cold start — cleared stale {:?}", stale);
1363 }
1364 }
1365 let mut child_cmd = Command::new(&first_run_exe);
1366 child_cmd.stdin(Stdio::inherit());
1367 child_cmd.stdout(Stdio::inherit());
1368 child_cmd.stderr(Stdio::inherit());
1369 apply_state_env(&mut child_cmd, &package_root);
1370 apply_window_env(&mut child_cmd, &package_root);
1371 // ТЗ 0.14.3-fix5 (откат fix4): на cold start state.json НЕ применяется —
1372 // дефолты из нового кода имеют приоритет над устаревшим state. При swap
1373 // состояние сохраняется (см. блок ниже): LIGHT_RESUME_FROM_SWAP=1 +
1374 // LIGHT_PRESERVE_STATE=1, поэтому count переживает .rs change, а
1375 // вычисляемые значения (методы, например status) пересчитываются из нового
1376 // кода. --preserve-state оставлен как алиас (те же эффекты, что и default).
1377 let child = match child_cmd.spawn() {
1378 Ok(c) => c,
1379 Err(e) => {
1380 eprintln!(
1381 "light dev: не удалось запустить {:?}: {e} — exit 1",
1382 first_run_exe
1383 );
1384 return Ok(1);
1385 }
1386 };
1387
1388 // ТЗ 0.14.2-B: общая обёртка для thread-safe доступа (main loop + rebuild
1389 // thread). `child_arc` — приложение, которое сейчас работает (или будет
1390 // после swap); `rebuild_arc` — текущая фоновая сборка (или None, если её
1391 // нет). Rebuild запускаем из main-потока и просто опрашиваем `try_wait()`
1392 // в главном цикле — отдельный тред не нужен (Cargo сам параллелит).
1393 let child_arc = Arc::new(Mutex::new(child));
1394 let rebuild_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
1395
1396 // State-machine: решение о реакции на результат фоновой сборки. Держим
1397 // в main-потоке (swap выполняется там); чистый, без side effects.
1398 let mut sm = RebuildStateMachine::default();
1399 // ТЗ 0.14.3-fix7: kill_child (kill + wait) синхронно потребляет exit
1400 // убитого swap-процесса, так что Ev::Exited в главном цикле получает
1401 // только события от «живых» детей (user-close или crash). Флаг pending_swap
1402 // больше не нужен — все exit 0 в Ev::Exited = user-close → удалить файлы.
1403 // Канал на перезапуск от наблюдателя. Несёт тип изменения: Html →
1404 // RELOAD_TEMPLATE (без убийства), Source → background rebuild + swap.
1405 let (restart_tx, restart_rx) = mpsc::channel::<(Instant, ChangeKind)>();
1406 // Флаг завершения: общий между главным потоком, наблюдателем и
1407 // rebuild-тредом. Атомарный (в отличие от mpsc::Receiver) — легко
1408 // шараится между несколькими тредами без clone() канала.
1409 let done_flag = Arc::new(AtomicBool::new(false));
1410 // Состояние наблюдателя (is_starting/pending_change) и канал на его
1411 // обновление. Главный поток решает, когда закрывать окно игнорирования:
1412 // как только порт 9229 открылся или прошёл STARTUP_IGNORE.
1413 let (state_tx, state_rx) = mpsc::channel::<WatchState>();
1414 let _ = state_tx.send(WatchState::starting());
1415
1416 let watch_paths_copy = watch_paths(&package_root);
1417 eprintln!("light dev: watching {:?}", watch_paths_copy);
1418 let verbose = args.verbose;
1419 let done_flag_watch = done_flag.clone();
1420 let watcher = thread::spawn(move || {
1421 if watch_paths_copy.is_empty() {
1422 eprintln!("light dev: нет путей для наблюдения (нет src/, templates/, Cargo.toml)");
1423 return;
1424 }
1425 let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
1426 // PollWatcher: периодическое опросование файловой системы. В отличие от
1427 // нативного `recommended_watcher`, работает на UNC- и сетевых дисках,
1428 // где нативный механизм события просто не ловит.
1429 let config = Config::default().with_poll_interval(POLL_INTERVAL);
1430 let mut watcher = match PollWatcher::new(
1431 move |res| {
1432 let _ = tx.send(res);
1433 },
1434 config,
1435 ) {
1436 Ok(w) => w,
1437 Err(e) => {
1438 eprintln!("light dev: не удалось создать файловый наблюдатель: {e}");
1439 return;
1440 }
1441 };
1442 for path in &watch_paths_copy {
1443 if let Err(e) = watcher.watch(path, RecursiveMode::NonRecursive) {
1444 eprintln!("light dev: не удалось следить за {}: {e}", path.display());
1445 }
1446 }
1447 // Начальное состояние: is_starting = true. Watcher сам решает, когда
1448 // закрывать окно: как только порт DevTools открылся либо прошёл
1449 // дедлайн STARTUP_IGNORE (защита от вечной блокировки при зависшей
1450 // сборке). Principal-поток лишь сбрасывает state в `starting()` при
1451 // перезапуске процесса.
1452 let mut state = match state_rx.recv() {
1453 Ok(s) => s,
1454 Err(_) => return,
1455 };
1456 // Момент запуска приложения: используется для дедлайна STARTUP_IGNORE.
1457 // Пересбрасывается при каждом новом запуске дочернего процесса (главный
1458 // поток шлёт `WatchState::starting()` через state_rx).
1459 let mut start_at = Instant::now();
1460 // Сбрасывает дебаунс; актуально только вне окна `is_starting`.
1461 // Отдельный таймер для каждого типа изменения (Html vs Source).
1462 let mut last_html: Option<Instant> = None;
1463 let mut last_rs: Option<Instant> = None;
1464 // Дедупликация по хэшу содержимого: один «логический» save генерирует
1465 // несколько событий notify (Modify + Modify(Data) + Modify(Metadata)).
1466 // Ключевой порядок (ТЗ 0.14.2-A-fix4): debounce СНАЧАЛА (файл успевает
1467 // дописаться), затем hash-фильтр (повторные события того же
1468 // содержимого отброшены), затем handle_watch_event.
1469 let mut last_hashes: std::collections::HashMap<PathBuf, u64> =
1470 std::collections::HashMap::new();
1471 // Накопитель путей, ожидающих debounce. На событие notify мы НЕ
1472 // хэшируем сразу (файл может быть в середине записи); только сохраняем
1473 // путь. Когда debounce срабатывает (100/500 мс тишины), проходим по
1474 // pending_debounce, хэшируем и дедуплицируем.
1475 let mut pending_debounce: std::collections::VecDeque<PathBuf> =
1476 std::collections::VecDeque::new();
1477 // Приоритет pending-флагов (Source > Html): если оба были накоплены,
1478 // применяем source (пересборка важнее reload).
1479 let apply_pending = |state: &mut WatchState,
1480 tx: &mpsc::Sender<(Instant, ChangeKind)>|
1481 -> Option<ChangeKind> {
1482 let kind = resolve_startup_completion(state)?;
1483 let _ = tx.send((Instant::now(), kind));
1484 state.clear_pending();
1485 Some(kind)
1486 };
1487 loop {
1488 // 1) Завершение от основного потока.
1489 if done_flag_watch.load(Ordering::Relaxed) {
1490 break;
1491 }
1492 // 2) Обновление состояния от главного потока (при перезапуске
1493 // приложение сбрасывает state в `starting()`).
1494 if let Ok(new_state) = state_rx.try_recv() {
1495 state = new_state;
1496 start_at = Instant::now();
1497 last_html = None;
1498 last_rs = None;
1499 // Сбрасываем накопленные пути: предыдущий запуск дочернего
1500 // процесса завершился, и все «недочитанные» изменения теряются.
1501 pending_debounce.clear();
1502 continue;
1503 }
1504 // 3) Окно игнорирования: порт 9229 открылся или дедлайн прошёл.
1505 // Применяем накопленный pending (если есть) как ReloadHtml /
1506 // Restart — без эвристики «обнаружены изменения».
1507 if state.is_starting {
1508 let port_open = is_port_open(DEVTOOLS_PORT);
1509 let deadline_reached = start_at.elapsed() >= STARTUP_IGNORE;
1510 if port_open || deadline_reached {
1511 let reason = if port_open {
1512 "порт DevTools открыт"
1513 } else {
1514 &format!("дедлайн {:?} истёк", STARTUP_IGNORE)
1515 };
1516 eprintln!(
1517 "light dev: окно игнорирования снято ({reason}), \
1518 очередь pending (HTML={}, Source={})",
1519 state.pending_html, state.pending_source
1520 );
1521 if let Some(kind) = apply_pending(&mut state, &restart_tx) {
1522 match kind {
1523 ChangeKind::Html => last_html = Some(Instant::now()),
1524 ChangeKind::Source => last_rs = Some(Instant::now()),
1525 }
1526 }
1527 state.is_starting = false;
1528 }
1529 continue;
1530 }
1531 // 3) События наблюдателя.
1532 match rx.recv_timeout(Duration::from_millis(50)) {
1533 Ok(Ok(event)) => {
1534 // На каждое notify-событие мы НЕ хэшируем сразу: файл может
1535 // быть в середине записи (двухэтапный append + flush), и
1536 // хэш поймает неполное содержимое. Вместо этого:
1537 // 1) фильтруем по watch_paths;
1538 // 2) классифицируем (Html / Source / None) для лога;
1539 // 3) кладём путь в pending_debounce (deduplication по пути,
1540 // чтобы серия событий одного save не раздувала очередь);
1541 // 4) поднимаем таймер debounce (Html / Source).
1542 //
1543 // Хэширование + дедупликация happen в timeout-ветке, ПОСЛЕ
1544 // debounce — к тому моменту файл уже полностью записан.
1545 for path in &event.paths {
1546 if !watch_paths_copy.iter().any(|w| path.starts_with(w)) {
1547 continue;
1548 }
1549 // Лог по ТЗ 3.1: сразу, до debounce.
1550 let kind_log = classify_change(path);
1551 if verbose {
1552 eprintln!(
1553 "light dev: notify event path={path:?} kind={kind_log:?} \
1554 (queued for debounce)"
1555 );
1556 }
1557 // Deduplicate by path within the queue: если тот же путь
1558 // уже в pending_debounce, не добавляем повторно (серия
1559 // событий одного save генерирует 2-3 notify на один путь).
1560 if !pending_debounce.contains(path) {
1561 pending_debounce.push_back(path.clone());
1562 }
1563 }
1564 // Поднимаем таймеры debounce: если любое из релевантных
1565 // изменений пришло, debounce обнуляется и начинает отсчёт
1566 // заново (стандартный trailing-edge debounce).
1567 if event
1568 .paths
1569 .iter()
1570 .any(|p| watch_paths_copy.iter().any(|w| p.starts_with(w)))
1571 {
1572 let has_html = event.paths.iter().any(|p| {
1573 watch_paths_copy.iter().any(|w| p.starts_with(w))
1574 && matches!(classify_change(p), Some(ChangeKind::Html))
1575 });
1576 let has_src = event.paths.iter().any(|p| {
1577 watch_paths_copy.iter().any(|w| p.starts_with(w))
1578 && matches!(classify_change(p), Some(ChangeKind::Source))
1579 });
1580 if has_html {
1581 last_html = Some(Instant::now());
1582 }
1583 if has_src {
1584 last_rs = Some(Instant::now());
1585 }
1586 }
1587 }
1588 Ok(Err(e)) => {
1589 eprintln!("light dev: ошибка наблюдателя: {e}");
1590 }
1591 Err(mpsc::RecvTimeoutError::Timeout) => {
1592 // Дебаунс + hash-дедупликация ПОСЛЕ debounce.
1593 //
1594 // Порядок (ТЗ 0.14.2-A-fix4):
1595 // 1) debounce: дождались тишины (HTML_DEBOUNCE / RS_DEBOUNCE);
1596 // 2) hash: хэшируем содержимое каждого пути из pending_debounce
1597 // (файл уже полностью записан, потому что прошло ≥ 100/500 мс);
1598 // 3) dedup: отбрасываем пути, чей хэш == last_hashes[path]
1599 // (повторные события того же содержимого);
1600 // 4) dispatch: берём ПЕРВЫЙ «чистый» путь, классифицируем и
1601 // передаём в handle_watch_event.
1602 //
1603 // Важно: если pending_debounce пусто — ничего не делаем, даже
1604 // если таймеры debounce истекли (таймеры поднимаются только
1605 // при добавлении путей, так что это ситуация невозможна, но
1606 // защита на случай race с respawn).
1607 if !state.is_starting && !state.has_pending() {
1608 let html_fired =
1609 matches!(last_html, Some(t) if t.elapsed() >= HTML_DEBOUNCE);
1610 let rs_fired = matches!(last_rs, Some(t) if t.elapsed() >= RS_DEBOUNCE);
1611 if html_fired || rs_fired {
1612 if html_fired {
1613 last_html = None;
1614 }
1615 if rs_fired {
1616 last_rs = None;
1617 }
1618 if verbose {
1619 eprintln!(
1620 "light dev: debounce fired, {} paths pending",
1621 pending_debounce.len()
1622 );
1623 }
1624 // Проходим по pending_debounce: хэшируем и дедуплицируем.
1625 // Файл уже полностью записан (debounce отработал).
1626 let mut clean_events: std::collections::VecDeque<PathBuf> =
1627 std::collections::VecDeque::new();
1628 while let Some(path) = pending_debounce.pop_front() {
1629 match file_hash(&path, verbose) {
1630 Some(hash) => {
1631 // Сравниваем с последним известным хэшем
1632 // этого пути:
1633 // - совпадает → повторное событие того же
1634 // содержимого → игнорируем (по ТЗ:
1635 // "duplicate notify event, ignored");
1636 // - отличается → настоящее изменение →
1637 // обновляем last_hashes и кладём в
1638 // clean_events.
1639 match last_hashes.get(&path) {
1640 Some(&last) if last == hash => {
1641 if verbose {
1642 eprintln!(
1643 "light dev: duplicate notify event for \
1644 {path:?} (hash unchanged) — ignored"
1645 );
1646 }
1647 }
1648 _ => {
1649 last_hashes.insert(path.clone(), hash);
1650 clean_events.push_back(path);
1651 }
1652 }
1653 }
1654 // Файл недоступен (удалён, ещё пишется после
1655 // 3 попыток чтения): НЕ дедуплицируем —
1656 // обрабатываем как изменение (консервативно).
1657 None => {
1658 if verbose {
1659 eprintln!(
1660 "light dev: file_hash returned None for \
1661 {path:?} (treated as real change, no dedup)"
1662 );
1663 }
1664 clean_events.push_back(path);
1665 }
1666 }
1667 }
1668 if verbose {
1669 eprintln!(
1670 "light dev: {} clean events after hash-dedup",
1671 clean_events.len()
1672 );
1673 }
1674 // Берём ПЕРВЫЙ чистый путь, классифицируем и
1675 // передаём в handle_watch_event (по ТЗ 1.3).
1676 if let Some(first) = clean_events.pop_front() {
1677 let kind = classify_change(&first);
1678 // Лог по ТЗ 3.4: при передаче в handle.
1679 eprintln!("light dev: dispatching {kind:?} change for {first:?}");
1680 match handle_watch_event(&mut state, kind) {
1681 WatchAction::None => {
1682 // Изменение во время сборки: просто
1683 // запомнили (pending_флаги подняты внутри
1684 // handle_watch_event).
1685 }
1686 WatchAction::ApplyPending => {
1687 if let Some(akind) = apply_pending(&mut state, &restart_tx)
1688 {
1689 eprintln!(
1690 "light dev: применяю pending (kind={akind:?})"
1691 );
1692 match akind {
1693 ChangeKind::Html => {
1694 last_html = Some(Instant::now());
1695 }
1696 ChangeKind::Source => {
1697 last_rs = Some(Instant::now());
1698 }
1699 }
1700 }
1701 }
1702 WatchAction::ReloadHtml => {
1703 eprintln!(
1704 "light dev: HTML change → RELOAD_TEMPLATE \
1705 (без пересборки)"
1706 );
1707 let _ = restart_tx.send((Instant::now(), ChangeKind::Html));
1708 last_html = Some(Instant::now());
1709 }
1710 WatchAction::Restart => {
1711 eprintln!(
1712 "light dev: Source change → RESTART \
1713 (пересборка)"
1714 );
1715 let _ =
1716 restart_tx.send((Instant::now(), ChangeKind::Source));
1717 last_rs = Some(Instant::now());
1718 }
1719 }
1720 } else {
1721 if verbose {
1722 eprintln!(
1723 "light dev: все события отфильтрованы hash-дедупом — \
1724 action не требуется"
1725 );
1726 }
1727 }
1728 }
1729 }
1730 }
1731 Err(mpsc::RecvTimeoutError::Disconnected) => break,
1732 }
1733 }
1734 });
1735
1736 loop {
1737 // Дедлайн защиты от вечной блокировки: если порт 9229 так и не
1738 // открылся за STARTUP_IGNORE (сборка зависла), watcher сам закроет
1739 // окно игнорирования (он владеет pending-флагами и знает, что
1740 // применить). Главный поток здесь ничего не решает.
1741 //
1742 // Внутренний цикл опроса: выходим в наружный цикл по первому событию
1743 // (restart от наблюдателя, завершение фоновой сборки либо выход
1744 // дочернего процесса).
1745 //
1746 // ТЗ 0.14.2-B: при .rs change мы НЕ убиваем старый процесс сразу —
1747 // запускаем `cargo build` в `target/hot-build` (рекурсивно, но не
1748 // блокируй watcher: он живёт в своём треде), а когда сборка закончится:
1749 // - exit 0 → SAVE_STATE, copy fresh → hot-run, kill old, spawn new
1750 // - exit != 0 → старый продолжает работать; stderr cargo уже на консоли.
1751 enum Ev {
1752 Restart(ChangeKind),
1753 RebuildDone(i32),
1754 Exited(std::process::ExitStatus),
1755 }
1756 let ev = loop {
1757 if let Ok((_ts, kind)) = restart_rx.try_recv() {
1758 break Ev::Restart(kind);
1759 }
1760 // Завершение фоновой сборки: poll rebuild-child в основном потоке.
1761 // `lock().ok()` — при poisoned mutex (краш в другом треде) просто
1762 // пропускаем опрос; main продолжит работать с app-процессом.
1763 let rebuild_done = rebuild_arc.lock().ok().and_then(|mut g| match g.as_mut() {
1764 Some(c) => c.try_wait().ok().flatten(),
1765 None => None,
1766 });
1767 if let Some(status) = rebuild_done {
1768 let code = status.code().unwrap_or(1);
1769 if let Ok(mut g) = rebuild_arc.lock() {
1770 *g = None;
1771 }
1772 break Ev::RebuildDone(code);
1773 }
1774 // Опрос дочернего процесса: если mutex poisoned — пропускаем tick;
1775 // если `try_wait` вернул Err — тоже выходим с exit code 1 (не
1776 // продолжаем работать с процессом, который не удалось опросить).
1777 let child_exit = match child_arc.lock() {
1778 Ok(mut g) => match g.try_wait() {
1779 Ok(Some(s)) => Some(Ok(s)),
1780 Ok(None) => None, // ещё жив
1781 Err(e) => Some(Err(e)),
1782 },
1783 Err(_) => None,
1784 };
1785 match child_exit {
1786 Some(Ok(s)) => break Ev::Exited(s),
1787 Some(Err(e)) => {
1788 eprintln!("light dev: ошибка ожидания дочернего процесса: {e}");
1789 break Ev::Exited(ExitStatus::from_raw(1));
1790 }
1791 None => {
1792 // Mutex poisoned или process ещё жив.
1793 if child_arc.lock().is_err() {
1794 eprintln!("light dev: child_arc poisoned (пропуск опроса)");
1795 }
1796 }
1797 }
1798 thread::sleep(Duration::from_millis(50));
1799 };
1800 match ev {
1801 Ev::Exited(status) => {
1802 done_flag.store(true, Ordering::Relaxed);
1803 let _ = watcher.join();
1804 // Если rebuild ещё идёт — тоже убиваем (Ctrl+C-сценарий).
1805 if let Ok(mut g) = rebuild_arc.lock()
1806 && let Some(mut r) = g.take()
1807 {
1808 let _ = r.kill();
1809 let _ = r.wait();
1810 }
1811 let code = status.code().unwrap_or(1);
1812 // ТЗ 0.14.3-fix7: Ev::Exited получает только события от
1813 // «живых» детей (kill_child синхронно потребляет exit убитого
1814 // swap-процесса). Exit 0 → user-close: стираем state + geometry.
1815 // Exit != 0 → crash: файлы не трогаем.
1816 if code == 0 {
1817 for stale in [
1818 state_file_path(&package_root),
1819 window_file_path(&package_root),
1820 ] {
1821 if std::fs::remove_file(&stale).is_ok() {
1822 eprintln!("light dev: removed {:?}", stale);
1823 }
1824 }
1825 eprintln!("light dev: user close — state + geometry removed");
1826 eprintln!(
1827 "light dev: app exited cleanly (window closed by user or session end)"
1828 );
1829 } else {
1830 eprintln!("light dev: app crashed (exit code {code})");
1831 }
1832 return Ok(code);
1833 }
1834 Ev::RebuildDone(exit_code) => {
1835 // Фоновая сборка закончилась. State-machine решает: swap или keep-old.
1836 let outcome = sm.poll_outcome(exit_code);
1837 if let RebuildOutcome::Success = outcome {
1838 // === SWAP: SAVE_STATE → copy fresh → kill old → spawn new ===
1839 // Порядок (ТЗ 0.14.2-B-fix2 3): copy работает и на живом
1840 // exe (это отдельный файл), kill — после copy, но до spawn
1841 // нового (чтобы не было двух видимых окон).
1842 let took = sm
1843 .started_at
1844 .map(|t| Instant::now().saturating_duration_since(t))
1845 .unwrap_or_default();
1846 eprintln!("light dev: rebuild succeeded (took {took:?}), swapping…");
1847 if let Err(e) = request_save_state(DEVTOOLS_PORT) {
1848 eprintln!(
1849 "light dev: не удалось отправить SAVE_STATE перед swap: {e} \
1850 (состояние может быть не сохранено)"
1851 );
1852 } else {
1853 eprintln!(
1854 "light dev: state saved to {:?}",
1855 state_file_path(&package_root)
1856 );
1857 }
1858 // Короткая пауза: даём UI-потоку время обработать команду
1859 // и записать файл, прежде чем уберём процесс.
1860 thread::sleep(Duration::from_millis(200));
1861 // a) Копируем свежий бинарник из hot-build в hot-run.
1862 // Никто не держит fresh-бинарник (запущена старая
1863 // копия в hot-run), run_exe новое имя — можно писать.
1864 let new_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
1865 Ok(p) => p,
1866 Err(e) => {
1867 eprintln!(
1868 "light dev: не удалось скопировать свежий бинарник: {e} \
1869 (old остаётся)"
1870 );
1871 continue;
1872 }
1873 };
1874 eprintln!("light dev: copied fresh build to {:?}", new_run_exe);
1875 // ТЗ 0.15.1-fix3: graceful save перед kill — отправляем
1876 // SAVE_ALL и ожидаем ok (до 500 мс). UI-поток синхронно
1877 // пишет state.json + window.json. Если таймаут/ошибка —
1878 // продолжаем как раньше (TerminateProcess без graceful).
1879 request_save_all(DEVTOOLS_PORT);
1880 // b) Убиваем старый app (через MutexGuard; `kill_child`
1881 // принимает `&mut Child`).
1882 let old_pid = child_arc.lock().map(|g| g.id()).unwrap_or(0);
1883 if let Ok(mut g) = child_arc.lock() {
1884 kill_child(&mut g);
1885 } else {
1886 eprintln!("light dev: child_arc poisoned — не смогла убить old");
1887 }
1888 // c) Spawn нового из копии в hot-run.
1889 let mut new_cmd = Command::new(&new_run_exe);
1890 new_cmd.stdin(Stdio::inherit());
1891 new_cmd.stdout(Stdio::inherit());
1892 new_cmd.stderr(Stdio::inherit());
1893 apply_state_env(&mut new_cmd, &package_root);
1894 apply_window_env(&mut new_cmd, &package_root);
1895 // Swap-процесс не должен удалять state.json при закрытии
1896 // (state уже сохранён предыдущим процессом через SAVE_STATE
1897 // или будет восстановлен из файла).
1898 new_cmd.env("LIGHT_SWAP", "1");
1899 // ТЗ 0.14.3-fix5 (откат fix4): по умолчанию состояние
1900 // СОХРАНЯЕТСЯ при .rs change (как до fix4): child
1901 // восстанавливает count из state.json, а вычисляемые
1902 // значения (методы, например status) пересчитываются из
1903 // нового кода. --preserve-state — алиас (те же эффекты),
1904 // поэтому блок не зависит от флага.
1905 new_cmd.env("LIGHT_RESUME_FROM_SWAP", "1");
1906 new_cmd.env("LIGHT_PRESERVE_STATE", "1");
1907 if args.preserve_state {
1908 eprintln!("light dev: .rs changed → state preserved (--preserve-state)");
1909 } else {
1910 eprintln!("light dev: .rs changed → state preserved (default)");
1911 }
1912 eprintln!("light dev: clean exit during swap — state.json kept");
1913 match new_cmd.spawn() {
1914 Ok(c) => {
1915 let new_pid = c.id();
1916 if let Ok(mut g) = child_arc.lock() {
1917 *g = c;
1918 } else {
1919 eprintln!(
1920 "light dev: child_arc poisoned — не записала новый process"
1921 );
1922 }
1923 // ТЗ 0.14.3-fix7: старый child уже убив синхронно
1924 // (kill_child → kill + wait), так что его exit НЕ
1925 // попадёт в Ev::Exited. Новый child — обычный:
1926 // если пользователь закроет X → user-close → удалить
1927 // state + geometry. pending_swap остаётся false.
1928 // Чистим старые копии (кроме текущей run_exe) +
1929 // age-привка: убиваем всё, что старше STALE_RUN_AGE
1930 // или чей суффикс-timestamp не совпадает с «сегодня».
1931 cleanup_run_dir(&run_dir, &new_run_exe);
1932 let pruned = prune_stale_run_exes(&run_dir, &new_run_exe);
1933 if pruned > 0 {
1934 eprintln!(
1935 "light dev: pruned {pruned} stale executables from hot-run"
1936 );
1937 }
1938 // Сбрасываем watcher в is_starting: он сам решит,
1939 // когда закрывать окно (порт 9229 открылся либо
1940 // дедлайн STARTUP_IGNORE прошёл).
1941 let _ = state_tx.send(WatchState::starting());
1942 eprintln!(
1943 "light dev: spawning {:?} (build {:?})",
1944 new_run_exe,
1945 fresh_bin_path(&build_target, &bin)
1946 );
1947 eprintln!("light dev: swap done (pid {old_pid} -> {new_pid})");
1948 }
1949 Err(e) => {
1950 eprintln!("light dev: не удалось запустить новое приложение: {e}");
1951 done_flag.store(true, Ordering::Relaxed);
1952 let _ = watcher.join();
1953 return Ok(1);
1954 }
1955 }
1956 }
1957 // RebuildOutcome::Failed(_): старый процесс остаётся; stderr
1958 // cargo уже на консоли. Watcher НЕ сбрасываем (старое app
1959 // работает, is_starting не нужен).
1960 }
1961 Ev::Restart(kind) => match kind {
1962 ChangeKind::Html => {
1963 // HTML-чнейдж → runtime-reload (RELOAD_TEMPLATE) без
1964 // убийства процесса. При неудаче (app умер, порт недоступен)
1965 // НЕ триггерим полную пересборку: ждём, пока TCP снова
1966 // станет доступен (poll каждые 200 мс, до 3 с), и повторяем
1967 // RELOAD_TEMPLATE. Если так и не поднимется — чнейдж
1968 // потеряется до следующего изменения (без лишних 4-5 сек
1969 // на пустой restart).
1970 eprintln!("light dev: HTML change → RELOAD_TEMPLATE (без пересборки)…");
1971 if let Err(e) = request_reload_template_with_retry(DEVTOOLS_PORT) {
1972 eprintln!(
1973 "light dev: RELOAD_TEMPLATE так и не был доставлен: {e} \
1974 (HTML-чнейдж потерян до следующего изменения)"
1975 );
1976 } else {
1977 eprintln!("light dev: RELOAD_TEMPLATE доставлен — продолжаю watch");
1978 }
1979 }
1980 ChangeKind::Source => {
1981 // Source-чнейдж (.rs / Cargo.toml) → seamless rebuild.
1982 //
1983 // 1) Если уже идёт сборка — убиваем её (новый save важнее).
1984 // Лог «cancelled in-flight rebuild (new save)».
1985 // 2) Поднимаем flag + таймер в state-machine.
1986 // 3) Запускаем `cargo build --target-dir target/hot-build`
1987 // в фоне (non-blocking), сохраняем Child в rebuild_arc.
1988 // 4) Главный цикл продолжит опрашивать — на Ev::RebuildDone
1989 // сделает swap (success) или промолчит (failure).
1990 let had_in_flight = {
1991 let old = rebuild_arc.lock().ok().and_then(|mut g| g.take());
1992 old.map(|mut c| {
1993 let _ = c.kill();
1994 let _ = c.wait();
1995 true
1996 })
1997 .unwrap_or(false)
1998 };
1999 if had_in_flight {
2000 eprintln!("light dev: cancelled in-flight rebuild (new save)");
2001 }
2002 eprintln!("light dev: rebuild started (old app still running)");
2003 sm.start();
2004 // Собираем команду для background build. `build_target` —
2005 // тот же путь, что и в warm-up (workspace_root/hot-build).
2006 let mut cmd = Command::new("cargo");
2007 cmd.arg("build");
2008 cmd.args(["-p", &pkg_name, "--features", "light-fw/dev-mode"]);
2009 cmd.args(["--target-dir", &build_target.to_string_lossy()]);
2010 cmd.args(&args.extra_cargo_args);
2011 cmd.stdin(Stdio::inherit())
2012 .stdout(Stdio::inherit())
2013 .stderr(Stdio::inherit());
2014 // Запускаем; если spawn упал — считаем ошибкой (old живёт).
2015 match cmd.spawn() {
2016 Ok(c) => {
2017 if let Ok(mut g) = rebuild_arc.lock() {
2018 *g = Some(c);
2019 } else {
2020 eprintln!(
2021 "light dev: rebuild_arc poisoned — не записала новый build"
2022 );
2023 }
2024 }
2025 Err(e) => {
2026 eprintln!("light dev: не удалось запустить `cargo build`: {e}");
2027 // Сбрасываем флаг: сборка не стартовала, old остаётся.
2028 sm.poll_outcome(1);
2029 }
2030 }
2031 }
2032 },
2033 }
2034 }
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039 use super::{
2040 ChangeKind, RebuildOutcome, RebuildStateMachine, WatchAction, WatchState, bin_name,
2041 build_info_path, check_cli_version_mismatch, classify_change, clean_dev_cache,
2042 cleanup_run_dir, copy_fresh_to_run, file_hash, fresh_bin_path, handle_watch_event,
2043 minor_key, parse_dev_command, parse_workspace_package_version, prune_stale_run_exes,
2044 read_fw_core_version_from_lock, read_fw_core_version_from_workspace_toml,
2045 resolve_package_root, resolve_startup_completion, run_exe_path, same_minor,
2046 save_build_info, wait_port_available,
2047 };
2048 use std::io::Write as _;
2049 use std::net::TcpListener;
2050 use std::path::{Path, PathBuf};
2051 use std::time::{Duration, Instant};
2052
2053 // ===================== handle_watch_event / resolve =====================
2054
2055 /// Нерелевантное событие (kind=None): ни действий, ни изменения флагов.
2056 #[test]
2057 fn watch_event_irrelevant_does_nothing() {
2058 let mut s = WatchState::starting();
2059 assert_eq!(handle_watch_event(&mut s, None), WatchAction::None);
2060 assert!(s.is_starting);
2061 assert!(!s.pending_html);
2062 assert!(!s.pending_source);
2063 }
2064
2065 /// **ТЗ 4.1**: HTML-чнейдж во время `is_starting` → `pending_html=true`,
2066 /// `pending_source=false`. При закрытии окна → ReloadHtml (НЕ restart).
2067 #[test]
2068 fn html_during_start_pending_as_html() {
2069 let mut s = WatchState::starting();
2070 assert_eq!(
2071 handle_watch_event(&mut s, Some(ChangeKind::Html)),
2072 WatchAction::None
2073 );
2074 assert!(s.is_starting);
2075 assert!(s.pending_html);
2076 assert!(!s.pending_source);
2077 // Окно закрывается (порт 9229 открылся либо дедлайн прошёл):
2078 // применение → Html (runtime-reload), не Source (пересборка).
2079 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Html));
2080 }
2081
2082 /// **ТЗ 4.2**: Source-чнейдж во время `is_starting` → `pending_source=true`,
2083 /// `pending_html=false`. При закрытии окна → Source (пересборка).
2084 #[test]
2085 fn source_during_start_pending_as_source() {
2086 let mut s = WatchState::starting();
2087 assert_eq!(
2088 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2089 WatchAction::None
2090 );
2091 assert!(s.is_starting);
2092 assert!(!s.pending_html);
2093 assert!(s.pending_source);
2094 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
2095 }
2096
2097 /// **ТЗ 4.3**: HTML-чнейдж ПОСЛЕ закрытия `is_starting` → немедленный
2098 /// `ReloadHtml` (не restart). Это ключевой фикс race: HTML-чнейдж не
2099 /// должен приводить к полной пересборке.
2100 #[test]
2101 fn html_after_start_emits_reload() {
2102 let mut s = WatchState {
2103 is_starting: false,
2104 pending_html: false,
2105 pending_source: false,
2106 };
2107 assert_eq!(
2108 handle_watch_event(&mut s, Some(ChangeKind::Html)),
2109 WatchAction::ReloadHtml
2110 );
2111 assert!(!s.is_starting);
2112 assert!(!s.pending_html);
2113 assert!(!s.pending_source);
2114 }
2115
2116 /// Source-чнейдж после закрытия окна → немедленный Restart.
2117 #[test]
2118 fn source_after_start_emits_restart() {
2119 let mut s = WatchState {
2120 is_starting: false,
2121 pending_html: false,
2122 pending_source: false,
2123 };
2124 assert_eq!(
2125 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2126 WatchAction::Restart
2127 );
2128 }
2129
2130 /// Приоритет при закрытии окна: Source > Html (пересборка важнее reload).
2131 #[test]
2132 fn startup_priority_source_over_html() {
2133 let mut s = WatchState::starting();
2134 let _ = handle_watch_event(&mut s, Some(ChangeKind::Html));
2135 let _ = handle_watch_event(&mut s, Some(ChangeKind::Source));
2136 assert!(s.pending_html && s.pending_source);
2137 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
2138 }
2139
2140 /// Без pending-флагов при закрытии окна — `None` (действий нет).
2141 #[test]
2142 fn startup_no_pending_returns_none() {
2143 let s = WatchState::starting();
2144 assert_eq!(resolve_startup_completion(&s), None);
2145 }
2146
2147 /// Если перед событием был pending и окно уже закрыто — `ApplyPending`
2148 /// (один перезапуск за накопленную серию).
2149 #[test]
2150 fn event_with_stale_pending_returns_apply_pending() {
2151 let mut s = WatchState {
2152 is_starting: false,
2153 pending_html: true,
2154 pending_source: false,
2155 };
2156 assert_eq!(
2157 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2158 WatchAction::ApplyPending
2159 );
2160 // Флаги сброшены.
2161 assert!(!s.pending_html && !s.pending_source);
2162 }
2163
2164 // ===================== wait_port_available / reload retry ==============
2165
2166 /// **ТЗ 4.4** (логика мока TCP): порт стал доступен до дедлайна →
2167 /// `wait_port_available` возвращает `true` раньше, чем истёк `max`.
2168 /// Не запускает пересборку: просто подтверждает, что повторная попытка
2169 /// `RELOAD_TEMPLATE` возможна.
2170 #[test]
2171 fn wait_port_available_becomes_open_within_max() {
2172 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2173 let port = listener.local_addr().unwrap().port();
2174 // Порт уже открыт — должно вернуть true мгновенно (без ожидания max).
2175 let start = Instant::now();
2176 assert!(wait_port_available(
2177 port,
2178 Duration::from_millis(500),
2179 Duration::from_millis(20)
2180 ));
2181 // Задержка < max: подтверждается, что мы не ждали всё время.
2182 assert!(
2183 start.elapsed() < Duration::from_millis(500),
2184 "wait_port_available вернул слишком поздно"
2185 );
2186 }
2187
2188 /// **ТЗ 4.4** (часть 2): порт так и не открылся → `false` через ~`max`.
2189 /// В production это означает: `request_reload_template_with_retry` вернёт
2190 /// `Err` — без триггера полной пересборки (HTML-чнейдж просто потеряется
2191 /// до следующего изменения).
2192 ///
2193 /// Проверяем поведение (не wall-clock время): если функция вернула `false`,
2194 /// значит цикл закончился по дедлайну; если бы она «зависла» или «сработала
2195 /// раньше», мы бы получили `true` или panic по timeout. Дополнительная
2196 /// проверка на wall-clock сознательно опущена — на Windows под нагрузкой
2197 /// `thread::sleep` не даёт точного верхнего bound.
2198 #[test]
2199 fn wait_port_available_never_opens_times_out() {
2200 // Берём свободный порт: listen'им и сразу drop'аем слушателя, чтобы
2201 // порт гарантированно не принимал соединения.
2202 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2203 let port = listener.local_addr().unwrap().port();
2204 drop(listener);
2205 // Короткий дедлайн (80 мс), быстрый poll (10 мс) — тест завершится
2206 // быстро и надёжно. Функция должна вернуть false.
2207 let result =
2208 wait_port_available(port, Duration::from_millis(80), Duration::from_millis(10));
2209 assert!(
2210 !result,
2211 "wait_port_available должен вернуть false для закрытого порта"
2212 );
2213 }
2214
2215 // ===================== parse / classify / package_root ==================
2216
2217 /// Без имени пакета корнем становится текущий каталог (быстрый путь без
2218 /// запуска `cargo metadata`).
2219 #[test]
2220 fn package_root_without_package_is_cwd() {
2221 let root = resolve_package_root(None, std::path::Path::new("/tmp/workspace"));
2222 assert_eq!(root, std::path::PathBuf::from("/tmp/workspace"));
2223 }
2224
2225 /// Без аргументов: ни пакета, ни доп. флагов.
2226 #[test]
2227 fn parse_empty() {
2228 let args = parse_dev_command(&[]);
2229 assert!(args.package.is_none());
2230 assert!(args.extra_cargo_args.is_empty());
2231 }
2232
2233 /// Один аргумент — имя пакета, без доп. флагов.
2234 #[test]
2235 fn parse_package_only() {
2236 let args = parse_dev_command(&["my-app".into()]);
2237 assert_eq!(args.package.as_deref(), Some("my-app"));
2238 assert!(args.extra_cargo_args.is_empty());
2239 }
2240
2241 /// Пакет + доп. флаги: первый свободный — пакет, остальное — в cargo.
2242 #[test]
2243 fn parse_package_and_extra() {
2244 let args = parse_dev_command(&[
2245 "my-app".into(),
2246 "--release".into(),
2247 "--".into(),
2248 "arg0".into(),
2249 ]);
2250 assert_eq!(args.package.as_deref(), Some("my-app"));
2251 assert_eq!(
2252 args.extra_cargo_args,
2253 vec!["--release".to_owned(), "--".to_owned(), "arg0".to_owned()]
2254 );
2255 }
2256
2257 /// Несколько позиционных: первый — пакет, остальные — доп. флаги.
2258 #[test]
2259 fn parse_multiple_positionals_first_is_package() {
2260 let args = parse_dev_command(&["crate-a".into(), "crate-b".into(), "flag".into()]);
2261 assert_eq!(args.package.as_deref(), Some("crate-a"));
2262 assert_eq!(
2263 args.extra_cargo_args,
2264 vec!["crate-b".to_owned(), "flag".to_owned()]
2265 );
2266 }
2267
2268 /// Только доп. флаги (начинаются с `-`): пакета нет, всё уходит в cargo.
2269 #[test]
2270 fn parse_flags_only_no_package() {
2271 let args = parse_dev_command(&["--release".into(), "--quiet".into()]);
2272 assert!(args.package.is_none());
2273 assert_eq!(
2274 args.extra_cargo_args,
2275 vec!["--release".to_owned(), "--quiet".to_owned()]
2276 );
2277 }
2278
2279 /// `--verbose` вынимается из доп. аргументов и не попадает в cargo.
2280 #[test]
2281 fn parse_verbose_flag() {
2282 let args = parse_dev_command(&["my-app".into(), "--verbose".into(), "--release".into()]);
2283 assert!(args.verbose);
2284 assert_eq!(args.package.as_deref(), Some("my-app"));
2285 assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
2286 }
2287
2288 /// `--verbose` без пакета.
2289 #[test]
2290 fn parse_verbose_only() {
2291 let args = parse_dev_command(&["--verbose".into()]);
2292 assert!(args.verbose);
2293 assert!(args.package.is_none());
2294 assert!(args.extra_cargo_args.is_empty());
2295 }
2296
2297 /// `--preserve-state` вынимается из аргументов (не попадает в cargo).
2298 #[test]
2299 fn parse_preserve_state_flag() {
2300 let args = parse_dev_command(&[
2301 "my-app".into(),
2302 "--preserve-state".into(),
2303 "--release".into(),
2304 ]);
2305 assert!(args.preserve_state);
2306 assert_eq!(args.package.as_deref(), Some("my-app"));
2307 assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
2308 }
2309
2310 /// По умолчанию preserve_state = false.
2311 #[test]
2312 fn parse_preserve_state_default_false() {
2313 let args = parse_dev_command(&[]);
2314 assert!(!args.preserve_state);
2315 }
2316
2317 /// `.html` → `ChangeKind::Html`.
2318 #[test]
2319 fn classify_html_file() {
2320 assert_eq!(
2321 classify_change(Path::new("templates/app.html")),
2322 Some(ChangeKind::Html)
2323 );
2324 }
2325
2326 /// `.rs` → `ChangeKind::Source`.
2327 #[test]
2328 fn classify_rs_file() {
2329 assert_eq!(
2330 classify_change(Path::new("src/main.rs")),
2331 Some(ChangeKind::Source)
2332 );
2333 }
2334
2335 /// `Cargo.toml` → `ChangeKind::Source`.
2336 #[test]
2337 fn classify_cargo_toml() {
2338 assert_eq!(
2339 classify_change(Path::new("Cargo.toml")),
2340 Some(ChangeKind::Source)
2341 );
2342 }
2343
2344 /// Неизвестное расширение → `None` (игнорируется).
2345 #[test]
2346 fn classify_unknown_extension_returns_none() {
2347 assert_eq!(classify_change(Path::new("assets/logo.png")), None);
2348 assert_eq!(classify_change(Path::new("data.json")), None);
2349 }
2350
2351 /// Файл без расширения → `None`.
2352 #[test]
2353 fn classify_no_extension_returns_none() {
2354 assert_eq!(classify_change(Path::new("README")), None);
2355 }
2356
2357 // ===================== file_hash (дедупликация) ========================
2358
2359 /// **ТЗ 2.1**: одно и то же содержимое даёт одинаковый хэш (повторное
2360 /// событие игнорируется), новое содержимое — другой хэш (обрабатывается).
2361 ///
2362 /// Сценарий из ТЗ:
2363 /// - записать "a" → hash1
2364 /// - записать "a" ещё раз → hash2 == hash1 (ИГНОРИРУЕТСЯ)
2365 /// - записать "b" → hash3 != hash1 (ОБРАБАТЫВАЕТСЯ)
2366 #[test]
2367 fn file_hash_same_content_same_hash() {
2368 let dir = std::env::temp_dir().join("light_dev_test_file_hash");
2369 std::fs::create_dir_all(&dir).expect("create temp dir");
2370 let path = dir.join("sample.html");
2371 // Убедимся, что файл не существует с предыдущих тестов.
2372 let _ = std::fs::remove_file(&path);
2373
2374 // Записываем "a".
2375 let mut f = std::fs::File::create(&path).expect("create file");
2376 f.write_all(b"a").expect("write 'a'");
2377 f.sync_all().expect("sync");
2378 drop(f);
2379 let hash1 = file_hash(&path, false).expect("hash1 must be Some");
2380
2381 // Записываем "a" ещё раз (симулируем повторное событие notify).
2382 let mut f = std::fs::OpenOptions::new()
2383 .write(true)
2384 .open(&path)
2385 .expect("reopen");
2386 f.set_len(0).expect("truncate");
2387 f.write_all(b"a").expect("rewrite 'a'");
2388 f.sync_all().expect("sync");
2389 drop(f);
2390 let hash2 = file_hash(&path, false).expect("hash2 must be Some");
2391
2392 // Одно и то же содержимое → одинаковый хэш.
2393 assert_eq!(
2394 hash1, hash2,
2395 "одинаковое содержимое должно давать одинаковый хэш"
2396 );
2397
2398 // Записываем "b" (новое содержимое).
2399 let mut f = std::fs::OpenOptions::new()
2400 .write(true)
2401 .open(&path)
2402 .expect("reopen");
2403 f.set_len(0).expect("truncate");
2404 f.write_all(b"b").expect("write 'b'");
2405 f.sync_all().expect("sync");
2406 drop(f);
2407 let hash3 = file_hash(&path, false).expect("hash3 must be Some");
2408
2409 // Разное содержимое → разные хэши (крайне маловероятно совпадение
2410 // у DefaultHasher для двух байтов, но формально проверяем).
2411 assert_ne!(hash1, hash3, "разное содержимое должно дать разный хэш");
2412
2413 // Cleanup.
2414 let _ = std::fs::remove_file(&path);
2415 let _ = std::fs::remove_dir(&dir);
2416 }
2417
2418 /// Файл, которого нет → `None` (не дедуплицируется, обрабатывается как
2419 /// изменение — консервативно).
2420 #[test]
2421 fn file_hash_missing_file_returns_none() {
2422 let dir = std::env::temp_dir().join("light_dev_test_file_hash_missing");
2423 std::fs::create_dir_all(&dir).expect("create temp dir");
2424 let path = dir.join("does_not_exist.html");
2425 let _ = std::fs::remove_file(&path);
2426 assert_eq!(file_hash(&path, false), None);
2427 let _ = std::fs::remove_dir(&dir);
2428 }
2429
2430 // ===================== ТЗ 0.14.2-A-fix4: dedup after debounce =========
2431
2432 /// **ТЗ 4.1**: имитируем двухэтапный save (append + flush) — первое
2433 /// событие видит неполное содержимое, второе — полное. После debounce
2434 /// содержимое уже финальное, и hash-фильтр корректно отбрасывает повторы.
2435 ///
2436 /// Сценарий:
2437 /// 1. записать "a" → событие 1 (неполное, append)
2438 /// 2. дописать "b" → событие 2 (полное, flush)
2439 /// 3. debounce срабатывает → хэшируем: hash("ab")
2440 /// 4. last_hashes["file"] = hash("ab")
2441 /// 5. следующее notify-событие с тем же содержимым → hash == last →
2442 /// ИГНОРИРУЕТСЯ (1 reload на 2 notify-события, не 2).
2443 ///
2444 /// Тест проверяет именно то, что происходит ПОСЛЕ debounce: если бы мы
2445 /// хэшировали ДО debounce, hash1 ("a") != hash2 ("ab"), и оба события
2446 /// прошли бы → двойной reload.
2447 #[test]
2448 fn dedup_after_debounce() {
2449 let dir = std::env::temp_dir().join("light_dev_test_dedup_after_debounce");
2450 std::fs::create_dir_all(&dir).expect("create temp dir");
2451 let path = dir.join("sample.html");
2452 let _ = std::fs::remove_file(&path);
2453
2454 // Этап 1 (append): пишем "a" — это первый notify-событие, файл
2455 // содержит только "a".
2456 let mut f = std::fs::File::create(&path).expect("create");
2457 f.write_all(b"a").expect("write 'a'");
2458 f.sync_all().expect("sync");
2459 drop(f);
2460
2461 // Этап 2 (flush): дописываем "b" — второй notify-событие, файл
2462 // содержит "ab".
2463 let mut f = std::fs::OpenOptions::new()
2464 .append(true)
2465 .open(&path)
2466 .expect("reopen-append");
2467 f.write_all(b"b").expect("append 'b'");
2468 f.sync_all().expect("sync");
2469 drop(f);
2470
2471 // К этому моменту debounce (100/500 мс) уже истёк. Хэшируем финальное
2472 // содержимое "ab":
2473 let final_hash = file_hash(&path, false).expect("hash of 'ab'");
2474
2475 // Симулируем «повторное» notify-событие (Modify(Data) / Modify(Metadata)):
2476 // содержимое НЕ изменилось, хэш должен совпадать.
2477 let dup_hash = file_hash(&path, false).expect("hash on duplicate event");
2478 assert_eq!(
2479 final_hash, dup_hash,
2480 "повторное событие того же содержимого должно дать одинаковый хэш \
2481 (и быть отброшено hash-фильтром)"
2482 );
2483
2484 // Теперь симулируем НОВЫЙ save: содержимое изменилось.
2485 let mut f = std::fs::OpenOptions::new()
2486 .write(true)
2487 .open(&path)
2488 .expect("rewrite");
2489 f.set_len(0).expect("truncate");
2490 f.write_all(b"xyz").expect("write 'xyz'");
2491 f.sync_all().expect("sync");
2492 drop(f);
2493 let new_hash = file_hash(&path, false).expect("hash of 'xyz'");
2494 assert_ne!(
2495 final_hash, new_hash,
2496 "новое содержимое даёт новый хэш → пройдёт hash-фильтр"
2497 );
2498
2499 // Cleanup.
2500 let _ = std::fs::remove_file(&path);
2501 let _ = std::fs::remove_dir(&dir);
2502 }
2503
2504 /// **ТЗ 4.2**: имитируем временную ошибку чтения (например, PermissionDenied
2505 /// при частичной записи, NotFound в момент атомарного удаления). `file_hash`
2506 /// должна ретраить 3 раза с интервалом 50 мс, и после всех попыток вернуть
2507 /// `None` (если файл реально недоступен) или `Some(hash)` (если ошибка
2508 /// ушла в процессе ретраев).
2509 ///
2510 /// Мы не можем реально вызвать `PermissionDenied` под sandbox-ограничениями,
2511 /// поэтому тестируем наблюдаемое поведение:
2512 /// - файл существует и читается → `Some(hash)`;
2513 /// - файла нет вообще → 3 неудачные попытки (лог с "read attempt N failed")
2514 /// и возврат `None`.
2515 ///
2516 /// Поведение «ретраи до 3 раз» проверяем косвенно: время выполнения
2517 /// `file_hash` для несуществующего файла >= 2 × 50 мс (два sleep между
2518 /// тремя попытками), но < 1 с (не блокирует поток watcher надолго).
2519 #[test]
2520 fn file_hash_retries_on_permission_denied() {
2521 let dir = std::env::temp_dir().join("light_dev_test_file_hash_retry");
2522 std::fs::create_dir_all(&dir).expect("create temp dir");
2523 let path = dir.join("missing.html");
2524 let _ = std::fs::remove_file(&path);
2525
2526 let start = Instant::now();
2527 let result = file_hash(&path, false);
2528 let elapsed = start.elapsed();
2529
2530 // Файл не существует: все 3 попытки падают, возвращаем None.
2531 assert_eq!(
2532 result, None,
2533 "несуществующий файл → None после всех ретраев"
2534 );
2535
2536 // Два sleep по 50 мс (между 3 попытками): минимум ~100 мс,
2537 // максимум ~200 мс с запасом на задержки ОС. НЕ проверяем верхнюю
2538 // границу жёстко: Windows `thread::sleep` может overshoot-нуть.
2539 assert!(
2540 elapsed >= Duration::from_millis(100),
2541 "ожидаю минимум 2 × 50 мс ретраев, получил {elapsed:?}"
2542 );
2543 assert!(
2544 elapsed < Duration::from_secs(1),
2545 "ретраи должны завершиться быстро (< 1 с), получили {elapsed:?}"
2546 );
2547
2548 // Cleanup.
2549 let _ = std::fs::remove_dir(&dir);
2550 }
2551
2552 // ===================== ТЗ 0.14.2-B: seamless rebuild state machine ======
2553
2554 /// **ТЗ 6.1**: `start()` поднимает `in_progress` и задаёт `started_at`.
2555 #[test]
2556 fn rebuild_started_flag() {
2557 let mut sm = RebuildStateMachine::default();
2558 assert!(!sm.in_progress, "начальное состояние: in_progress=false");
2559 assert!(
2560 sm.started_at.is_none(),
2561 "начальное состояние: started_at=None"
2562 );
2563 sm.start();
2564 assert!(sm.in_progress, "после start(): in_progress=true");
2565 assert!(
2566 sm.started_at.is_some(),
2567 "после start(): started_at=Some(Instant)"
2568 );
2569 }
2570
2571 /// **ТЗ 6.2**: `poll_outcome(0)` → Success (swap будет выполнен).
2572 /// `started_at` остаётся (для лога «took N s»); `in_progress` сбрасывается.
2573 #[test]
2574 fn rebuild_success_triggers_swap() {
2575 let mut sm = RebuildStateMachine::default();
2576 sm.start();
2577 let Some(started) = sm.started_at else {
2578 panic!("expected started_at to be set after start()");
2579 };
2580 let outcome = sm.poll_outcome(0);
2581 assert_eq!(outcome, RebuildOutcome::Success);
2582 assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
2583 assert_eq!(
2584 sm.started_at,
2585 Some(started),
2586 "started_at сохраняется для лога"
2587 );
2588 }
2589
2590 /// **ТЗ 6.3**: `poll_outcome(N)` при N != 0 → Failed(N) (old живёт).
2591 /// `started_at` остаётся (для возможного последующего пересбора).
2592 #[test]
2593 fn rebuild_failure_keeps_old() {
2594 let mut sm = RebuildStateMachine::default();
2595 sm.start();
2596 let Some(started) = sm.started_at else {
2597 panic!("expected started_at to be set after start()");
2598 };
2599 let outcome = sm.poll_outcome(2);
2600 assert_eq!(outcome, RebuildOutcome::Failed(2));
2601 assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
2602 assert_eq!(sm.started_at, Some(started), "started_at сохраняется");
2603 }
2604
2605 /// **ТЗ 6.4**: новый save во время in-flight сборки → убить старую,
2606 /// `start()` заново (flag + fresh timer).
2607 ///
2608 /// Юнит на уровне SM: проверяем, что после `reset()`+`start()` флаг
2609 /// возвращается к `true`, а `started_at` обновляется. Убийство процесса
2610 /// выполняется в main-loop (через `rebuild_arc`) — это integration test.
2611 #[test]
2612 fn new_save_cancels_in_flight() {
2613 let mut sm = RebuildStateMachine::default();
2614 sm.start();
2615 assert!(sm.in_progress);
2616 let first_start = sm.started_at.unwrap();
2617 // Имитация: новый save пришёл, старую сборку убили, новая запускается.
2618 sm.start();
2619 assert!(sm.in_progress, "после нового start(): in_progress=true");
2620 assert_ne!(
2621 sm.started_at,
2622 Some(first_start),
2623 "started_at должен быть обновлён (новый запуск)"
2624 );
2625 }
2626
2627 // ===================== ТЗ 0.14.2-B-fix2: path helpers ================
2628
2629 /// Бинарник: имя пакета без изменений (cargo не заменяет `-`). Windows: `.exe`.
2630 #[test]
2631 fn bin_name_preserves_dashes() {
2632 assert_eq!(bin_name("my-app"), "my-app");
2633 assert_eq!(bin_name("my-app-x"), "my-app-x");
2634 assert_eq!(bin_name("simple"), "simple");
2635 }
2636
2637 /// Свежий бинарник: `<build_target>/debug/<package-name>[.exe]`.
2638 #[test]
2639 fn fresh_bin_path_layout() {
2640 let base = std::env::temp_dir();
2641 let build_target = base.join("hot-build");
2642 let p = fresh_bin_path(&build_target, "my-app");
2643 let s = p.to_string_lossy().to_string();
2644 assert!(s.contains("hot-build"), "path contains hot-build: {s}");
2645 assert!(
2646 s.ends_with("my-app.exe") || s.ends_with("my-app"),
2647 "correct bin name: {s}"
2648 );
2649 if cfg!(windows) {
2650 assert!(s.ends_with(".exe"), "windows .exe, got: {s}");
2651 }
2652 }
2653
2654 /// Копия для запуска: `<run_dir>/<bin>-<ts>[.exe]` — имя содержит bin
2655 /// и timestamp, расширение `.exe` на Windows.
2656 #[test]
2657 fn run_exe_path_has_timestamp_and_extension() {
2658 let run_dir = std::env::temp_dir().join("hot-run");
2659 let p = run_exe_path(&run_dir, "my-app");
2660 let file = p
2661 .file_name()
2662 .unwrap_or_default()
2663 .to_string_lossy()
2664 .to_string();
2665 assert!(
2666 file.starts_with("my-app-"),
2667 "filename should start with bin + dash, got: {file}"
2668 );
2669 if cfg!(windows) {
2670 assert!(file.ends_with(".exe"), "windows extension, got: {file}");
2671 } else {
2672 assert!(!file.ends_with(".exe"), "no .exe on unix, got: {file}");
2673 }
2674 }
2675
2676 /// `copy_fresh_to_run`: создаёт run_dir, копирует, возвращает путь к копии.
2677 /// `cleanup_run_dir`: удаляет старые .exe, кроме активного.
2678 #[test]
2679 fn copy_and_cleanup_run_dir() {
2680 // Используем CWD-относительный каталог, потому что temp_dir() в этом
2681 // окружении имеет ACL-ограничения (см. предсуществующие ACL-файлы
2682 // dev::tests::file_hash_*). Каталог уникален по pid + nanos и чистится
2683 // в конце теста.
2684 let tmp = std::env::current_dir()
2685 .unwrap_or_else(|_| PathBuf::from("."))
2686 .join(format!(
2687 ".light-dev-test-{}-{}",
2688 std::process::id(),
2689 std::time::SystemTime::now()
2690 .duration_since(std::time::UNIX_EPOCH)
2691 .unwrap_or_default()
2692 .as_nanos()
2693 ));
2694 let build_root = tmp.join("hot-build");
2695 let build_target = build_root.join("debug");
2696 let run_dir = tmp.join("hot-run");
2697 let _ = std::fs::remove_dir_all(&tmp);
2698 std::fs::create_dir_all(&build_target).expect("create build dir");
2699
2700 // Создаём фейковый fresh-бинарник в <build_target>/<bin>[.exe].
2701 let bin = "test-app";
2702 let mut fresh = build_target.join(bin);
2703 #[cfg(windows)]
2704 {
2705 fresh.set_extension("exe");
2706 }
2707 std::fs::write(&fresh, b"fake").expect("write fake binary");
2708
2709 // Копируем: build_root = <tmp>/hot-build.
2710 let copied = copy_fresh_to_run(&build_root, &run_dir, bin).expect("copy should succeed");
2711 assert!(copied.exists(), "copy should exist: {:?}", copied);
2712 let name = copied
2713 .file_name()
2714 .unwrap_or_default()
2715 .to_string_lossy()
2716 .to_string();
2717 assert!(name.starts_with("test-app-"), "name: {name}");
2718
2719 // Чистим: добавляем «старый» .exe и проверяем, что он удалён, а
2720 // текущий остался.
2721 let mut stale = run_dir.join("test-app-oldstale");
2722 #[cfg(windows)]
2723 {
2724 stale.set_extension("exe");
2725 }
2726 std::fs::write(&stale, b"old").expect("write stale");
2727 cleanup_run_dir(&run_dir, &copied);
2728 assert!(!stale.exists(), "stale should be removed: {:?}", stale);
2729 assert!(copied.exists(), "current should remain: {:?}", copied);
2730
2731 let _ = std::fs::remove_dir_all(&tmp);
2732 }
2733
2734 // ===================== TZ 0.14.4: cache invalidation tests =====================
2735
2736 /// ТЗ 6.1: round-trip — `save_build_info` пишет JSON с двумя полями,
2737 /// `read_fw_core_version_from_lock` читает версию из `Cargo.lock`.
2738 #[test]
2739 fn build_info_round_trip() {
2740 let tmp = test_tmp_dir("build_info");
2741 std::fs::create_dir_all(&tmp).expect("create tmp");
2742
2743 let info = build_info_path(&tmp);
2744 save_build_info(&info, "0.14.0", "abc123hash");
2745
2746 // Проверяем, что файл существует и содержит обе версии.
2747 let content = std::fs::read_to_string(&info).expect("read build-info.json");
2748 assert!(content.contains("0.14.0"), "version in file: {content}");
2749 assert!(content.contains("abc123hash"), "hash in file: {content}");
2750
2751 let _ = std::fs::remove_dir_all(&tmp);
2752 }
2753
2754 /// ТЗ 6.1 (часть 2): `read_fw_core_version_from_lock` находит версию
2755 /// light-fw-core в тексте Cargo.lock.
2756 #[test]
2757 fn read_fw_core_version_from_lock_text() {
2758 let tmp = test_tmp_dir("lock_parse");
2759 std::fs::create_dir_all(&tmp).expect("create tmp");
2760 let lock = tmp.join("Cargo.lock");
2761
2762 // Фиктивный Cargo.lock с блоком light-fw-core.
2763 let lock_content = r#"
2764[[package]]
2765name = "light-fw-core"
2766version = "0.14.0"
2767dependencies = [
2768 "serde",
2769]
2770
2771[[package]]
2772name = "other-crate"
2773version = "1.0.0"
2774"#;
2775 std::fs::write(&lock, lock_content).expect("write fake lock");
2776
2777 let version = read_fw_core_version_from_lock(&lock).expect("should find version");
2778 assert_eq!(version, "0.14.0");
2779
2780 let _ = std::fs::remove_dir_all(&tmp);
2781 }
2782
2783 /// ТЗ 6.2: `--clean` чистит правильные пути: hot-build, hot-run, state, deps.
2784 #[test]
2785 fn clean_dev_cache_removes_expected_paths() {
2786 let tmp = test_tmp_dir("clean_cache");
2787 // Создаём структуру: target/hot-build, target/hot-run, .light/state.json,
2788 // target/debug/deps/<package>*.
2789 let target = tmp.join("target");
2790 let hot_build = target.join("hot-build");
2791 let hot_run = target.join("hot-run");
2792 let state_file = tmp.join(".light").join("state.json");
2793 let deps = target.join("debug").join("deps");
2794 let pkg_dep = deps.join("example-devtools-demo-abcdef.exe");
2795
2796 std::fs::create_dir_all(&hot_build).expect("create hot-build");
2797 std::fs::create_dir_all(&hot_run).expect("create hot-run");
2798 std::fs::create_dir_all(tmp.join(".light")).expect("create .light");
2799 std::fs::create_dir_all(&deps).expect("create deps");
2800 std::fs::write(&state_file, "{}").expect("write state");
2801 std::fs::write(&pkg_dep, b"fake").expect("write dep");
2802
2803 // Убеждаемся, что файлы существуют до чистки.
2804 assert!(hot_build.exists());
2805 assert!(hot_run.exists());
2806 assert!(state_file.exists());
2807 assert!(pkg_dep.exists());
2808
2809 // Чистим.
2810 let removed = clean_dev_cache(&tmp, Some("example-devtools-demo"));
2811
2812 // Проверяем, что все четыре пути удалены.
2813 assert!(!hot_build.exists(), "hot-build should be removed");
2814 assert!(!hot_run.exists(), "hot-run should be removed");
2815 assert!(!state_file.exists(), "state.json should be removed");
2816 assert!(!pkg_dep.exists(), "deps artifact should be removed");
2817
2818 // Проверяем, что в списке removed есть наши пути.
2819 let removed_strs: Vec<String> = removed
2820 .iter()
2821 .map(|p| p.to_string_lossy().into_owned())
2822 .collect();
2823 assert!(
2824 removed_strs.iter().any(|s| s.contains("hot-build")),
2825 "removed should contain hot-build: {removed_strs:?}"
2826 );
2827 assert!(
2828 removed_strs.iter().any(|s| s.contains("hot-run")),
2829 "removed should contain hot-run: {removed_strs:?}"
2830 );
2831 assert!(
2832 removed_strs.iter().any(|s| s.contains("state.json")),
2833 "removed should contain state.json: {removed_strs:?}"
2834 );
2835 assert!(
2836 removed_strs
2837 .iter()
2838 .any(|s| s.contains("example-devtools-demo")),
2839 "removed should contain dep artifact: {removed_strs:?}"
2840 );
2841
2842 let _ = std::fs::remove_dir_all(&tmp);
2843 }
2844
2845 /// ТЗ 6.3: age-prune удаляет устаревшие .exe, но не текущий.
2846 /// Активный exe имеет свежий timestamp; старый — «вчерашний» (date ≠ today).
2847 #[test]
2848 fn prune_stale_run_exes_respects_keep_and_age() {
2849 let tmp = test_tmp_dir("prune_stale");
2850 let run_dir = tmp.join("hot-run");
2851 std::fs::create_dir_all(&run_dir).expect("create run dir");
2852
2853 // Активный exe: свежий timestamp (сейчас) — НЕ должен быть удалён.
2854 let active = run_exe_path(&run_dir, "test-app");
2855 std::fs::write(&active, b"active").expect("write active");
2856
2857 // Старый exe: «вчерашний» timestamp → date ≠ today → stale_by_date=true.
2858 // Берём сейчас минус 24 часа в миллисекундах.
2859 let now_ms = std::time::SystemTime::now()
2860 .duration_since(std::time::UNIX_EPOCH)
2861 .unwrap_or_default()
2862 .as_millis();
2863 let yesterday_ms = now_ms.saturating_sub(86_400_000); // 24 hours ago
2864 let old_name = format!("test-app-{yesterday_ms}.exe");
2865 let old_exe = run_dir.join(&old_name);
2866 std::fs::write(&old_exe, b"old").expect("write old");
2867
2868 // Проверяем, что активный НЕ удалён, а старый — удалён.
2869 let pruned = prune_stale_run_exes(&run_dir, &active);
2870 assert!(
2871 pruned >= 1,
2872 "should prune at least 1 stale: pruned={pruned}"
2873 );
2874 assert!(active.exists(), "active should remain: {:?}", active);
2875 assert!(!old_exe.exists(), "old should be removed: {:?}", old_exe);
2876
2877 let _ = std::fs::remove_dir_all(&tmp);
2878 }
2879
2880 /// ТЗ fix1 4.1: `read_fw_core_version_from_workspace_toml` парсит
2881 /// `[workspace.package] version = "0.15.0"` из текста Cargo.toml.
2882 #[test]
2883 fn parse_workspace_package_version_basic() {
2884 let toml_text = r#"
2885[workspace]
2886resolver = "2"
2887members = ["crates/core"]
2888
2889[workspace.package]
2890version = "0.15.0"
2891edition = "2024"
2892license = "MIT"
2893
2894[workspace.dependencies]
2895serde = "1.0"
2896"#;
2897 let ver = parse_workspace_package_version(toml_text).expect("should parse");
2898 assert_eq!(ver, "0.15.0");
2899 }
2900
2901 /// ТЗ fix1 4.1 (часть 2): отсутствие [workspace.package] → None.
2902 #[test]
2903 fn parse_workspace_package_version_missing_section() {
2904 let toml_text = r#"
2905[package]
2906name = "solo-crate"
2907version = "1.0.0"
2908"#;
2909 assert!(parse_workspace_package_version(toml_text).is_none());
2910 }
2911
2912 /// ТЗ fix1 4.2: `read_fw_core_version_from_workspace_toml` читает файл,
2913 /// а при его отсутствии возвращает None (fallback на lock вызывается
2914 /// снаружи через .or_else).
2915 #[test]
2916 fn workspace_toml_read_and_fallback() {
2917 let tmp = test_tmp_dir("toml_fallback");
2918 let _ = std::fs::create_dir_all(&tmp);
2919
2920 // С томом:
2921 let toml_path = tmp.join("Cargo.toml");
2922 let _ = std::fs::write(
2923 &toml_path,
2924 "[workspace.package]\nversion = \"0.16.0\"\nedition = \"2024\"\n",
2925 );
2926 let ver = read_fw_core_version_from_workspace_toml(&toml_path).expect("should read toml");
2927 assert_eq!(ver, "0.16.0");
2928
2929 // Fallback: нет Cargo.toml → None (вызывающий код делает .or_else(lock)).
2930 let missing = tmp.join("nonexistent/Cargo.toml");
2931 assert!(read_fw_core_version_from_workspace_toml(&missing).is_none());
2932
2933 let _ = std::fs::remove_dir_all(&tmp);
2934 }
2935
2936 /// ТЗ fix1 4.3: `clean_dev_cache` удаляет build-info.json.
2937 #[test]
2938 fn clean_dev_cache_removes_build_info() {
2939 let tmp = test_tmp_dir("clean_build_info");
2940 let _ = std::fs::create_dir_all(tmp.join("target/hot-build"));
2941 let _ = std::fs::create_dir_all(tmp.join(".light"));
2942 let bi = build_info_path(&tmp);
2943 let _ = std::fs::write(&bi, "{}");
2944
2945 let removed = clean_dev_cache(&tmp, Some("myapp"));
2946 assert!(!bi.exists(), "build-info.json should be removed");
2947 let removed_strs: Vec<_> = removed
2948 .iter()
2949 .map(|p| p.to_string_lossy().into_owned())
2950 .collect();
2951 assert!(
2952 removed_strs.iter().any(|s| s.contains("build-info")),
2953 "removed should contain build-info: {removed_strs:?}"
2954 );
2955 let _ = std::fs::remove_dir_all(&tmp);
2956 }
2957
2958 // ===================== same_minor / minor_key / check_cli_version_mismatch =====
2959
2960 /// `"0.15.3"` → `"0.15"`, `"0.15"` → `"0.15"`, `"0.14"` → `"0.14"`.
2961 #[test]
2962 fn minor_key_extracts_major_minor() {
2963 assert_eq!(minor_key("0.15.3"), "0.15");
2964 assert_eq!(minor_key("0.15"), "0.15");
2965 assert_eq!(minor_key("0.14"), "0.14");
2966 assert_eq!(minor_key("1.2.3"), "1.2");
2967 assert_eq!(minor_key("1.2"), "1.2");
2968 }
2969
2970 /// Same minor: `"0.15.3"` vs `"0.15"` → true (no invalidation).
2971 #[test]
2972 fn same_minor_exact_vs_range() {
2973 assert!(same_minor("0.15.3", "0.15"));
2974 assert!(same_minor("0.15", "0.15.3"));
2975 assert!(same_minor("0.15.3", "0.15.2"));
2976 assert!(!same_minor("0.14", "0.15"));
2977 assert!(!same_minor("0.15", "0.16"));
2978 assert!(!same_minor("1.0", "0.99"));
2979 }
2980
2981 /// `check_cli_version_mismatch`: project version from TOML is a range
2982 /// (`light = "0.15"`), installed is exact (`0.15.4`) → no mismatch.
2983 #[test]
2984 fn cli_version_mismatch_same_minor() {
2985 let tmp = test_tmp_dir("mismatch_same");
2986 let _ = std::fs::create_dir_all(&tmp);
2987 let toml_file = tmp.join("Cargo.toml");
2988 std::fs::write(&toml_file, "[dependencies]\nlight = \"0.15\"\n").unwrap();
2989 let lock = tmp.join("Cargo.lock");
2990 // No mismatch: 0.15.4 vs 0.15 → same minor.
2991 assert_eq!(
2992 check_cli_version_mismatch("0.15.4", &toml_file, &lock),
2993 None
2994 );
2995 let _ = std::fs::remove_dir_all(&tmp);
2996 }
2997
2998 /// `check_cli_version_mismatch`: different minor → Some warning.
2999 #[test]
3000 fn cli_version_mismatch_different_minor() {
3001 let tmp = test_tmp_dir("mismatch_diff");
3002 let _ = std::fs::create_dir_all(&tmp);
3003 let toml_file = tmp.join("Cargo.toml");
3004 std::fs::write(&toml_file, "[dependencies]\nlight = \"0.14\"\n").unwrap();
3005 let lock = tmp.join("Cargo.lock");
3006 let result = check_cli_version_mismatch("0.15.4", &toml_file, &lock);
3007 assert_eq!(result, Some(("0.15.4".to_string(), "0.14".to_string())));
3008 let _ = std::fs::remove_dir_all(&tmp);
3009 }
3010
3011 /// Вспомогательная: создаёт уникальный CWD-относительный каталог для теста.
3012 fn test_tmp_dir(label: &str) -> PathBuf {
3013 std::env::current_dir()
3014 .unwrap_or_else(|_| PathBuf::from("."))
3015 .join(format!(
3016 ".light-dev-test-{}-{}-{}",
3017 std::process::id(),
3018 std::time::SystemTime::now()
3019 .duration_since(std::time::UNIX_EPOCH)
3020 .unwrap_or_default()
3021 .as_nanos(),
3022 label
3023 ))
3024 }
3025}