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