Skip to main content

code_repo_wiki/incremental/
watch.rs

1//! 文件系统监听模块
2//!
3//! 使用 `notify-debouncer-full` crate 实现防抖文件监听,
4//! 将底层 notify 事件去重、聚合后通过 channel 上报。
5
6use std::path::{Path, PathBuf};
7use std::sync::mpsc;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11use notify::RecursiveMode;
12use notify_debouncer_full::{new_debouncer, DebouncedEvent, DebounceEventResult};
13
14use crate::ingest::scanner::NOISE_DIRS;
15use crate::ingest::parser::SUPPORTED_EXTENSIONS;
16
17/// 冷却窗口常量(v31 C-07):连续编辑期间合并事件,安静 `COOLDOWN_QUIET_MS`
18/// 或首个事件后 `COOLDOWN_DEADLINE_MS` 触发一次合并增量。
19/// 2s/5s 取值依据(SME v31):主流 IDE 自动保存频率 1-2s,2s 静默覆盖单次
20/// 保存后的停顿;5s 上限保证批量编辑(git checkout、重构重命名)不会无限
21/// 推迟更新。
22pub const COOLDOWN_QUIET_MS: u64 = 2000;
23pub const COOLDOWN_DEADLINE_MS: u64 = 5000;
24
25/// 文件变更类型(由监听事件显式标记,下游无需以 exists() 推断删除)
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ChangeKind {
28    /// 文件被创建
29    Created,
30    /// 文件内容/元数据被修改(含重命名,保守视为修改)
31    Modified,
32    /// 文件被删除
33    Deleted,
34}
35
36/// 一次防抖聚合产生的事件:同一 kind 的路径合并去重
37#[derive(Debug, Clone)]
38pub struct WatchEvent {
39    pub paths: Vec<PathBuf>,
40    pub kind: ChangeKind,
41}
42
43/// 启动文件监听并执行回调(阻塞版本)
44///
45/// v30+:监听根恒为仓库根(扫描范围已硬编码为全量遍历+内置过滤),
46/// 事件上报时按支持语言扩展名与噪音目录过滤(与 scanner 同一边界)。
47///
48/// 边界:删除事件的路径已不存在于磁盘,回调内不能读取文件内容;
49/// 事件类型(ChangeKind)已显式携带,下游不再以 exists() 推断删除。
50pub fn run_watch_loop(
51    root: &Path,
52    stop_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
53    on_change: impl Fn(Vec<WatchEvent>) + Send + 'static,
54) -> Result<()> {
55    let include_exts = supported_exts();
56
57    let (tx, rx) = mpsc::channel::<DebounceEventResult>();
58
59    let mut debouncer = new_debouncer(
60        Duration::from_millis(300),
61        None,
62        move |result: DebounceEventResult| {
63            // tx.send 失败 = 接收端(主循环 rx)已 drop。此时事件本就无人消费,
64            // 静默丢弃是正确语义(不存在"数据丢失"——通道已断);主循环退出
65            // 由外部信号/错误驱动,不由 send 结果驱动,故不在此处理 Err。
66            let _ = tx.send(result);
67        },
68    )
69    .with_context(|| "创建文件防抖监听器失败")?;
70
71    // v30+:监听根=仓库根(全量监听,事件按扩展名/噪音目录过滤——
72    // 目录结构因项目而异,路径模式无法通用,语言才是能力边界)
73    let watch_roots = vec![root.to_path_buf()];
74    for watch_root in &watch_roots {
75        if !watch_root.exists() {
76            tracing::warn!("监听根不存在,跳过: {}", watch_root.display());
77            continue;
78        }
79        // notify-debouncer-full 0.4:Debouncer 自身实现了 Watcher trait,
80        // 可以直接调用 .watch(),无需 .watcher()
81        debouncer
82            .watch(watch_root.as_path(), RecursiveMode::Recursive)
83            .with_context(|| format!("监听目录失败: {}", watch_root.display()))?;
84    }
85
86    tracing::info!(
87        "文件监听已启动(阻塞模式): {}",
88        watch_roots
89            .iter()
90            .map(|p| p.display().to_string())
91            .collect::<Vec<_>>()
92            .join(", ")
93    );
94
95    // v14 F 组(t06 拍板):Ctrl-C 优雅退出——主循环每 500ms 检查停止
96    // 标记,置位时退出(当前正在执行的 on_change 完成后才检查,即
97    // "等当前增量生成完成";不会在生成中途打断状态落盘)。
98    // v31 C-07:冷却窗口——高频编辑(IDE 自动保存、批量重构)期间把事件
99    // 累积到 pending,安静 2s(尾沿)或首个事件后 5s(强制)触发一次
100    // 合并增量,避免连续保存 N 次触发 N 次全量管线(Token 节省核心)。
101    use std::sync::atomic::Ordering;
102    let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
103    let mut pending_first_at: Option<Instant> = None;
104    let mut quiet_since: Option<Instant> = None;
105    loop {
106        if stop_flag.load(Ordering::Relaxed) {
107            // 退出丢弃 pending:事件只是"待触发"的请求,进程退出后由
108            // 下次启动的全量/增量兜底,丢弃不产生数据丢失
109            tracing::info!("收到停止信号,文件监听退出");
110            return Ok(());
111        }
112        match rx.recv_timeout(Duration::from_millis(500)) {
113            Ok(Ok(events)) => {
114                // 聚合 + 折叠(批内同路径跨 kind 按最终态合并),
115                // 事件类型显式传递给下游,删除不再依赖 exists() 推断
116                let watch_events = process_batch(&events, &include_exts);
117                if !watch_events.is_empty() {
118                    // 跨批按时间序收敛(apply_batch):同路径后到达的 kind
119                    // 覆盖先到达的——删除重建(git checkout/codegen clean+
120                    // rebuild/IDE save-as)收敛为 Created/Modified 而非删除
121                    // 优先(删除优先只对批内最终态成立,跨批会把"删→建"
122                    // 误判为"仍删除",下游误删现存文件的产物页)
123                    apply_batch(&mut pending, &watch_events);
124                    let now = Instant::now();
125                    pending_first_at.get_or_insert(now);
126                    quiet_since = Some(now);
127                }
128            }
129            Ok(Err(errors)) => {
130                for e in &errors {
131                    tracing::warn!("文件监听错误: {:?}", e);
132                }
133            }
134            // 超时(轮询停止标记 + 冷却窗口判定)是正常路径:
135            // 回到循环顶检查 stop_flag 与 pending 是否应触发
136            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
137                if should_flush_now(&pending, &mut pending_first_at, &mut quiet_since) {
138                    on_change(flush_events(&std::mem::take(&mut pending)));
139                }
140            }
141            // Disconnected = 接收端全部 drop,监听无意义
142            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
143        }
144        // 持续事件流下(批间隔 <500ms 恒走 Ok 分支)5s 强制截止也必须可达
145        // ——每批处理完后求值一次 deadline(reviewer 修正:原实现只在
146        // Timeout 分支求值,<500ms 间隔的持续编辑会无限推迟触发)。
147        // quiet=0:只依赖强制截止(与 should_flush 共享同一判定逻辑,DRY;
148        // 阈值语义由 test_should_flush_deadline_forced 纯函数测试覆盖)
149        if let Some(first_at) = pending_first_at {
150            let total = Instant::now().saturating_duration_since(first_at);
151            if should_flush(Duration::ZERO, total) {
152                on_change(flush_events(&std::mem::take(&mut pending)));
153                pending_first_at = None;
154                quiet_since = None;
155            }
156        }
157    }
158    Ok(())
159}
160
161/// 冷却窗口触发判定(纯函数,可测):
162/// 尾沿——安静期 ≥ `COOLDOWN_QUIET_MS`(连续编辑停止后尽快收敛);
163/// 强制——总时长 ≥ `COOLDOWN_DEADLINE_MS`(防无限推迟,保证最终一致性)。
164fn should_flush(quiet_elapsed: Duration, total_elapsed: Duration) -> bool {
165    quiet_elapsed >= Duration::from_millis(COOLDOWN_QUIET_MS)
166        || total_elapsed >= Duration::from_millis(COOLDOWN_DEADLINE_MS)
167}
168
169/// Timeout 分支的触发判定:pending 非空且冷却窗口到点
170fn should_flush_now(
171    pending: &[(PathBuf, ChangeKind)],
172    pending_first_at: &mut Option<Instant>,
173    quiet_since: &mut Option<Instant>,
174) -> bool {
175    if pending.is_empty() {
176        return false;
177    }
178    let now = Instant::now();
179    let quiet_elapsed = quiet_since
180        .map(|q| now.saturating_duration_since(q))
181        .unwrap_or(Duration::ZERO);
182    let total_elapsed = pending_first_at
183        .map(|f| now.saturating_duration_since(f))
184        .unwrap_or(Duration::ZERO);
185    let flush = should_flush(quiet_elapsed, total_elapsed);
186    if flush {
187        *pending_first_at = None;
188        *quiet_since = None;
189    }
190    flush
191}
192
193/// 把一批(已折叠的)事件应用到跨批累积表:同路径后到达的 kind 覆盖
194/// 先到达的(按时间序收敛),不同路径追加。路径在该批内唯一
195/// (process_batch 已按最终态折叠),无同批重复覆盖问题。
196fn apply_batch(pending: &mut Vec<(PathBuf, ChangeKind)>, events: &[WatchEvent]) {
197    for event in events {
198        for p in &event.paths {
199            match pending.iter_mut().find(|(path, _)| path == p) {
200                Some(entry) => entry.1 = event.kind,
201                None => pending.push((p.clone(), event.kind)),
202            }
203        }
204    }
205}
206
207/// 把累积表分组为待回调的 WatchEvent 列表(保留各组首次出现顺序,
208/// 与单批 aggregate 语义一致;下游逐组跑 pipeline)
209fn flush_events(pending: &[(PathBuf, ChangeKind)]) -> Vec<WatchEvent> {
210    let mut out: Vec<WatchEvent> = Vec::new();
211    for (path, kind) in pending {
212        match out.iter_mut().find(|e| e.kind == *kind) {
213            Some(ev) => ev.paths.push(path.clone()),
214            None => out.push(WatchEvent {
215                paths: vec![path.clone()],
216                kind: *kind,
217            }),
218        }
219    }
220    out
221}
222
223/// 将防抖窗口内的事件批处理为待回调的 WatchEvent 列表(纯函数,主循环可测化)
224///
225/// 两步:aggregate_events 按 kind 分组去重合并 → fold_events 按最终态语义
226/// 折叠同路径的跨 kind 事件。窗口内同路径最多产出一个 WatchEvent。
227pub fn process_batch(events: &[DebouncedEvent], include_exts: &[String]) -> Vec<WatchEvent> {
228    fold_events(aggregate_events(events, include_exts))
229}
230
231/// 按最终态语义折叠同路径的跨 kind 事件,消除同窗口双跑完整流水线
232///
233/// 规则(文件在窗口内的最终状态决定唯一事件):
234/// - 路径出现在 Deleted 事件中 → 从其他 kind 移除(最终不存在,删除优先于一切)
235/// - 路径同时出现在 Created 与 Modified → 保留 Modified(最终存在且被修改)
236///
237/// 不同路径、不同 kind 的事件保持独立;输出保持各 kind 首次出现的顺序。
238fn fold_events(events: Vec<WatchEvent>) -> Vec<WatchEvent> {
239    let mut out: Vec<WatchEvent> = Vec::new();
240    for event in &events {
241        let paths: Vec<PathBuf> = event
242            .paths
243            .iter()
244            .filter(|p| {
245                // 删除优先:路径最终不存在时,非 Deleted 记录无意义
246                if event.kind != ChangeKind::Deleted && has_path(&events, ChangeKind::Deleted, p) {
247                    return false;
248                }
249                // Modified 优先于 Created:文件最终存在且被修改过
250                if event.kind == ChangeKind::Created && has_path(&events, ChangeKind::Modified, p) {
251                    return false;
252                }
253                true
254            })
255            .cloned()
256            .collect();
257        if !paths.is_empty() {
258            out.push(WatchEvent { paths, kind: event.kind });
259        }
260    }
261    out
262}
263
264/// 判断 events 中是否存在指定 kind 且包含该路径的事件
265fn has_path(events: &[WatchEvent], kind: ChangeKind, path: &Path) -> bool {
266    events
267        .iter()
268        .any(|e| e.kind == kind && e.paths.iter().any(|p| p == path))
269}
270///
271/// Modify 与 Remove 混合的窗口产出两个独立事件,删除路径不与被
272/// 修改路径混在一起,下游可直入清理而无需 exists() 推断。
273fn aggregate_events(events: &[DebouncedEvent], include_exts: &[String]) -> Vec<WatchEvent> {
274    let mut out: Vec<WatchEvent> = Vec::new();
275    for debounced in events {
276        let kind = change_kind_of(&debounced.event.kind);
277        for p in &debounced.event.paths {
278            if !should_report(p, include_exts) {
279                continue;
280            }
281            match out.iter_mut().find(|e| e.kind == kind) {
282                // 该 kind 分组已存在且路径未记录 → 合并
283                Some(ev) if !ev.paths.contains(p) => ev.paths.push(p.clone()),
284                // 已记录过该路径(去重)
285                Some(_) => {}
286                // 首个该 kind 的事件
287                None => out.push(WatchEvent { paths: vec![p.clone()], kind }),
288            }
289        }
290    }
291    out
292}
293
294/// 将 notify 事件类型映射为业务变更类型
295///
296/// Access/Any/Other 等未知事件保守视为修改——宁可多一次增量更新,
297/// 也不让未知事件误触发删除清理。
298fn change_kind_of(kind: &notify::EventKind) -> ChangeKind {
299    match kind {
300        notify::EventKind::Create(_) => ChangeKind::Created,
301        notify::EventKind::Remove(_) => ChangeKind::Deleted,
302        _ => ChangeKind::Modified,
303    }
304}
305
306/// 支持语言扩展名列表(去点前缀、小写,供事件上报过滤——与
307/// parser::SUPPORTED_EXTENSIONS 同源,扫与听共用同一能力边界)
308fn supported_exts() -> Vec<String> {
309    SUPPORTED_EXTENSIONS
310        .iter()
311        .map(|e| e.trim_start_matches('.').to_string())
312        .collect()
313}
314
315/// 路径是否应上报(不在噪音目录内且扩展名为支持语言)
316fn should_report(path: &Path, include_exts: &[String]) -> bool {
317    !should_ignore(path) && matches_include(path, include_exts)
318}
319
320/// 判断路径是否位于噪音目录(与 scanner::NOISE_DIRS 同清单:
321/// 第三方依赖与构建产物,事件不必上报)
322fn should_ignore(path: &Path) -> bool {
323    path.components().any(|c| {
324        if let Some(s) = c.as_os_str().to_str() {
325            NOISE_DIRS.contains(&s)
326        } else {
327            false
328        }
329    })
330}
331
332/// 检查路径的扩展名是否在支持语言列表内
333fn matches_include(path: &Path, include_exts: &[String]) -> bool {
334    if include_exts.is_empty() {
335        return true;
336    }
337    match path.extension() {
338        Some(ext) => include_exts.iter().any(|e| ext == e.as_str()),
339        None => false,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_should_ignore_target_dir() {
349        let p = Path::new("/repo/target/debug/main.rs");
350        assert!(should_ignore(p));
351    }
352
353    #[test]
354    fn test_should_ignore_git_dir() {
355        let p = Path::new("/repo/.git/HEAD");
356        assert!(should_ignore(p));
357    }
358
359    #[test]
360    fn test_should_ignore_node_modules() {
361        let p = Path::new("/repo/node_modules/foo/index.js");
362        assert!(should_ignore(p));
363    }
364
365    #[test]
366    fn test_should_ignore_dist_and_venv() {
367        assert!(should_ignore(Path::new("/repo/dist/bundle.js")));
368        assert!(should_ignore(Path::new("/repo/.venv/lib/py.py")));
369    }
370
371    #[test]
372    fn test_should_not_ignore_src_dir() {
373        let p = Path::new("/repo/src/main.rs");
374        assert!(!should_ignore(p));
375    }
376
377    #[test]
378    fn test_matches_include_with_matching_ext() {
379        let exts = vec!["rs".to_string(), "tsx".to_string()];
380        assert!(matches_include(Path::new("main.rs"), &exts));
381        assert!(matches_include(Path::new("comp.tsx"), &exts));
382    }
383
384    #[test]
385    fn test_matches_include_with_mismatch_ext() {
386        let exts = vec!["rs".to_string()];
387        assert!(!matches_include(Path::new("main.js"), &exts));
388        assert!(!matches_include(Path::new("no_ext"), &exts));
389    }
390
391    /// v30+:支持扩展名列表来自 parser 注册表同源常量
392    #[test]
393    fn test_supported_exts_cover_all_parsers() {
394        let exts = supported_exts();
395        for expected in ["rs", "ts", "tsx", "py", "go", "js", "jsx", "mjs", "cjs", "cs", "java"] {
396            assert!(exts.contains(&expected.to_string()), "缺少 {expected}");
397        }
398    }
399
400    /// 上报判定:噪音目录与扩展名不匹配的路径不上报
401    #[test]
402    fn test_should_report_filters_ignored_and_mismatched() {
403        let exts = vec!["rs".to_string()];
404        assert!(should_report(Path::new("/repo/src/main.rs"), &exts));
405        assert!(!should_report(Path::new("/repo/target/main.rs"), &exts));
406        assert!(!should_report(Path::new("/repo/src/main.js"), &exts));
407        assert!(!should_report(Path::new("/repo/src/no_ext"), &exts));
408    }
409
410    /// 事件类型显式化:Modify 与 Remove 事件聚合后 kind 正确保留,
411    /// 删除路径不被修改路径"吸收"(下游可直入清理,无需 exists() 推断)
412    #[test]
413    fn test_watch_event_kind_preserved() {
414        use notify::event::{DataChange, ModifyKind, RemoveKind};
415        use notify::{Event, EventKind};
416
417        let mk = || {
418            let mut e = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)));
419            e.paths = vec![PathBuf::from("src/a.rs")];
420            DebouncedEvent::new(e, std::time::Instant::now())
421        };
422        let mut removed = Event::new(EventKind::Remove(RemoveKind::File));
423        removed.paths = vec![PathBuf::from("src/b.rs")];
424        let events = vec![
425            mk(),
426            DebouncedEvent::new(removed, std::time::Instant::now()),
427        ];
428        let exts = vec!["rs".to_string()];
429        let aggregated = aggregate_events(&events, &exts);
430
431        assert_eq!(aggregated.len(), 2, "Modify 与 Remove 应各自聚合成独立事件");
432        let modified = aggregated
433            .iter()
434            .find(|e| e.kind == ChangeKind::Modified)
435            .expect("应存在 Modified 事件");
436        assert_eq!(modified.paths, vec![PathBuf::from("src/a.rs")]);
437        let deleted = aggregated
438            .iter()
439            .find(|e| e.kind == ChangeKind::Deleted)
440            .expect("应存在 Deleted 事件");
441        assert_eq!(deleted.paths, vec![PathBuf::from("src/b.rs")]);
442    }
443
444    /// 聚合去重:同一 kind 的重复路径只保留一份
445    #[test]
446    fn test_aggregate_events_dedups_same_kind_paths() {
447        use notify::event::{DataChange, ModifyKind};
448        use notify::{Event, EventKind};
449
450        let mk = || {
451            let mut e = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content)));
452            e.paths = vec![PathBuf::from("src/a.rs")];
453            DebouncedEvent::new(e, std::time::Instant::now())
454        };
455        let exts = vec!["rs".to_string()];
456        let aggregated = aggregate_events(&[mk(), mk()], &exts);
457        assert_eq!(aggregated.len(), 1);
458        assert_eq!(aggregated[0].paths, vec![PathBuf::from("src/a.rs")]);
459    }
460
461    // ---- 折叠(fold_events / process_batch)测试 ----
462    // 折叠语义:同路径跨 kind 事件按"窗口内最终态"合并——
463    // 删除优先于一切,Modified 优先于 Created;不同路径保持独立。
464
465    /// 测试事件构造器:任意 kind 的 notify 事件(路径/时刻可覆盖)
466    fn make_debounced(kind: notify::EventKind, path: &str) -> DebouncedEvent {
467        let mut e = notify::Event::new(kind);
468        e.paths = vec![PathBuf::from(path)];
469        DebouncedEvent::new(e, std::time::Instant::now())
470    }
471
472    /// Modified + Deleted 同路径 → 折叠为单个 Deleted(文件最终不存在)
473    #[test]
474    fn test_fold_modified_deleted() {
475        use notify::event::{DataChange, ModifyKind, RemoveKind};
476        let exts = vec!["rs".to_string()];
477        let events = vec![
478            make_debounced(notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), "src/a.rs"),
479            make_debounced(notify::EventKind::Remove(RemoveKind::File), "src/a.rs"),
480        ];
481        let folded = process_batch(&events, &exts);
482        assert_eq!(folded.len(), 1, "同路径 Modified+Deleted 应折叠为单事件");
483        assert_eq!(folded[0].kind, ChangeKind::Deleted);
484        assert_eq!(folded[0].paths, vec![PathBuf::from("src/a.rs")]);
485    }
486
487    /// Created + Deleted 同路径 → 折叠为单个 Deleted(创建即删,最终不存在)
488    #[test]
489    fn test_fold_created_deleted() {
490        use notify::event::{CreateKind, RemoveKind};
491        let exts = vec!["rs".to_string()];
492        let events = vec![
493            make_debounced(notify::EventKind::Create(CreateKind::File), "src/a.rs"),
494            make_debounced(notify::EventKind::Remove(RemoveKind::File), "src/a.rs"),
495        ];
496        let folded = process_batch(&events, &exts);
497        assert_eq!(folded.len(), 1);
498        assert_eq!(folded[0].kind, ChangeKind::Deleted);
499    }
500
501    /// Created + Modified 同路径 → 折叠为单个 Modified(文件最终存在且被修改)
502    #[test]
503    fn test_fold_created_modified() {
504        use notify::event::{CreateKind, DataChange, ModifyKind};
505        let exts = vec!["rs".to_string()];
506        let events = vec![
507            make_debounced(notify::EventKind::Create(CreateKind::File), "src/a.rs"),
508            make_debounced(notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), "src/a.rs"),
509        ];
510        let folded = process_batch(&events, &exts);
511        assert_eq!(folded.len(), 1);
512        assert_eq!(folded[0].kind, ChangeKind::Modified);
513        assert_eq!(folded[0].paths, vec![PathBuf::from("src/a.rs")]);
514    }
515
516    /// 聚合+折叠:同一路径的 Modify+Remove 产出单一 Deleted 事件
517    /// (aggregate 先分组,fold 再按最终态合并——下游只会跑一次删除清理)
518    #[test]
519    fn test_aggregate_events_same_path_cross_kind() {
520        use notify::event::{DataChange, ModifyKind, RemoveKind};
521        let exts = vec!["rs".to_string()];
522        let events = vec![
523            make_debounced(notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), "src/a.rs"),
524            make_debounced(notify::EventKind::Remove(RemoveKind::File), "src/a.rs"),
525        ];
526        let folded = process_batch(&events, &exts);
527        assert_eq!(folded.len(), 1);
528        assert_eq!(folded[0].kind, ChangeKind::Deleted);
529    }
530
531    /// 不同路径互不影响:a 的 Modify+Remove 折叠为 Deleted,
532    /// b 的 Modify 独立保留(折叠不吞并无关路径)
533    #[test]
534    fn test_aggregate_events_preserves_distinct_paths() {
535        use notify::event::{DataChange, ModifyKind, RemoveKind};
536        let exts = vec!["rs".to_string()];
537        let events = vec![
538            make_debounced(notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), "src/a.rs"),
539            make_debounced(notify::EventKind::Remove(RemoveKind::File), "src/a.rs"),
540            make_debounced(notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), "src/b.rs"),
541        ];
542        let folded = process_batch(&events, &exts);
543        assert_eq!(folded.len(), 2, "a 折叠为 Deleted、b 独立 Modified,共 2 事件");
544        let deleted = folded
545            .iter()
546            .find(|e| e.kind == ChangeKind::Deleted)
547            .expect("应存在 Deleted 事件");
548        assert_eq!(deleted.paths, vec![PathBuf::from("src/a.rs")]);
549        let modified = folded
550            .iter()
551            .find(|e| e.kind == ChangeKind::Modified)
552            .expect("应存在 Modified 事件");
553        assert_eq!(modified.paths, vec![PathBuf::from("src/b.rs")]);
554    }
555
556    /// v14 F 组(t06 拍板):停止标记预置 → 主循环立即退出
557    /// (优雅停止路径:标记在 on_change 完成后才检查,不打断进行中的
558    /// 增量生成;本测试验证预置标记的退出语义与不崩溃)
559    #[test]
560    fn test_watch_loop_exits_on_pre_set_stop_flag() {
561        let dir = std::env::temp_dir().join(format!("code_repo_wiki_watch_stop_{}", std::process::id()));
562        let _ = std::fs::remove_dir_all(&dir);
563        std::fs::create_dir_all(&dir).unwrap();
564        // 标记预置:循环第一次检查即退出(监听根不存在只告警不阻塞)
565        let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
566        let start = std::time::Instant::now();
567        let result = run_watch_loop(&dir, stop_flag, |_| panic!("不应触发回调"));
568        assert!(result.is_ok(), "优雅退出应返回 Ok: {result:?}");
569        assert!(
570            start.elapsed() < std::time::Duration::from_secs(5),
571            "预置停止标记应在监听启动后立即退出(无需等待事件)"
572        );
573        let _ = std::fs::remove_dir_all(&dir);
574    }
575
576    // ---- v31 C-07 冷却窗口测试 ----
577
578    /// 尾沿触发:安静期 ≥2s → 触发(连续编辑停止后的收敛路径)
579    #[test]
580    fn test_should_flush_quiet_elapsed_reaches_threshold() {
581        assert!(
582            should_flush(
583                Duration::from_millis(COOLDOWN_QUIET_MS),
584                Duration::from_millis(500)
585            ),
586            "安静 2s 应触发(尾沿)"
587        );
588        assert!(
589            should_flush(
590                Duration::from_millis(3000),
591                Duration::from_millis(3000)
592            ),
593            "安静 3s 应触发"
594        );
595    }
596
597    /// 强制触发:总时长 ≥5s → 触发(即使一直在编辑,最终一致性保证)
598    #[test]
599    fn test_should_flush_deadline_forced() {
600        assert!(
601            should_flush(
602                Duration::from_millis(300),
603                Duration::from_millis(COOLDOWN_DEADLINE_MS)
604            ),
605            "总时长 5s 应强制触发(编辑未停也触发)"
606        );
607    }
608
609    /// 冷却期内不触发:安静 <2s 且总时长 <5s
610    #[test]
611    fn test_should_flush_within_cooldown_does_not_trigger() {
612        assert!(
613            !should_flush(
614                Duration::from_millis(1500),
615                Duration::from_millis(1500)
616            ),
617            "编辑未停且未到 5s 上限不应触发"
618        );
619        assert!(
620            !should_flush(Duration::ZERO, Duration::ZERO),
621            "刚收到事件不应触发"
622        );
623    }
624
625    /// 累积表:同 kind 路径合并、不同 kind 独立保留
626    #[test]
627    fn test_apply_batch_dedups_and_combines() {
628        let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
629        let batch1 = vec![WatchEvent {
630            paths: vec![PathBuf::from("src/a.rs")],
631            kind: ChangeKind::Modified,
632        }];
633        let batch2 = vec![
634            WatchEvent {
635                paths: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
636                kind: ChangeKind::Modified,
637            },
638            WatchEvent {
639                paths: vec![PathBuf::from("src/c.rs")],
640                kind: ChangeKind::Deleted,
641            },
642        ];
643        apply_batch(&mut pending, &batch1);
644        apply_batch(&mut pending, &batch2);
645        let flushed = flush_events(&pending);
646        assert_eq!(flushed.len(), 2, "同 kind 合并为 1 组 + Deleted 1 组");
647        let modified = flushed
648            .iter()
649            .find(|e| e.kind == ChangeKind::Modified)
650            .expect("应存在 Modified 组");
651        assert_eq!(
652            modified.paths,
653            vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
654            "a 去重、b 追加"
655        );
656        assert!(flushed.iter().any(|e| e.kind == ChangeKind::Deleted));
657    }
658
659    /// 跨批时间序收敛:批 1 Modified a.rs、批 2 Deleted a.rs →
660    /// 后到达的 Deleted 覆盖 Modified(文件最终被删,下游只跑一次删除清理)
661    #[test]
662    fn test_apply_batch_later_kind_overwrites() {
663        let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
664        apply_batch(
665            &mut pending,
666            &[WatchEvent {
667                paths: vec![PathBuf::from("src/a.rs")],
668                kind: ChangeKind::Modified,
669            }],
670        );
671        apply_batch(
672            &mut pending,
673            &[WatchEvent {
674                paths: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
675                kind: ChangeKind::Deleted,
676            }],
677        );
678        let flushed = flush_events(&pending);
679        assert_eq!(flushed.len(), 1, "跨批 Modified+Deleted 收敛为单个事件");
680        assert_eq!(flushed[0].kind, ChangeKind::Deleted);
681        assert_eq!(
682            flushed[0].paths,
683            vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")]
684        );
685    }
686
687    /// 跨批删除重建:批 1 Deleted a.rs、批 2 Created a.rs →
688    /// 后到达的 Created 覆盖 Deleted(文件最终存在,不能误跑删除清理——
689    /// reviewer HIGH 缺陷的回归测试:删除优先只对批内最终态成立)
690    #[test]
691    fn test_apply_batch_delete_then_recreate_keeps_created() {
692        let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
693        apply_batch(
694            &mut pending,
695            &[WatchEvent {
696                paths: vec![PathBuf::from("src/a.rs")],
697                kind: ChangeKind::Deleted,
698            }],
699        );
700        apply_batch(
701            &mut pending,
702            &[WatchEvent {
703                paths: vec![PathBuf::from("src/a.rs")],
704                kind: ChangeKind::Created,
705            }],
706        );
707        let flushed = flush_events(&pending);
708        assert_eq!(flushed.len(), 1);
709        assert_eq!(
710            flushed[0].kind,
711            ChangeKind::Created,
712            "删除重建必须收敛为 Created(文件最终存在),否则下游误删产物页"
713        );
714        assert_eq!(flushed[0].paths, vec![PathBuf::from("src/a.rs")]);
715    }
716
717    /// 连续编辑不丢路径:三次批的路径全部累积(同 kind)
718    #[test]
719    fn test_apply_batch_accumulates_across_batches() {
720        let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
721        apply_batch(
722            &mut pending,
723            &[WatchEvent {
724                paths: vec![PathBuf::from("src/a.rs")],
725                kind: ChangeKind::Modified,
726            }],
727        );
728        apply_batch(
729            &mut pending,
730            &[WatchEvent {
731                paths: vec![PathBuf::from("src/b.rs")],
732                kind: ChangeKind::Modified,
733            }],
734        );
735        apply_batch(
736            &mut pending,
737            &[WatchEvent {
738                paths: vec![PathBuf::from("src/c.rs")],
739                kind: ChangeKind::Modified,
740            }],
741        );
742        let flushed = flush_events(&pending);
743        assert_eq!(flushed.len(), 1);
744        assert_eq!(
745            flushed[0].paths,
746            vec![
747                PathBuf::from("src/a.rs"),
748                PathBuf::from("src/b.rs"),
749                PathBuf::from("src/c.rs")
750            ],
751            "三批同 kind 路径应全部累积"
752        );
753    }
754}