Skip to main content

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) = &current_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}