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!(
1261 "light dev: building (cargo build) — target {:?}…",
1262 build_target
1263 );
1264 let mut cmd = Command::new("cargo");
1265 cmd.arg("build");
1266 cmd.args(["-p", &pkg_name, "--features", "light-fw/dev-mode"]);
1267 cmd.args(["--target-dir", &build_target.to_string_lossy()]);
1268 cmd.args(&args.extra_cargo_args);
1269 cmd.stdin(Stdio::inherit())
1270 .stdout(Stdio::inherit())
1271 .stderr(Stdio::inherit());
1272 let status = cmd.status();
1273 match status {
1274 Ok(s) if s.success() => println!("light dev: build done, launching app…"),
1275 Ok(s) => {
1276 eprintln!(
1277 "light dev: warm-up build FAILED (exit {:?}) — exit 1",
1278 s.code()
1279 );
1280 return Ok(1);
1281 }
1282 Err(e) => {
1283 eprintln!("light dev: не удалось запустить `cargo build`: {e} — exit 1");
1284 return Ok(1);
1285 }
1286 }
1287 // ТЗ 0.14.2-B-fix 4: после успешного build проверим, что бинарник
1288 // действительно лежит там, где мы его ищем. Если нет — путь расхожился.
1289 let fresh_bin = fresh_bin_path(&build_target, &bin);
1290 if std::fs::metadata(&fresh_bin).is_err() {
1291 eprintln!(
1292 "light dev: BUG — build succeeded but binary not at {:?}. \
1293 Search in {:?}...",
1294 fresh_bin,
1295 build_target.join("debug")
1296 );
1297 return Ok(1);
1298 }
1299 }
1300
1301 // === ФАЗА B: копия в target/hot-run + spawn ===
1302 // Запускаем из копии, а не из hot-build: следующая сборка перезапишет
1303 // fresh-бинарник без os error 5 (запущен процесс с другой, свободной
1304 // копии).
1305 let first_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
1306 Ok(p) => p,
1307 Err(e) => {
1308 eprintln!("light dev: не удалось скопировать бинарник: {e} — exit 1");
1309 return Ok(1);
1310 }
1311 };
1312 eprintln!(
1313 "light dev: spawning {:?} (build {:?})",
1314 first_run_exe,
1315 fresh_bin_path(&build_target, &bin)
1316 );
1317 // ТЗ 0.14.3-fix7: cold start не применяет ни state, ни geometry (см.
1318 // `resumed`/`create_window` в core). Чтобы они гарантированно не подтянули
1319 // устаревшие значения из прошлой сессии, удаляем оба файла до запуска
1320 // первого warm-up бинарника. (При user-close supervisor их тоже удаляет —
1321 // это двойная страховка на случай, если прошлый процесс завершился грубо.)
1322 for stale in [
1323 state_file_path(&package_root),
1324 window_file_path(&package_root),
1325 ] {
1326 if std::fs::remove_file(&stale).is_ok() {
1327 eprintln!("light dev: cold start — cleared stale {:?}", stale);
1328 }
1329 }
1330 let mut child_cmd = Command::new(&first_run_exe);
1331 child_cmd.stdin(Stdio::inherit());
1332 child_cmd.stdout(Stdio::inherit());
1333 child_cmd.stderr(Stdio::inherit());
1334 apply_state_env(&mut child_cmd, &package_root);
1335 apply_window_env(&mut child_cmd, &package_root);
1336 // ТЗ 0.14.3-fix5 (откат fix4): на cold start state.json НЕ применяется —
1337 // дефолты из нового кода имеют приоритет над устаревшим state. При swap
1338 // состояние сохраняется (см. блок ниже): LIGHT_RESUME_FROM_SWAP=1 +
1339 // LIGHT_PRESERVE_STATE=1, поэтому count переживает .rs change, а
1340 // вычисляемые значения (методы, например status) пересчитываются из нового
1341 // кода. --preserve-state оставлен как алиас (те же эффекты, что и default).
1342 let child = match child_cmd.spawn() {
1343 Ok(c) => c,
1344 Err(e) => {
1345 eprintln!(
1346 "light dev: не удалось запустить {:?}: {e} — exit 1",
1347 first_run_exe
1348 );
1349 return Ok(1);
1350 }
1351 };
1352
1353 // ТЗ 0.14.2-B: общая обёртка для thread-safe доступа (main loop + rebuild
1354 // thread). `child_arc` — приложение, которое сейчас работает (или будет
1355 // после swap); `rebuild_arc` — текущая фоновая сборка (или None, если её
1356 // нет). Rebuild запускаем из main-потока и просто опрашиваем `try_wait()`
1357 // в главном цикле — отдельный тред не нужен (Cargo сам параллелит).
1358 let child_arc = Arc::new(Mutex::new(child));
1359 let rebuild_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
1360
1361 // State-machine: решение о реакции на результат фоновой сборки. Держим
1362 // в main-потоке (swap выполняется там); чистый, без side effects.
1363 let mut sm = RebuildStateMachine::default();
1364 // ТЗ 0.14.3-fix7: kill_child (kill + wait) синхронно потребляет exit
1365 // убитого swap-процесса, так что Ev::Exited в главном цикле получает
1366 // только события от «живых» детей (user-close или crash). Флаг pending_swap
1367 // больше не нужен — все exit 0 в Ev::Exited = user-close → удалить файлы.
1368 // Канал на перезапуск от наблюдателя. Несёт тип изменения: Html →
1369 // RELOAD_TEMPLATE (без убийства), Source → background rebuild + swap.
1370 let (restart_tx, restart_rx) = mpsc::channel::<(Instant, ChangeKind)>();
1371 // Флаг завершения: общий между главным потоком, наблюдателем и
1372 // rebuild-тредом. Атомарный (в отличие от mpsc::Receiver) — легко
1373 // шараится между несколькими тредами без clone() канала.
1374 let done_flag = Arc::new(AtomicBool::new(false));
1375 // Состояние наблюдателя (is_starting/pending_change) и канал на его
1376 // обновление. Главный поток решает, когда закрывать окно игнорирования:
1377 // как только порт 9229 открылся или прошёл STARTUP_IGNORE.
1378 let (state_tx, state_rx) = mpsc::channel::<WatchState>();
1379 let _ = state_tx.send(WatchState::starting());
1380
1381 let watch_paths_copy = watch_paths(&package_root);
1382 eprintln!("light dev: watching {:?}", watch_paths_copy);
1383 let verbose = args.verbose;
1384 let done_flag_watch = done_flag.clone();
1385 let watcher = thread::spawn(move || {
1386 if watch_paths_copy.is_empty() {
1387 eprintln!("light dev: нет путей для наблюдения (нет src/, templates/, Cargo.toml)");
1388 return;
1389 }
1390 let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
1391 // PollWatcher: периодическое опросование файловой системы. В отличие от
1392 // нативного `recommended_watcher`, работает на UNC- и сетевых дисках,
1393 // где нативный механизм события просто не ловит.
1394 let config = Config::default().with_poll_interval(POLL_INTERVAL);
1395 let mut watcher = match PollWatcher::new(
1396 move |res| {
1397 let _ = tx.send(res);
1398 },
1399 config,
1400 ) {
1401 Ok(w) => w,
1402 Err(e) => {
1403 eprintln!("light dev: не удалось создать файловый наблюдатель: {e}");
1404 return;
1405 }
1406 };
1407 for path in &watch_paths_copy {
1408 if let Err(e) = watcher.watch(path, RecursiveMode::NonRecursive) {
1409 eprintln!("light dev: не удалось следить за {}: {e}", path.display());
1410 }
1411 }
1412 // Начальное состояние: is_starting = true. Watcher сам решает, когда
1413 // закрывать окно: как только порт DevTools открылся либо прошёл
1414 // дедлайн STARTUP_IGNORE (защита от вечной блокировки при зависшей
1415 // сборке). Principal-поток лишь сбрасывает state в `starting()` при
1416 // перезапуске процесса.
1417 let mut state = match state_rx.recv() {
1418 Ok(s) => s,
1419 Err(_) => return,
1420 };
1421 // Момент запуска приложения: используется для дедлайна STARTUP_IGNORE.
1422 // Пересбрасывается при каждом новом запуске дочернего процесса (главный
1423 // поток шлёт `WatchState::starting()` через state_rx).
1424 let mut start_at = Instant::now();
1425 // Сбрасывает дебаунс; актуально только вне окна `is_starting`.
1426 // Отдельный таймер для каждого типа изменения (Html vs Source).
1427 let mut last_html: Option<Instant> = None;
1428 let mut last_rs: Option<Instant> = None;
1429 // Дедупликация по хэшу содержимого: один «логический» save генерирует
1430 // несколько событий notify (Modify + Modify(Data) + Modify(Metadata)).
1431 // Ключевой порядок (ТЗ 0.14.2-A-fix4): debounce СНАЧАЛА (файл успевает
1432 // дописаться), затем hash-фильтр (повторные события того же
1433 // содержимого отброшены), затем handle_watch_event.
1434 let mut last_hashes: std::collections::HashMap<PathBuf, u64> =
1435 std::collections::HashMap::new();
1436 // Накопитель путей, ожидающих debounce. На событие notify мы НЕ
1437 // хэшируем сразу (файл может быть в середине записи); только сохраняем
1438 // путь. Когда debounce срабатывает (100/500 мс тишины), проходим по
1439 // pending_debounce, хэшируем и дедуплицируем.
1440 let mut pending_debounce: std::collections::VecDeque<PathBuf> =
1441 std::collections::VecDeque::new();
1442 // Приоритет pending-флагов (Source > Html): если оба были накоплены,
1443 // применяем source (пересборка важнее reload).
1444 let apply_pending = |state: &mut WatchState,
1445 tx: &mpsc::Sender<(Instant, ChangeKind)>|
1446 -> Option<ChangeKind> {
1447 let kind = resolve_startup_completion(state)?;
1448 let _ = tx.send((Instant::now(), kind));
1449 state.clear_pending();
1450 Some(kind)
1451 };
1452 loop {
1453 // 1) Завершение от основного потока.
1454 if done_flag_watch.load(Ordering::Relaxed) {
1455 break;
1456 }
1457 // 2) Обновление состояния от главного потока (при перезапуске
1458 // приложение сбрасывает state в `starting()`).
1459 if let Ok(new_state) = state_rx.try_recv() {
1460 state = new_state;
1461 start_at = Instant::now();
1462 last_html = None;
1463 last_rs = None;
1464 // Сбрасываем накопленные пути: предыдущий запуск дочернего
1465 // процесса завершился, и все «недочитанные» изменения теряются.
1466 pending_debounce.clear();
1467 continue;
1468 }
1469 // 3) Окно игнорирования: порт 9229 открылся или дедлайн прошёл.
1470 // Применяем накопленный pending (если есть) как ReloadHtml /
1471 // Restart — без эвристики «обнаружены изменения».
1472 if state.is_starting {
1473 let port_open = is_port_open(DEVTOOLS_PORT);
1474 let deadline_reached = start_at.elapsed() >= STARTUP_IGNORE;
1475 if port_open || deadline_reached {
1476 let reason = if port_open {
1477 "порт DevTools открыт"
1478 } else {
1479 &format!("дедлайн {:?} истёк", STARTUP_IGNORE)
1480 };
1481 eprintln!(
1482 "light dev: окно игнорирования снято ({reason}), \
1483 очередь pending (HTML={}, Source={})",
1484 state.pending_html, state.pending_source
1485 );
1486 if let Some(kind) = apply_pending(&mut state, &restart_tx) {
1487 match kind {
1488 ChangeKind::Html => last_html = Some(Instant::now()),
1489 ChangeKind::Source => last_rs = Some(Instant::now()),
1490 }
1491 }
1492 state.is_starting = false;
1493 }
1494 continue;
1495 }
1496 // 3) События наблюдателя.
1497 match rx.recv_timeout(Duration::from_millis(50)) {
1498 Ok(Ok(event)) => {
1499 // На каждое notify-событие мы НЕ хэшируем сразу: файл может
1500 // быть в середине записи (двухэтапный append + flush), и
1501 // хэш поймает неполное содержимое. Вместо этого:
1502 // 1) фильтруем по watch_paths;
1503 // 2) классифицируем (Html / Source / None) для лога;
1504 // 3) кладём путь в pending_debounce (deduplication по пути,
1505 // чтобы серия событий одного save не раздувала очередь);
1506 // 4) поднимаем таймер debounce (Html / Source).
1507 //
1508 // Хэширование + дедупликация happen в timeout-ветке, ПОСЛЕ
1509 // debounce — к тому моменту файл уже полностью записан.
1510 for path in &event.paths {
1511 if !watch_paths_copy.iter().any(|w| path.starts_with(w)) {
1512 continue;
1513 }
1514 // Лог по ТЗ 3.1: сразу, до debounce.
1515 let kind_log = classify_change(path);
1516 if verbose {
1517 eprintln!(
1518 "light dev: notify event path={path:?} kind={kind_log:?} \
1519 (queued for debounce)"
1520 );
1521 }
1522 // Deduplicate by path within the queue: если тот же путь
1523 // уже в pending_debounce, не добавляем повторно (серия
1524 // событий одного save генерирует 2-3 notify на один путь).
1525 if !pending_debounce.contains(path) {
1526 pending_debounce.push_back(path.clone());
1527 }
1528 }
1529 // Поднимаем таймеры debounce: если любое из релевантных
1530 // изменений пришло, debounce обнуляется и начинает отсчёт
1531 // заново (стандартный trailing-edge debounce).
1532 if event
1533 .paths
1534 .iter()
1535 .any(|p| watch_paths_copy.iter().any(|w| p.starts_with(w)))
1536 {
1537 let has_html = event.paths.iter().any(|p| {
1538 watch_paths_copy.iter().any(|w| p.starts_with(w))
1539 && matches!(classify_change(p), Some(ChangeKind::Html))
1540 });
1541 let has_src = event.paths.iter().any(|p| {
1542 watch_paths_copy.iter().any(|w| p.starts_with(w))
1543 && matches!(classify_change(p), Some(ChangeKind::Source))
1544 });
1545 if has_html {
1546 last_html = Some(Instant::now());
1547 }
1548 if has_src {
1549 last_rs = Some(Instant::now());
1550 }
1551 }
1552 }
1553 Ok(Err(e)) => {
1554 eprintln!("light dev: ошибка наблюдателя: {e}");
1555 }
1556 Err(mpsc::RecvTimeoutError::Timeout) => {
1557 // Дебаунс + hash-дедупликация ПОСЛЕ debounce.
1558 //
1559 // Порядок (ТЗ 0.14.2-A-fix4):
1560 // 1) debounce: дождались тишины (HTML_DEBOUNCE / RS_DEBOUNCE);
1561 // 2) hash: хэшируем содержимое каждого пути из pending_debounce
1562 // (файл уже полностью записан, потому что прошло ≥ 100/500 мс);
1563 // 3) dedup: отбрасываем пути, чей хэш == last_hashes[path]
1564 // (повторные события того же содержимого);
1565 // 4) dispatch: берём ПЕРВЫЙ «чистый» путь, классифицируем и
1566 // передаём в handle_watch_event.
1567 //
1568 // Важно: если pending_debounce пусто — ничего не делаем, даже
1569 // если таймеры debounce истекли (таймеры поднимаются только
1570 // при добавлении путей, так что это ситуация невозможна, но
1571 // защита на случай race с respawn).
1572 if !state.is_starting && !state.has_pending() {
1573 let html_fired =
1574 matches!(last_html, Some(t) if t.elapsed() >= HTML_DEBOUNCE);
1575 let rs_fired = matches!(last_rs, Some(t) if t.elapsed() >= RS_DEBOUNCE);
1576 if html_fired || rs_fired {
1577 if html_fired {
1578 last_html = None;
1579 }
1580 if rs_fired {
1581 last_rs = None;
1582 }
1583 if verbose {
1584 eprintln!(
1585 "light dev: debounce fired, {} paths pending",
1586 pending_debounce.len()
1587 );
1588 }
1589 // Проходим по pending_debounce: хэшируем и дедуплицируем.
1590 // Файл уже полностью записан (debounce отработал).
1591 let mut clean_events: std::collections::VecDeque<PathBuf> =
1592 std::collections::VecDeque::new();
1593 while let Some(path) = pending_debounce.pop_front() {
1594 match file_hash(&path, verbose) {
1595 Some(hash) => {
1596 // Сравниваем с последним известным хэшем
1597 // этого пути:
1598 // - совпадает → повторное событие того же
1599 // содержимого → игнорируем (по ТЗ:
1600 // "duplicate notify event, ignored");
1601 // - отличается → настоящее изменение →
1602 // обновляем last_hashes и кладём в
1603 // clean_events.
1604 match last_hashes.get(&path) {
1605 Some(&last) if last == hash => {
1606 if verbose {
1607 eprintln!(
1608 "light dev: duplicate notify event for \
1609 {path:?} (hash unchanged) — ignored"
1610 );
1611 }
1612 }
1613 _ => {
1614 last_hashes.insert(path.clone(), hash);
1615 clean_events.push_back(path);
1616 }
1617 }
1618 }
1619 // Файл недоступен (удалён, ещё пишется после
1620 // 3 попыток чтения): НЕ дедуплицируем —
1621 // обрабатываем как изменение (консервативно).
1622 None => {
1623 if verbose {
1624 eprintln!(
1625 "light dev: file_hash returned None for \
1626 {path:?} (treated as real change, no dedup)"
1627 );
1628 }
1629 clean_events.push_back(path);
1630 }
1631 }
1632 }
1633 if verbose {
1634 eprintln!(
1635 "light dev: {} clean events after hash-dedup",
1636 clean_events.len()
1637 );
1638 }
1639 // Берём ПЕРВЫЙ чистый путь, классифицируем и
1640 // передаём в handle_watch_event (по ТЗ 1.3).
1641 if let Some(first) = clean_events.pop_front() {
1642 let kind = classify_change(&first);
1643 // Лог по ТЗ 3.4: при передаче в handle.
1644 eprintln!("light dev: dispatching {kind:?} change for {first:?}");
1645 match handle_watch_event(&mut state, kind) {
1646 WatchAction::None => {
1647 // Изменение во время сборки: просто
1648 // запомнили (pending_флаги подняты внутри
1649 // handle_watch_event).
1650 }
1651 WatchAction::ApplyPending => {
1652 if let Some(akind) = apply_pending(&mut state, &restart_tx)
1653 {
1654 eprintln!(
1655 "light dev: применяю pending (kind={akind:?})"
1656 );
1657 match akind {
1658 ChangeKind::Html => {
1659 last_html = Some(Instant::now());
1660 }
1661 ChangeKind::Source => {
1662 last_rs = Some(Instant::now());
1663 }
1664 }
1665 }
1666 }
1667 WatchAction::ReloadHtml => {
1668 eprintln!(
1669 "light dev: HTML change → RELOAD_TEMPLATE \
1670 (без пересборки)"
1671 );
1672 let _ = restart_tx.send((Instant::now(), ChangeKind::Html));
1673 last_html = Some(Instant::now());
1674 }
1675 WatchAction::Restart => {
1676 eprintln!(
1677 "light dev: Source change → RESTART \
1678 (пересборка)"
1679 );
1680 let _ =
1681 restart_tx.send((Instant::now(), ChangeKind::Source));
1682 last_rs = Some(Instant::now());
1683 }
1684 }
1685 } else {
1686 if verbose {
1687 eprintln!(
1688 "light dev: все события отфильтрованы hash-дедупом — \
1689 action не требуется"
1690 );
1691 }
1692 }
1693 }
1694 }
1695 }
1696 Err(mpsc::RecvTimeoutError::Disconnected) => break,
1697 }
1698 }
1699 });
1700
1701 loop {
1702 // Дедлайн защиты от вечной блокировки: если порт 9229 так и не
1703 // открылся за STARTUP_IGNORE (сборка зависла), watcher сам закроет
1704 // окно игнорирования (он владеет pending-флагами и знает, что
1705 // применить). Главный поток здесь ничего не решает.
1706 //
1707 // Внутренний цикл опроса: выходим в наружный цикл по первому событию
1708 // (restart от наблюдателя, завершение фоновой сборки либо выход
1709 // дочернего процесса).
1710 //
1711 // ТЗ 0.14.2-B: при .rs change мы НЕ убиваем старый процесс сразу —
1712 // запускаем `cargo build` в `target/hot-build` (рекурсивно, но не
1713 // блокируй watcher: он живёт в своём треде), а когда сборка закончится:
1714 // - exit 0 → SAVE_STATE, copy fresh → hot-run, kill old, spawn new
1715 // - exit != 0 → старый продолжает работать; stderr cargo уже на консоли.
1716 enum Ev {
1717 Restart(ChangeKind),
1718 RebuildDone(i32),
1719 Exited(std::process::ExitStatus),
1720 }
1721 let ev = loop {
1722 if let Ok((_ts, kind)) = restart_rx.try_recv() {
1723 break Ev::Restart(kind);
1724 }
1725 // Завершение фоновой сборки: poll rebuild-child в основном потоке.
1726 // `lock().ok()` — при poisoned mutex (краш в другом треде) просто
1727 // пропускаем опрос; main продолжит работать с app-процессом.
1728 let rebuild_done = rebuild_arc.lock().ok().and_then(|mut g| match g.as_mut() {
1729 Some(c) => c.try_wait().ok().flatten(),
1730 None => None,
1731 });
1732 if let Some(status) = rebuild_done {
1733 let code = status.code().unwrap_or(1);
1734 if let Ok(mut g) = rebuild_arc.lock() {
1735 *g = None;
1736 }
1737 break Ev::RebuildDone(code);
1738 }
1739 // Опрос дочернего процесса: если mutex poisoned — пропускаем tick;
1740 // если `try_wait` вернул Err — тоже выходим с exit code 1 (не
1741 // продолжаем работать с процессом, который не удалось опросить).
1742 let child_exit = match child_arc.lock() {
1743 Ok(mut g) => match g.try_wait() {
1744 Ok(Some(s)) => Some(Ok(s)),
1745 Ok(None) => None, // ещё жив
1746 Err(e) => Some(Err(e)),
1747 },
1748 Err(_) => None,
1749 };
1750 match child_exit {
1751 Some(Ok(s)) => break Ev::Exited(s),
1752 Some(Err(e)) => {
1753 eprintln!("light dev: ошибка ожидания дочернего процесса: {e}");
1754 break Ev::Exited(ExitStatus::from_raw(1));
1755 }
1756 None => {
1757 // Mutex poisoned или process ещё жив.
1758 if child_arc.lock().is_err() {
1759 eprintln!("light dev: child_arc poisoned (пропуск опроса)");
1760 }
1761 }
1762 }
1763 thread::sleep(Duration::from_millis(50));
1764 };
1765 match ev {
1766 Ev::Exited(status) => {
1767 done_flag.store(true, Ordering::Relaxed);
1768 let _ = watcher.join();
1769 // Если rebuild ещё идёт — тоже убиваем (Ctrl+C-сценарий).
1770 if let Ok(mut g) = rebuild_arc.lock()
1771 && let Some(mut r) = g.take()
1772 {
1773 let _ = r.kill();
1774 let _ = r.wait();
1775 }
1776 let code = status.code().unwrap_or(1);
1777 // ТЗ 0.14.3-fix7: Ev::Exited получает только события от
1778 // «живых» детей (kill_child синхронно потребляет exit убитого
1779 // swap-процесса). Exit 0 → user-close: стираем state + geometry.
1780 // Exit != 0 → crash: файлы не трогаем.
1781 if code == 0 {
1782 for stale in [
1783 state_file_path(&package_root),
1784 window_file_path(&package_root),
1785 ] {
1786 if std::fs::remove_file(&stale).is_ok() {
1787 eprintln!("light dev: removed {:?}", stale);
1788 }
1789 }
1790 eprintln!("light dev: user close — state + geometry removed");
1791 eprintln!(
1792 "light dev: app exited cleanly (window closed by user or session end)"
1793 );
1794 } else {
1795 eprintln!("light dev: app crashed (exit code {code})");
1796 }
1797 return Ok(code);
1798 }
1799 Ev::RebuildDone(exit_code) => {
1800 // Фоновая сборка закончилась. State-machine решает: swap или keep-old.
1801 let outcome = sm.poll_outcome(exit_code);
1802 if let RebuildOutcome::Success = outcome {
1803 // === SWAP: SAVE_STATE → copy fresh → kill old → spawn new ===
1804 // Порядок (ТЗ 0.14.2-B-fix2 3): copy работает и на живом
1805 // exe (это отдельный файл), kill — после copy, но до spawn
1806 // нового (чтобы не было двух видимых окон).
1807 let took = sm
1808 .started_at
1809 .map(|t| Instant::now().saturating_duration_since(t))
1810 .unwrap_or_default();
1811 eprintln!("light dev: rebuild succeeded (took {took:?}), swapping…");
1812 if let Err(e) = request_save_state(DEVTOOLS_PORT) {
1813 eprintln!(
1814 "light dev: не удалось отправить SAVE_STATE перед swap: {e} \
1815 (состояние может быть не сохранено)"
1816 );
1817 } else {
1818 eprintln!(
1819 "light dev: state saved to {:?}",
1820 state_file_path(&package_root)
1821 );
1822 }
1823 // Короткая пауза: даём UI-потоку время обработать команду
1824 // и записать файл, прежде чем уберём процесс.
1825 thread::sleep(Duration::from_millis(200));
1826 // a) Копируем свежий бинарник из hot-build в hot-run.
1827 // Никто не держит fresh-бинарник (запущена старая
1828 // копия в hot-run), run_exe новое имя — можно писать.
1829 let new_run_exe = match copy_fresh_to_run(&build_target, &run_dir, &bin) {
1830 Ok(p) => p,
1831 Err(e) => {
1832 eprintln!(
1833 "light dev: не удалось скопировать свежий бинарник: {e} \
1834 (old остаётся)"
1835 );
1836 continue;
1837 }
1838 };
1839 eprintln!("light dev: copied fresh build to {:?}", new_run_exe);
1840 // ТЗ 0.15.1-fix3: graceful save перед kill — отправляем
1841 // SAVE_ALL и ожидаем ok (до 500 мс). UI-поток синхронно
1842 // пишет state.json + window.json. Если таймаут/ошибка —
1843 // продолжаем как раньше (TerminateProcess без graceful).
1844 request_save_all(DEVTOOLS_PORT);
1845 // b) Убиваем старый app (через MutexGuard; `kill_child`
1846 // принимает `&mut Child`).
1847 let old_pid = child_arc.lock().map(|g| g.id()).unwrap_or(0);
1848 if let Ok(mut g) = child_arc.lock() {
1849 kill_child(&mut g);
1850 } else {
1851 eprintln!("light dev: child_arc poisoned — не смогла убить old");
1852 }
1853 // c) Spawn нового из копии в hot-run.
1854 let mut new_cmd = Command::new(&new_run_exe);
1855 new_cmd.stdin(Stdio::inherit());
1856 new_cmd.stdout(Stdio::inherit());
1857 new_cmd.stderr(Stdio::inherit());
1858 apply_state_env(&mut new_cmd, &package_root);
1859 apply_window_env(&mut new_cmd, &package_root);
1860 // Swap-процесс не должен удалять state.json при закрытии
1861 // (state уже сохранён предыдущим процессом через SAVE_STATE
1862 // или будет восстановлен из файла).
1863 new_cmd.env("LIGHT_SWAP", "1");
1864 // ТЗ 0.14.3-fix5 (откат fix4): по умолчанию состояние
1865 // СОХРАНЯЕТСЯ при .rs change (как до fix4): child
1866 // восстанавливает count из state.json, а вычисляемые
1867 // значения (методы, например status) пересчитываются из
1868 // нового кода. --preserve-state — алиас (те же эффекты),
1869 // поэтому блок не зависит от флага.
1870 new_cmd.env("LIGHT_RESUME_FROM_SWAP", "1");
1871 new_cmd.env("LIGHT_PRESERVE_STATE", "1");
1872 if args.preserve_state {
1873 eprintln!("light dev: .rs changed → state preserved (--preserve-state)");
1874 } else {
1875 eprintln!("light dev: .rs changed → state preserved (default)");
1876 }
1877 eprintln!("light dev: clean exit during swap — state.json kept");
1878 match new_cmd.spawn() {
1879 Ok(c) => {
1880 let new_pid = c.id();
1881 if let Ok(mut g) = child_arc.lock() {
1882 *g = c;
1883 } else {
1884 eprintln!(
1885 "light dev: child_arc poisoned — не записала новый process"
1886 );
1887 }
1888 // ТЗ 0.14.3-fix7: старый child уже убив синхронно
1889 // (kill_child → kill + wait), так что его exit НЕ
1890 // попадёт в Ev::Exited. Новый child — обычный:
1891 // если пользователь закроет X → user-close → удалить
1892 // state + geometry. pending_swap остаётся false.
1893 // Чистим старые копии (кроме текущей run_exe) +
1894 // age-привка: убиваем всё, что старше STALE_RUN_AGE
1895 // или чей суффикс-timestamp не совпадает с «сегодня».
1896 cleanup_run_dir(&run_dir, &new_run_exe);
1897 let pruned = prune_stale_run_exes(&run_dir, &new_run_exe);
1898 if pruned > 0 {
1899 eprintln!(
1900 "light dev: pruned {pruned} stale executables from hot-run"
1901 );
1902 }
1903 // Сбрасываем watcher в is_starting: он сам решит,
1904 // когда закрывать окно (порт 9229 открылся либо
1905 // дедлайн STARTUP_IGNORE прошёл).
1906 let _ = state_tx.send(WatchState::starting());
1907 eprintln!(
1908 "light dev: spawning {:?} (build {:?})",
1909 new_run_exe,
1910 fresh_bin_path(&build_target, &bin)
1911 );
1912 eprintln!("light dev: swap done (pid {old_pid} -> {new_pid})");
1913 }
1914 Err(e) => {
1915 eprintln!("light dev: не удалось запустить новое приложение: {e}");
1916 done_flag.store(true, Ordering::Relaxed);
1917 let _ = watcher.join();
1918 return Ok(1);
1919 }
1920 }
1921 }
1922 // RebuildOutcome::Failed(_): старый процесс остаётся; stderr
1923 // cargo уже на консоли. Watcher НЕ сбрасываем (старое app
1924 // работает, is_starting не нужен).
1925 }
1926 Ev::Restart(kind) => match kind {
1927 ChangeKind::Html => {
1928 // HTML-чнейдж → runtime-reload (RELOAD_TEMPLATE) без
1929 // убийства процесса. При неудаче (app умер, порт недоступен)
1930 // НЕ триггерим полную пересборку: ждём, пока TCP снова
1931 // станет доступен (poll каждые 200 мс, до 3 с), и повторяем
1932 // RELOAD_TEMPLATE. Если так и не поднимется — чнейдж
1933 // потеряется до следующего изменения (без лишних 4-5 сек
1934 // на пустой restart).
1935 eprintln!("light dev: HTML change → RELOAD_TEMPLATE (без пересборки)…");
1936 if let Err(e) = request_reload_template_with_retry(DEVTOOLS_PORT) {
1937 eprintln!(
1938 "light dev: RELOAD_TEMPLATE так и не был доставлен: {e} \
1939 (HTML-чнейдж потерян до следующего изменения)"
1940 );
1941 } else {
1942 eprintln!("light dev: RELOAD_TEMPLATE доставлен — продолжаю watch");
1943 }
1944 }
1945 ChangeKind::Source => {
1946 // Source-чнейдж (.rs / Cargo.toml) → seamless rebuild.
1947 //
1948 // 1) Если уже идёт сборка — убиваем её (новый save важнее).
1949 // Лог «cancelled in-flight rebuild (new save)».
1950 // 2) Поднимаем flag + таймер в state-machine.
1951 // 3) Запускаем `cargo build --target-dir target/hot-build`
1952 // в фоне (non-blocking), сохраняем Child в rebuild_arc.
1953 // 4) Главный цикл продолжит опрашивать — на Ev::RebuildDone
1954 // сделает swap (success) или промолчит (failure).
1955 let had_in_flight = {
1956 let old = rebuild_arc.lock().ok().and_then(|mut g| g.take());
1957 old.map(|mut c| {
1958 let _ = c.kill();
1959 let _ = c.wait();
1960 true
1961 })
1962 .unwrap_or(false)
1963 };
1964 if had_in_flight {
1965 eprintln!("light dev: cancelled in-flight rebuild (new save)");
1966 }
1967 eprintln!("light dev: rebuild started (old app still running)");
1968 sm.start();
1969 // Собираем команду для background build. `build_target` —
1970 // тот же путь, что и в warm-up (workspace_root/hot-build).
1971 let mut cmd = Command::new("cargo");
1972 cmd.arg("build");
1973 cmd.args(["-p", &pkg_name, "--features", "light-fw/dev-mode"]);
1974 cmd.args(["--target-dir", &build_target.to_string_lossy()]);
1975 cmd.args(&args.extra_cargo_args);
1976 cmd.stdin(Stdio::inherit())
1977 .stdout(Stdio::inherit())
1978 .stderr(Stdio::inherit());
1979 // Запускаем; если spawn упал — считаем ошибкой (old живёт).
1980 match cmd.spawn() {
1981 Ok(c) => {
1982 if let Ok(mut g) = rebuild_arc.lock() {
1983 *g = Some(c);
1984 } else {
1985 eprintln!(
1986 "light dev: rebuild_arc poisoned — не записала новый build"
1987 );
1988 }
1989 }
1990 Err(e) => {
1991 eprintln!("light dev: не удалось запустить `cargo build`: {e}");
1992 // Сбрасываем флаг: сборка не стартовала, old остаётся.
1993 sm.poll_outcome(1);
1994 }
1995 }
1996 }
1997 },
1998 }
1999 }
2000}
2001
2002#[cfg(test)]
2003mod tests {
2004 use super::{
2005 ChangeKind, RebuildOutcome, RebuildStateMachine, WatchAction, WatchState, bin_name,
2006 build_info_path, check_cli_version_mismatch, classify_change, clean_dev_cache,
2007 cleanup_run_dir, copy_fresh_to_run, file_hash, fresh_bin_path, handle_watch_event,
2008 minor_key, parse_dev_command, parse_workspace_package_version, prune_stale_run_exes,
2009 read_fw_core_version_from_lock, read_fw_core_version_from_workspace_toml,
2010 resolve_package_root, resolve_startup_completion, run_exe_path, same_minor,
2011 save_build_info, wait_port_available,
2012 };
2013 use std::io::Write as _;
2014 use std::net::TcpListener;
2015 use std::path::{Path, PathBuf};
2016 use std::time::{Duration, Instant};
2017
2018 // ===================== handle_watch_event / resolve =====================
2019
2020 /// Нерелевантное событие (kind=None): ни действий, ни изменения флагов.
2021 #[test]
2022 fn watch_event_irrelevant_does_nothing() {
2023 let mut s = WatchState::starting();
2024 assert_eq!(handle_watch_event(&mut s, None), WatchAction::None);
2025 assert!(s.is_starting);
2026 assert!(!s.pending_html);
2027 assert!(!s.pending_source);
2028 }
2029
2030 /// **ТЗ 4.1**: HTML-чнейдж во время `is_starting` → `pending_html=true`,
2031 /// `pending_source=false`. При закрытии окна → ReloadHtml (НЕ restart).
2032 #[test]
2033 fn html_during_start_pending_as_html() {
2034 let mut s = WatchState::starting();
2035 assert_eq!(
2036 handle_watch_event(&mut s, Some(ChangeKind::Html)),
2037 WatchAction::None
2038 );
2039 assert!(s.is_starting);
2040 assert!(s.pending_html);
2041 assert!(!s.pending_source);
2042 // Окно закрывается (порт 9229 открылся либо дедлайн прошёл):
2043 // применение → Html (runtime-reload), не Source (пересборка).
2044 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Html));
2045 }
2046
2047 /// **ТЗ 4.2**: Source-чнейдж во время `is_starting` → `pending_source=true`,
2048 /// `pending_html=false`. При закрытии окна → Source (пересборка).
2049 #[test]
2050 fn source_during_start_pending_as_source() {
2051 let mut s = WatchState::starting();
2052 assert_eq!(
2053 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2054 WatchAction::None
2055 );
2056 assert!(s.is_starting);
2057 assert!(!s.pending_html);
2058 assert!(s.pending_source);
2059 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
2060 }
2061
2062 /// **ТЗ 4.3**: HTML-чнейдж ПОСЛЕ закрытия `is_starting` → немедленный
2063 /// `ReloadHtml` (не restart). Это ключевой фикс race: HTML-чнейдж не
2064 /// должен приводить к полной пересборке.
2065 #[test]
2066 fn html_after_start_emits_reload() {
2067 let mut s = WatchState {
2068 is_starting: false,
2069 pending_html: false,
2070 pending_source: false,
2071 };
2072 assert_eq!(
2073 handle_watch_event(&mut s, Some(ChangeKind::Html)),
2074 WatchAction::ReloadHtml
2075 );
2076 assert!(!s.is_starting);
2077 assert!(!s.pending_html);
2078 assert!(!s.pending_source);
2079 }
2080
2081 /// Source-чнейдж после закрытия окна → немедленный Restart.
2082 #[test]
2083 fn source_after_start_emits_restart() {
2084 let mut s = WatchState {
2085 is_starting: false,
2086 pending_html: false,
2087 pending_source: false,
2088 };
2089 assert_eq!(
2090 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2091 WatchAction::Restart
2092 );
2093 }
2094
2095 /// Приоритет при закрытии окна: Source > Html (пересборка важнее reload).
2096 #[test]
2097 fn startup_priority_source_over_html() {
2098 let mut s = WatchState::starting();
2099 let _ = handle_watch_event(&mut s, Some(ChangeKind::Html));
2100 let _ = handle_watch_event(&mut s, Some(ChangeKind::Source));
2101 assert!(s.pending_html && s.pending_source);
2102 assert_eq!(resolve_startup_completion(&s), Some(ChangeKind::Source));
2103 }
2104
2105 /// Без pending-флагов при закрытии окна — `None` (действий нет).
2106 #[test]
2107 fn startup_no_pending_returns_none() {
2108 let s = WatchState::starting();
2109 assert_eq!(resolve_startup_completion(&s), None);
2110 }
2111
2112 /// Если перед событием был pending и окно уже закрыто — `ApplyPending`
2113 /// (один перезапуск за накопленную серию).
2114 #[test]
2115 fn event_with_stale_pending_returns_apply_pending() {
2116 let mut s = WatchState {
2117 is_starting: false,
2118 pending_html: true,
2119 pending_source: false,
2120 };
2121 assert_eq!(
2122 handle_watch_event(&mut s, Some(ChangeKind::Source)),
2123 WatchAction::ApplyPending
2124 );
2125 // Флаги сброшены.
2126 assert!(!s.pending_html && !s.pending_source);
2127 }
2128
2129 // ===================== wait_port_available / reload retry ==============
2130
2131 /// **ТЗ 4.4** (логика мока TCP): порт стал доступен до дедлайна →
2132 /// `wait_port_available` возвращает `true` раньше, чем истёк `max`.
2133 /// Не запускает пересборку: просто подтверждает, что повторная попытка
2134 /// `RELOAD_TEMPLATE` возможна.
2135 #[test]
2136 fn wait_port_available_becomes_open_within_max() {
2137 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2138 let port = listener.local_addr().unwrap().port();
2139 // Порт уже открыт — должно вернуть true мгновенно (без ожидания max).
2140 let start = Instant::now();
2141 assert!(wait_port_available(
2142 port,
2143 Duration::from_millis(500),
2144 Duration::from_millis(20)
2145 ));
2146 // Задержка < max: подтверждается, что мы не ждали всё время.
2147 assert!(
2148 start.elapsed() < Duration::from_millis(500),
2149 "wait_port_available вернул слишком поздно"
2150 );
2151 }
2152
2153 /// **ТЗ 4.4** (часть 2): порт так и не открылся → `false` через ~`max`.
2154 /// В production это означает: `request_reload_template_with_retry` вернёт
2155 /// `Err` — без триггера полной пересборки (HTML-чнейдж просто потеряется
2156 /// до следующего изменения).
2157 ///
2158 /// Проверяем поведение (не wall-clock время): если функция вернула `false`,
2159 /// значит цикл закончился по дедлайну; если бы она «зависла» или «сработала
2160 /// раньше», мы бы получили `true` или panic по timeout. Дополнительная
2161 /// проверка на wall-clock сознательно опущена — на Windows под нагрузкой
2162 /// `thread::sleep` не даёт точного верхнего bound.
2163 #[test]
2164 fn wait_port_available_never_opens_times_out() {
2165 // Берём свободный порт: listen'им и сразу drop'аем слушателя, чтобы
2166 // порт гарантированно не принимал соединения.
2167 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2168 let port = listener.local_addr().unwrap().port();
2169 drop(listener);
2170 // Короткий дедлайн (80 мс), быстрый poll (10 мс) — тест завершится
2171 // быстро и надёжно. Функция должна вернуть false.
2172 let result =
2173 wait_port_available(port, Duration::from_millis(80), Duration::from_millis(10));
2174 assert!(
2175 !result,
2176 "wait_port_available должен вернуть false для закрытого порта"
2177 );
2178 }
2179
2180 // ===================== parse / classify / package_root ==================
2181
2182 /// Без имени пакета корнем становится текущий каталог (быстрый путь без
2183 /// запуска `cargo metadata`).
2184 #[test]
2185 fn package_root_without_package_is_cwd() {
2186 let root = resolve_package_root(None, std::path::Path::new("/tmp/workspace"));
2187 assert_eq!(root, std::path::PathBuf::from("/tmp/workspace"));
2188 }
2189
2190 /// Без аргументов: ни пакета, ни доп. флагов.
2191 #[test]
2192 fn parse_empty() {
2193 let args = parse_dev_command(&[]);
2194 assert!(args.package.is_none());
2195 assert!(args.extra_cargo_args.is_empty());
2196 }
2197
2198 /// Один аргумент — имя пакета, без доп. флагов.
2199 #[test]
2200 fn parse_package_only() {
2201 let args = parse_dev_command(&["my-app".into()]);
2202 assert_eq!(args.package.as_deref(), Some("my-app"));
2203 assert!(args.extra_cargo_args.is_empty());
2204 }
2205
2206 /// Пакет + доп. флаги: первый свободный — пакет, остальное — в cargo.
2207 #[test]
2208 fn parse_package_and_extra() {
2209 let args = parse_dev_command(&[
2210 "my-app".into(),
2211 "--release".into(),
2212 "--".into(),
2213 "arg0".into(),
2214 ]);
2215 assert_eq!(args.package.as_deref(), Some("my-app"));
2216 assert_eq!(
2217 args.extra_cargo_args,
2218 vec!["--release".to_owned(), "--".to_owned(), "arg0".to_owned()]
2219 );
2220 }
2221
2222 /// Несколько позиционных: первый — пакет, остальные — доп. флаги.
2223 #[test]
2224 fn parse_multiple_positionals_first_is_package() {
2225 let args = parse_dev_command(&["crate-a".into(), "crate-b".into(), "flag".into()]);
2226 assert_eq!(args.package.as_deref(), Some("crate-a"));
2227 assert_eq!(
2228 args.extra_cargo_args,
2229 vec!["crate-b".to_owned(), "flag".to_owned()]
2230 );
2231 }
2232
2233 /// Только доп. флаги (начинаются с `-`): пакета нет, всё уходит в cargo.
2234 #[test]
2235 fn parse_flags_only_no_package() {
2236 let args = parse_dev_command(&["--release".into(), "--quiet".into()]);
2237 assert!(args.package.is_none());
2238 assert_eq!(
2239 args.extra_cargo_args,
2240 vec!["--release".to_owned(), "--quiet".to_owned()]
2241 );
2242 }
2243
2244 /// `--verbose` вынимается из доп. аргументов и не попадает в cargo.
2245 #[test]
2246 fn parse_verbose_flag() {
2247 let args = parse_dev_command(&["my-app".into(), "--verbose".into(), "--release".into()]);
2248 assert!(args.verbose);
2249 assert_eq!(args.package.as_deref(), Some("my-app"));
2250 assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
2251 }
2252
2253 /// `--verbose` без пакета.
2254 #[test]
2255 fn parse_verbose_only() {
2256 let args = parse_dev_command(&["--verbose".into()]);
2257 assert!(args.verbose);
2258 assert!(args.package.is_none());
2259 assert!(args.extra_cargo_args.is_empty());
2260 }
2261
2262 /// `--preserve-state` вынимается из аргументов (не попадает в cargo).
2263 #[test]
2264 fn parse_preserve_state_flag() {
2265 let args = parse_dev_command(&[
2266 "my-app".into(),
2267 "--preserve-state".into(),
2268 "--release".into(),
2269 ]);
2270 assert!(args.preserve_state);
2271 assert_eq!(args.package.as_deref(), Some("my-app"));
2272 assert_eq!(args.extra_cargo_args, vec!["--release".to_owned()]);
2273 }
2274
2275 /// По умолчанию preserve_state = false.
2276 #[test]
2277 fn parse_preserve_state_default_false() {
2278 let args = parse_dev_command(&[]);
2279 assert!(!args.preserve_state);
2280 }
2281
2282 /// `.html` → `ChangeKind::Html`.
2283 #[test]
2284 fn classify_html_file() {
2285 assert_eq!(
2286 classify_change(Path::new("templates/app.html")),
2287 Some(ChangeKind::Html)
2288 );
2289 }
2290
2291 /// `.rs` → `ChangeKind::Source`.
2292 #[test]
2293 fn classify_rs_file() {
2294 assert_eq!(
2295 classify_change(Path::new("src/main.rs")),
2296 Some(ChangeKind::Source)
2297 );
2298 }
2299
2300 /// `Cargo.toml` → `ChangeKind::Source`.
2301 #[test]
2302 fn classify_cargo_toml() {
2303 assert_eq!(
2304 classify_change(Path::new("Cargo.toml")),
2305 Some(ChangeKind::Source)
2306 );
2307 }
2308
2309 /// Неизвестное расширение → `None` (игнорируется).
2310 #[test]
2311 fn classify_unknown_extension_returns_none() {
2312 assert_eq!(classify_change(Path::new("assets/logo.png")), None);
2313 assert_eq!(classify_change(Path::new("data.json")), None);
2314 }
2315
2316 /// Файл без расширения → `None`.
2317 #[test]
2318 fn classify_no_extension_returns_none() {
2319 assert_eq!(classify_change(Path::new("README")), None);
2320 }
2321
2322 // ===================== file_hash (дедупликация) ========================
2323
2324 /// **ТЗ 2.1**: одно и то же содержимое даёт одинаковый хэш (повторное
2325 /// событие игнорируется), новое содержимое — другой хэш (обрабатывается).
2326 ///
2327 /// Сценарий из ТЗ:
2328 /// - записать "a" → hash1
2329 /// - записать "a" ещё раз → hash2 == hash1 (ИГНОРИРУЕТСЯ)
2330 /// - записать "b" → hash3 != hash1 (ОБРАБАТЫВАЕТСЯ)
2331 #[test]
2332 fn file_hash_same_content_same_hash() {
2333 let dir = std::env::temp_dir().join("light_dev_test_file_hash");
2334 std::fs::create_dir_all(&dir).expect("create temp dir");
2335 let path = dir.join("sample.html");
2336 // Убедимся, что файл не существует с предыдущих тестов.
2337 let _ = std::fs::remove_file(&path);
2338
2339 // Записываем "a".
2340 let mut f = std::fs::File::create(&path).expect("create file");
2341 f.write_all(b"a").expect("write 'a'");
2342 f.sync_all().expect("sync");
2343 drop(f);
2344 let hash1 = file_hash(&path, false).expect("hash1 must be Some");
2345
2346 // Записываем "a" ещё раз (симулируем повторное событие notify).
2347 let mut f = std::fs::OpenOptions::new()
2348 .write(true)
2349 .open(&path)
2350 .expect("reopen");
2351 f.set_len(0).expect("truncate");
2352 f.write_all(b"a").expect("rewrite 'a'");
2353 f.sync_all().expect("sync");
2354 drop(f);
2355 let hash2 = file_hash(&path, false).expect("hash2 must be Some");
2356
2357 // Одно и то же содержимое → одинаковый хэш.
2358 assert_eq!(
2359 hash1, hash2,
2360 "одинаковое содержимое должно давать одинаковый хэш"
2361 );
2362
2363 // Записываем "b" (новое содержимое).
2364 let mut f = std::fs::OpenOptions::new()
2365 .write(true)
2366 .open(&path)
2367 .expect("reopen");
2368 f.set_len(0).expect("truncate");
2369 f.write_all(b"b").expect("write 'b'");
2370 f.sync_all().expect("sync");
2371 drop(f);
2372 let hash3 = file_hash(&path, false).expect("hash3 must be Some");
2373
2374 // Разное содержимое → разные хэши (крайне маловероятно совпадение
2375 // у DefaultHasher для двух байтов, но формально проверяем).
2376 assert_ne!(hash1, hash3, "разное содержимое должно дать разный хэш");
2377
2378 // Cleanup.
2379 let _ = std::fs::remove_file(&path);
2380 let _ = std::fs::remove_dir(&dir);
2381 }
2382
2383 /// Файл, которого нет → `None` (не дедуплицируется, обрабатывается как
2384 /// изменение — консервативно).
2385 #[test]
2386 fn file_hash_missing_file_returns_none() {
2387 let dir = std::env::temp_dir().join("light_dev_test_file_hash_missing");
2388 std::fs::create_dir_all(&dir).expect("create temp dir");
2389 let path = dir.join("does_not_exist.html");
2390 let _ = std::fs::remove_file(&path);
2391 assert_eq!(file_hash(&path, false), None);
2392 let _ = std::fs::remove_dir(&dir);
2393 }
2394
2395 // ===================== ТЗ 0.14.2-A-fix4: dedup after debounce =========
2396
2397 /// **ТЗ 4.1**: имитируем двухэтапный save (append + flush) — первое
2398 /// событие видит неполное содержимое, второе — полное. После debounce
2399 /// содержимое уже финальное, и hash-фильтр корректно отбрасывает повторы.
2400 ///
2401 /// Сценарий:
2402 /// 1. записать "a" → событие 1 (неполное, append)
2403 /// 2. дописать "b" → событие 2 (полное, flush)
2404 /// 3. debounce срабатывает → хэшируем: hash("ab")
2405 /// 4. last_hashes["file"] = hash("ab")
2406 /// 5. следующее notify-событие с тем же содержимым → hash == last →
2407 /// ИГНОРИРУЕТСЯ (1 reload на 2 notify-события, не 2).
2408 ///
2409 /// Тест проверяет именно то, что происходит ПОСЛЕ debounce: если бы мы
2410 /// хэшировали ДО debounce, hash1 ("a") != hash2 ("ab"), и оба события
2411 /// прошли бы → двойной reload.
2412 #[test]
2413 fn dedup_after_debounce() {
2414 let dir = std::env::temp_dir().join("light_dev_test_dedup_after_debounce");
2415 std::fs::create_dir_all(&dir).expect("create temp dir");
2416 let path = dir.join("sample.html");
2417 let _ = std::fs::remove_file(&path);
2418
2419 // Этап 1 (append): пишем "a" — это первый notify-событие, файл
2420 // содержит только "a".
2421 let mut f = std::fs::File::create(&path).expect("create");
2422 f.write_all(b"a").expect("write 'a'");
2423 f.sync_all().expect("sync");
2424 drop(f);
2425
2426 // Этап 2 (flush): дописываем "b" — второй notify-событие, файл
2427 // содержит "ab".
2428 let mut f = std::fs::OpenOptions::new()
2429 .append(true)
2430 .open(&path)
2431 .expect("reopen-append");
2432 f.write_all(b"b").expect("append 'b'");
2433 f.sync_all().expect("sync");
2434 drop(f);
2435
2436 // К этому моменту debounce (100/500 мс) уже истёк. Хэшируем финальное
2437 // содержимое "ab":
2438 let final_hash = file_hash(&path, false).expect("hash of 'ab'");
2439
2440 // Симулируем «повторное» notify-событие (Modify(Data) / Modify(Metadata)):
2441 // содержимое НЕ изменилось, хэш должен совпадать.
2442 let dup_hash = file_hash(&path, false).expect("hash on duplicate event");
2443 assert_eq!(
2444 final_hash, dup_hash,
2445 "повторное событие того же содержимого должно дать одинаковый хэш \
2446 (и быть отброшено hash-фильтром)"
2447 );
2448
2449 // Теперь симулируем НОВЫЙ save: содержимое изменилось.
2450 let mut f = std::fs::OpenOptions::new()
2451 .write(true)
2452 .open(&path)
2453 .expect("rewrite");
2454 f.set_len(0).expect("truncate");
2455 f.write_all(b"xyz").expect("write 'xyz'");
2456 f.sync_all().expect("sync");
2457 drop(f);
2458 let new_hash = file_hash(&path, false).expect("hash of 'xyz'");
2459 assert_ne!(
2460 final_hash, new_hash,
2461 "новое содержимое даёт новый хэш → пройдёт hash-фильтр"
2462 );
2463
2464 // Cleanup.
2465 let _ = std::fs::remove_file(&path);
2466 let _ = std::fs::remove_dir(&dir);
2467 }
2468
2469 /// **ТЗ 4.2**: имитируем временную ошибку чтения (например, PermissionDenied
2470 /// при частичной записи, NotFound в момент атомарного удаления). `file_hash`
2471 /// должна ретраить 3 раза с интервалом 50 мс, и после всех попыток вернуть
2472 /// `None` (если файл реально недоступен) или `Some(hash)` (если ошибка
2473 /// ушла в процессе ретраев).
2474 ///
2475 /// Мы не можем реально вызвать `PermissionDenied` под sandbox-ограничениями,
2476 /// поэтому тестируем наблюдаемое поведение:
2477 /// - файл существует и читается → `Some(hash)`;
2478 /// - файла нет вообще → 3 неудачные попытки (лог с "read attempt N failed")
2479 /// и возврат `None`.
2480 ///
2481 /// Поведение «ретраи до 3 раз» проверяем косвенно: время выполнения
2482 /// `file_hash` для несуществующего файла >= 2 × 50 мс (два sleep между
2483 /// тремя попытками), но < 1 с (не блокирует поток watcher надолго).
2484 #[test]
2485 fn file_hash_retries_on_permission_denied() {
2486 let dir = std::env::temp_dir().join("light_dev_test_file_hash_retry");
2487 std::fs::create_dir_all(&dir).expect("create temp dir");
2488 let path = dir.join("missing.html");
2489 let _ = std::fs::remove_file(&path);
2490
2491 let start = Instant::now();
2492 let result = file_hash(&path, false);
2493 let elapsed = start.elapsed();
2494
2495 // Файл не существует: все 3 попытки падают, возвращаем None.
2496 assert_eq!(
2497 result, None,
2498 "несуществующий файл → None после всех ретраев"
2499 );
2500
2501 // Два sleep по 50 мс (между 3 попытками): минимум ~100 мс,
2502 // максимум ~200 мс с запасом на задержки ОС. НЕ проверяем верхнюю
2503 // границу жёстко: Windows `thread::sleep` может overshoot-нуть.
2504 assert!(
2505 elapsed >= Duration::from_millis(100),
2506 "ожидаю минимум 2 × 50 мс ретраев, получил {elapsed:?}"
2507 );
2508 assert!(
2509 elapsed < Duration::from_secs(1),
2510 "ретраи должны завершиться быстро (< 1 с), получили {elapsed:?}"
2511 );
2512
2513 // Cleanup.
2514 let _ = std::fs::remove_dir(&dir);
2515 }
2516
2517 // ===================== ТЗ 0.14.2-B: seamless rebuild state machine ======
2518
2519 /// **ТЗ 6.1**: `start()` поднимает `in_progress` и задаёт `started_at`.
2520 #[test]
2521 fn rebuild_started_flag() {
2522 let mut sm = RebuildStateMachine::default();
2523 assert!(!sm.in_progress, "начальное состояние: in_progress=false");
2524 assert!(
2525 sm.started_at.is_none(),
2526 "начальное состояние: started_at=None"
2527 );
2528 sm.start();
2529 assert!(sm.in_progress, "после start(): in_progress=true");
2530 assert!(
2531 sm.started_at.is_some(),
2532 "после start(): started_at=Some(Instant)"
2533 );
2534 }
2535
2536 /// **ТЗ 6.2**: `poll_outcome(0)` → Success (swap будет выполнен).
2537 /// `started_at` остаётся (для лога «took N s»); `in_progress` сбрасывается.
2538 #[test]
2539 fn rebuild_success_triggers_swap() {
2540 let mut sm = RebuildStateMachine::default();
2541 sm.start();
2542 let Some(started) = sm.started_at else {
2543 panic!("expected started_at to be set after start()");
2544 };
2545 let outcome = sm.poll_outcome(0);
2546 assert_eq!(outcome, RebuildOutcome::Success);
2547 assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
2548 assert_eq!(
2549 sm.started_at,
2550 Some(started),
2551 "started_at сохраняется для лога"
2552 );
2553 }
2554
2555 /// **ТЗ 6.3**: `poll_outcome(N)` при N != 0 → Failed(N) (old живёт).
2556 /// `started_at` остаётся (для возможного последующего пересбора).
2557 #[test]
2558 fn rebuild_failure_keeps_old() {
2559 let mut sm = RebuildStateMachine::default();
2560 sm.start();
2561 let Some(started) = sm.started_at else {
2562 panic!("expected started_at to be set after start()");
2563 };
2564 let outcome = sm.poll_outcome(2);
2565 assert_eq!(outcome, RebuildOutcome::Failed(2));
2566 assert!(!sm.in_progress, "после poll_outcome: in_progress=false");
2567 assert_eq!(sm.started_at, Some(started), "started_at сохраняется");
2568 }
2569
2570 /// **ТЗ 6.4**: новый save во время in-flight сборки → убить старую,
2571 /// `start()` заново (flag + fresh timer).
2572 ///
2573 /// Юнит на уровне SM: проверяем, что после `reset()`+`start()` флаг
2574 /// возвращается к `true`, а `started_at` обновляется. Убийство процесса
2575 /// выполняется в main-loop (через `rebuild_arc`) — это integration test.
2576 #[test]
2577 fn new_save_cancels_in_flight() {
2578 let mut sm = RebuildStateMachine::default();
2579 sm.start();
2580 assert!(sm.in_progress);
2581 let first_start = sm.started_at.unwrap();
2582 // Имитация: новый save пришёл, старую сборку убили, новая запускается.
2583 sm.start();
2584 assert!(sm.in_progress, "после нового start(): in_progress=true");
2585 assert_ne!(
2586 sm.started_at,
2587 Some(first_start),
2588 "started_at должен быть обновлён (новый запуск)"
2589 );
2590 }
2591
2592 // ===================== ТЗ 0.14.2-B-fix2: path helpers ================
2593
2594 /// Бинарник: имя пакета без изменений (cargo не заменяет `-`). Windows: `.exe`.
2595 #[test]
2596 fn bin_name_preserves_dashes() {
2597 assert_eq!(bin_name("my-app"), "my-app");
2598 assert_eq!(bin_name("my-app-x"), "my-app-x");
2599 assert_eq!(bin_name("simple"), "simple");
2600 }
2601
2602 /// Свежий бинарник: `<build_target>/debug/<package-name>[.exe]`.
2603 #[test]
2604 fn fresh_bin_path_layout() {
2605 let base = std::env::temp_dir();
2606 let build_target = base.join("hot-build");
2607 let p = fresh_bin_path(&build_target, "my-app");
2608 let s = p.to_string_lossy().to_string();
2609 assert!(s.contains("hot-build"), "path contains hot-build: {s}");
2610 assert!(
2611 s.ends_with("my-app.exe") || s.ends_with("my-app"),
2612 "correct bin name: {s}"
2613 );
2614 if cfg!(windows) {
2615 assert!(s.ends_with(".exe"), "windows .exe, got: {s}");
2616 }
2617 }
2618
2619 /// Копия для запуска: `<run_dir>/<bin>-<ts>[.exe]` — имя содержит bin
2620 /// и timestamp, расширение `.exe` на Windows.
2621 #[test]
2622 fn run_exe_path_has_timestamp_and_extension() {
2623 let run_dir = std::env::temp_dir().join("hot-run");
2624 let p = run_exe_path(&run_dir, "my-app");
2625 let file = p
2626 .file_name()
2627 .unwrap_or_default()
2628 .to_string_lossy()
2629 .to_string();
2630 assert!(
2631 file.starts_with("my-app-"),
2632 "filename should start with bin + dash, got: {file}"
2633 );
2634 if cfg!(windows) {
2635 assert!(file.ends_with(".exe"), "windows extension, got: {file}");
2636 } else {
2637 assert!(!file.ends_with(".exe"), "no .exe on unix, got: {file}");
2638 }
2639 }
2640
2641 /// `copy_fresh_to_run`: создаёт run_dir, копирует, возвращает путь к копии.
2642 /// `cleanup_run_dir`: удаляет старые .exe, кроме активного.
2643 #[test]
2644 fn copy_and_cleanup_run_dir() {
2645 // Используем CWD-относительный каталог, потому что temp_dir() в этом
2646 // окружении имеет ACL-ограничения (см. предсуществующие ACL-файлы
2647 // dev::tests::file_hash_*). Каталог уникален по pid + nanos и чистится
2648 // в конце теста.
2649 let tmp = std::env::current_dir()
2650 .unwrap_or_else(|_| PathBuf::from("."))
2651 .join(format!(
2652 ".light-dev-test-{}-{}",
2653 std::process::id(),
2654 std::time::SystemTime::now()
2655 .duration_since(std::time::UNIX_EPOCH)
2656 .unwrap_or_default()
2657 .as_nanos()
2658 ));
2659 let build_root = tmp.join("hot-build");
2660 let build_target = build_root.join("debug");
2661 let run_dir = tmp.join("hot-run");
2662 let _ = std::fs::remove_dir_all(&tmp);
2663 std::fs::create_dir_all(&build_target).expect("create build dir");
2664
2665 // Создаём фейковый fresh-бинарник в <build_target>/<bin>[.exe].
2666 let bin = "test-app";
2667 let mut fresh = build_target.join(bin);
2668 #[cfg(windows)]
2669 {
2670 fresh.set_extension("exe");
2671 }
2672 std::fs::write(&fresh, b"fake").expect("write fake binary");
2673
2674 // Копируем: build_root = <tmp>/hot-build.
2675 let copied = copy_fresh_to_run(&build_root, &run_dir, bin).expect("copy should succeed");
2676 assert!(copied.exists(), "copy should exist: {:?}", copied);
2677 let name = copied
2678 .file_name()
2679 .unwrap_or_default()
2680 .to_string_lossy()
2681 .to_string();
2682 assert!(name.starts_with("test-app-"), "name: {name}");
2683
2684 // Чистим: добавляем «старый» .exe и проверяем, что он удалён, а
2685 // текущий остался.
2686 let mut stale = run_dir.join("test-app-oldstale");
2687 #[cfg(windows)]
2688 {
2689 stale.set_extension("exe");
2690 }
2691 std::fs::write(&stale, b"old").expect("write stale");
2692 cleanup_run_dir(&run_dir, &copied);
2693 assert!(!stale.exists(), "stale should be removed: {:?}", stale);
2694 assert!(copied.exists(), "current should remain: {:?}", copied);
2695
2696 let _ = std::fs::remove_dir_all(&tmp);
2697 }
2698
2699 // ===================== TZ 0.14.4: cache invalidation tests =====================
2700
2701 /// ТЗ 6.1: round-trip — `save_build_info` пишет JSON с двумя полями,
2702 /// `read_fw_core_version_from_lock` читает версию из `Cargo.lock`.
2703 #[test]
2704 fn build_info_round_trip() {
2705 let tmp = test_tmp_dir("build_info");
2706 std::fs::create_dir_all(&tmp).expect("create tmp");
2707
2708 let info = build_info_path(&tmp);
2709 save_build_info(&info, "0.14.0", "abc123hash");
2710
2711 // Проверяем, что файл существует и содержит обе версии.
2712 let content = std::fs::read_to_string(&info).expect("read build-info.json");
2713 assert!(content.contains("0.14.0"), "version in file: {content}");
2714 assert!(content.contains("abc123hash"), "hash in file: {content}");
2715
2716 let _ = std::fs::remove_dir_all(&tmp);
2717 }
2718
2719 /// ТЗ 6.1 (часть 2): `read_fw_core_version_from_lock` находит версию
2720 /// light-fw-core в тексте Cargo.lock.
2721 #[test]
2722 fn read_fw_core_version_from_lock_text() {
2723 let tmp = test_tmp_dir("lock_parse");
2724 std::fs::create_dir_all(&tmp).expect("create tmp");
2725 let lock = tmp.join("Cargo.lock");
2726
2727 // Фиктивный Cargo.lock с блоком light-fw-core.
2728 let lock_content = r#"
2729[[package]]
2730name = "light-fw-core"
2731version = "0.14.0"
2732dependencies = [
2733 "serde",
2734]
2735
2736[[package]]
2737name = "other-crate"
2738version = "1.0.0"
2739"#;
2740 std::fs::write(&lock, lock_content).expect("write fake lock");
2741
2742 let version = read_fw_core_version_from_lock(&lock).expect("should find version");
2743 assert_eq!(version, "0.14.0");
2744
2745 let _ = std::fs::remove_dir_all(&tmp);
2746 }
2747
2748 /// ТЗ 6.2: `--clean` чистит правильные пути: hot-build, hot-run, state, deps.
2749 #[test]
2750 fn clean_dev_cache_removes_expected_paths() {
2751 let tmp = test_tmp_dir("clean_cache");
2752 // Создаём структуру: target/hot-build, target/hot-run, .light/state.json,
2753 // target/debug/deps/<package>*.
2754 let target = tmp.join("target");
2755 let hot_build = target.join("hot-build");
2756 let hot_run = target.join("hot-run");
2757 let state_file = tmp.join(".light").join("state.json");
2758 let deps = target.join("debug").join("deps");
2759 let pkg_dep = deps.join("example-devtools-demo-abcdef.exe");
2760
2761 std::fs::create_dir_all(&hot_build).expect("create hot-build");
2762 std::fs::create_dir_all(&hot_run).expect("create hot-run");
2763 std::fs::create_dir_all(tmp.join(".light")).expect("create .light");
2764 std::fs::create_dir_all(&deps).expect("create deps");
2765 std::fs::write(&state_file, "{}").expect("write state");
2766 std::fs::write(&pkg_dep, b"fake").expect("write dep");
2767
2768 // Убеждаемся, что файлы существуют до чистки.
2769 assert!(hot_build.exists());
2770 assert!(hot_run.exists());
2771 assert!(state_file.exists());
2772 assert!(pkg_dep.exists());
2773
2774 // Чистим.
2775 let removed = clean_dev_cache(&tmp, Some("example-devtools-demo"));
2776
2777 // Проверяем, что все четыре пути удалены.
2778 assert!(!hot_build.exists(), "hot-build should be removed");
2779 assert!(!hot_run.exists(), "hot-run should be removed");
2780 assert!(!state_file.exists(), "state.json should be removed");
2781 assert!(!pkg_dep.exists(), "deps artifact should be removed");
2782
2783 // Проверяем, что в списке removed есть наши пути.
2784 let removed_strs: Vec<String> = removed
2785 .iter()
2786 .map(|p| p.to_string_lossy().into_owned())
2787 .collect();
2788 assert!(
2789 removed_strs.iter().any(|s| s.contains("hot-build")),
2790 "removed should contain hot-build: {removed_strs:?}"
2791 );
2792 assert!(
2793 removed_strs.iter().any(|s| s.contains("hot-run")),
2794 "removed should contain hot-run: {removed_strs:?}"
2795 );
2796 assert!(
2797 removed_strs.iter().any(|s| s.contains("state.json")),
2798 "removed should contain state.json: {removed_strs:?}"
2799 );
2800 assert!(
2801 removed_strs
2802 .iter()
2803 .any(|s| s.contains("example-devtools-demo")),
2804 "removed should contain dep artifact: {removed_strs:?}"
2805 );
2806
2807 let _ = std::fs::remove_dir_all(&tmp);
2808 }
2809
2810 /// ТЗ 6.3: age-prune удаляет устаревшие .exe, но не текущий.
2811 /// Активный exe имеет свежий timestamp; старый — «вчерашний» (date ≠ today).
2812 #[test]
2813 fn prune_stale_run_exes_respects_keep_and_age() {
2814 let tmp = test_tmp_dir("prune_stale");
2815 let run_dir = tmp.join("hot-run");
2816 std::fs::create_dir_all(&run_dir).expect("create run dir");
2817
2818 // Активный exe: свежий timestamp (сейчас) — НЕ должен быть удалён.
2819 let active = run_exe_path(&run_dir, "test-app");
2820 std::fs::write(&active, b"active").expect("write active");
2821
2822 // Старый exe: «вчерашний» timestamp → date ≠ today → stale_by_date=true.
2823 // Берём сейчас минус 24 часа в миллисекундах.
2824 let now_ms = std::time::SystemTime::now()
2825 .duration_since(std::time::UNIX_EPOCH)
2826 .unwrap_or_default()
2827 .as_millis();
2828 let yesterday_ms = now_ms.saturating_sub(86_400_000); // 24 hours ago
2829 let old_name = format!("test-app-{yesterday_ms}.exe");
2830 let old_exe = run_dir.join(&old_name);
2831 std::fs::write(&old_exe, b"old").expect("write old");
2832
2833 // Проверяем, что активный НЕ удалён, а старый — удалён.
2834 let pruned = prune_stale_run_exes(&run_dir, &active);
2835 assert!(
2836 pruned >= 1,
2837 "should prune at least 1 stale: pruned={pruned}"
2838 );
2839 assert!(active.exists(), "active should remain: {:?}", active);
2840 assert!(!old_exe.exists(), "old should be removed: {:?}", old_exe);
2841
2842 let _ = std::fs::remove_dir_all(&tmp);
2843 }
2844
2845 /// ТЗ fix1 4.1: `read_fw_core_version_from_workspace_toml` парсит
2846 /// `[workspace.package] version = "0.15.0"` из текста Cargo.toml.
2847 #[test]
2848 fn parse_workspace_package_version_basic() {
2849 let toml_text = r#"
2850[workspace]
2851resolver = "2"
2852members = ["crates/core"]
2853
2854[workspace.package]
2855version = "0.15.0"
2856edition = "2024"
2857license = "MIT"
2858
2859[workspace.dependencies]
2860serde = "1.0"
2861"#;
2862 let ver = parse_workspace_package_version(toml_text).expect("should parse");
2863 assert_eq!(ver, "0.15.0");
2864 }
2865
2866 /// ТЗ fix1 4.1 (часть 2): отсутствие [workspace.package] → None.
2867 #[test]
2868 fn parse_workspace_package_version_missing_section() {
2869 let toml_text = r#"
2870[package]
2871name = "solo-crate"
2872version = "1.0.0"
2873"#;
2874 assert!(parse_workspace_package_version(toml_text).is_none());
2875 }
2876
2877 /// ТЗ fix1 4.2: `read_fw_core_version_from_workspace_toml` читает файл,
2878 /// а при его отсутствии возвращает None (fallback на lock вызывается
2879 /// снаружи через .or_else).
2880 #[test]
2881 fn workspace_toml_read_and_fallback() {
2882 let tmp = test_tmp_dir("toml_fallback");
2883 let _ = std::fs::create_dir_all(&tmp);
2884
2885 // С томом:
2886 let toml_path = tmp.join("Cargo.toml");
2887 let _ = std::fs::write(
2888 &toml_path,
2889 "[workspace.package]\nversion = \"0.16.0\"\nedition = \"2024\"\n",
2890 );
2891 let ver = read_fw_core_version_from_workspace_toml(&toml_path).expect("should read toml");
2892 assert_eq!(ver, "0.16.0");
2893
2894 // Fallback: нет Cargo.toml → None (вызывающий код делает .or_else(lock)).
2895 let missing = tmp.join("nonexistent/Cargo.toml");
2896 assert!(read_fw_core_version_from_workspace_toml(&missing).is_none());
2897
2898 let _ = std::fs::remove_dir_all(&tmp);
2899 }
2900
2901 /// ТЗ fix1 4.3: `clean_dev_cache` удаляет build-info.json.
2902 #[test]
2903 fn clean_dev_cache_removes_build_info() {
2904 let tmp = test_tmp_dir("clean_build_info");
2905 let _ = std::fs::create_dir_all(tmp.join("target/hot-build"));
2906 let _ = std::fs::create_dir_all(tmp.join(".light"));
2907 let bi = build_info_path(&tmp);
2908 let _ = std::fs::write(&bi, "{}");
2909
2910 let removed = clean_dev_cache(&tmp, Some("myapp"));
2911 assert!(!bi.exists(), "build-info.json should be removed");
2912 let removed_strs: Vec<_> = removed
2913 .iter()
2914 .map(|p| p.to_string_lossy().into_owned())
2915 .collect();
2916 assert!(
2917 removed_strs.iter().any(|s| s.contains("build-info")),
2918 "removed should contain build-info: {removed_strs:?}"
2919 );
2920 let _ = std::fs::remove_dir_all(&tmp);
2921 }
2922
2923 // ===================== same_minor / minor_key / check_cli_version_mismatch =====
2924
2925 /// `"0.15.3"` → `"0.15"`, `"0.15"` → `"0.15"`, `"0.14"` → `"0.14"`.
2926 #[test]
2927 fn minor_key_extracts_major_minor() {
2928 assert_eq!(minor_key("0.15.3"), "0.15");
2929 assert_eq!(minor_key("0.15"), "0.15");
2930 assert_eq!(minor_key("0.14"), "0.14");
2931 assert_eq!(minor_key("1.2.3"), "1.2");
2932 assert_eq!(minor_key("1.2"), "1.2");
2933 }
2934
2935 /// Same minor: `"0.15.3"` vs `"0.15"` → true (no invalidation).
2936 #[test]
2937 fn same_minor_exact_vs_range() {
2938 assert!(same_minor("0.15.3", "0.15"));
2939 assert!(same_minor("0.15", "0.15.3"));
2940 assert!(same_minor("0.15.3", "0.15.2"));
2941 assert!(!same_minor("0.14", "0.15"));
2942 assert!(!same_minor("0.15", "0.16"));
2943 assert!(!same_minor("1.0", "0.99"));
2944 }
2945
2946 /// `check_cli_version_mismatch`: project version from TOML is a range
2947 /// (`light = "0.15"`), installed is exact (`0.15.4`) → no mismatch.
2948 #[test]
2949 fn cli_version_mismatch_same_minor() {
2950 let tmp = test_tmp_dir("mismatch_same");
2951 let _ = std::fs::create_dir_all(&tmp);
2952 let toml_file = tmp.join("Cargo.toml");
2953 std::fs::write(&toml_file, "[dependencies]\nlight = \"0.15\"\n").unwrap();
2954 let lock = tmp.join("Cargo.lock");
2955 // No mismatch: 0.15.4 vs 0.15 → same minor.
2956 assert_eq!(
2957 check_cli_version_mismatch("0.15.4", &toml_file, &lock),
2958 None
2959 );
2960 let _ = std::fs::remove_dir_all(&tmp);
2961 }
2962
2963 /// `check_cli_version_mismatch`: different minor → Some warning.
2964 #[test]
2965 fn cli_version_mismatch_different_minor() {
2966 let tmp = test_tmp_dir("mismatch_diff");
2967 let _ = std::fs::create_dir_all(&tmp);
2968 let toml_file = tmp.join("Cargo.toml");
2969 std::fs::write(&toml_file, "[dependencies]\nlight = \"0.14\"\n").unwrap();
2970 let lock = tmp.join("Cargo.lock");
2971 let result = check_cli_version_mismatch("0.15.4", &toml_file, &lock);
2972 assert_eq!(result, Some(("0.15.4".to_string(), "0.14".to_string())));
2973 let _ = std::fs::remove_dir_all(&tmp);
2974 }
2975
2976 /// Вспомогательная: создаёт уникальный CWD-относительный каталог для теста.
2977 fn test_tmp_dir(label: &str) -> PathBuf {
2978 std::env::current_dir()
2979 .unwrap_or_else(|_| PathBuf::from("."))
2980 .join(format!(
2981 ".light-dev-test-{}-{}-{}",
2982 std::process::id(),
2983 std::time::SystemTime::now()
2984 .duration_since(std::time::UNIX_EPOCH)
2985 .unwrap_or_default()
2986 .as_nanos(),
2987 label
2988 ))
2989 }
2990}