dowse 0.10.0

Windows 本地文件内容全文搜索:PDF/Office、中文分词、图片 OCR、NTFS/USN 与只读 MCP
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! 索引目录旁的元数据文件(`<index_dir>-meta.json`):schema 版本号、已注册
//! 的索引根目录列表([`IndexMeta::roots`])、每个卷的 USN 游标基线。打开索引
//! 前用 [`ensure_schema_version`] 校验版本,不匹配就报错提示重建,不做静默
//! 迁移。[`registered_roots`] 是对外读根列表的入口。

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use crate::cursor::{UsnCursor, VolumeKey};

/// schema 版本号:字段定义每次不兼容变更就 +1。里程碑 3 给 schema 加了
/// mtime/size 两个字段,从里程碑 1 的隐式 v1 升到 v2。v0.5.0 给 mtime/size
/// 补上 FAST 属性(排序器需要)、新增 kind 字段(为里程碑 4 OCR 预留),
/// 再从 v2 升到 v3。v4 换了内容分词器:汉字仍交 jieba,非汉字改成按字母数字
/// 切词并统一小写——token 边界跟 v3 不一样,旧倒排索引搜不准,必须重建。
/// v5 为查询语法升级新增 path_text 字段(path 的 jieba 分词镜像),让查询串里的
/// `path:关键词` 能在查询层按路径子串命中——旧索引里没有这个字段,搜 `path:` 只会
/// 空转,所以也算不兼容变更,从 v4 升到 v5。(mtime:/size: 的范围过滤没有另立字段:
/// mtime/size 从 v3 起就是 FAST,tantivy 0.26 的 RangeQuery 直接走 fast field 扫描,
/// 无需额外索引属性。)
/// 打开索引时版本对不上就要求重建,不做静默迁移、不做自动升级——旧字段布局
/// 搜出来的结果不可靠,宁可让用户重建一次。
pub(crate) const SCHEMA_VERSION: u32 = 5;

/// 索引目录旁的元数据:schema 版本号 + 已注册的索引根目录列表 + 每个卷的
/// USN 游标(里程碑 6)。
///
/// `usn_cursors` 特意不参与 schema 版本号——它是"能不能走快速追平"的可选
/// 优化信息,不是索引字段布局,读不到/读到旧格式(没这个字段)都不该逼用户
/// 重建索引,`#[serde(default)]` 让旧 meta.json 静默补一个空表,退回到
/// mtime 全扫对账,行为上等价于这个卷从来没走过快速路径。
///
/// `roots` 的写入语义在多根索引(里程碑 7)之后从"单根整体替换"升级为
/// "逐根增删":全量重建(`finish_rebuild`)仍然整份覆盖,但 [`append_root`]/
/// [`remove_root`] 只增删列表里的一项,其余字段原样保留。字段本身的类型/
/// 序列化格式完全没变(一直就是 `Vec<PathBuf>`),旧版本写的单根 meta.json
/// 不需要任何迁移,读出来就是一个长度为 1 的列表,向后兼容是自动的。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexMeta {
    pub schema_version: u32,
    pub roots: Vec<PathBuf>,
    #[serde(default)]
    pub usn_cursors: HashMap<VolumeKey, UsnCursor>,
}

/// meta.json 放在索引目录的**兄弟**位置(`<index_dir>-meta.json`),不是塞进
/// 索引目录里:tantivy 自己在索引目录里也维护一个 `meta.json`,同名会撞车。
fn meta_path(index_dir: &Path) -> PathBuf {
    let stem = index_dir
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("dowse-index");
    index_dir.with_file_name(format!("{stem}-meta.json"))
}

pub(crate) fn load_meta(index_dir: &Path) -> Result<IndexMeta> {
    let path = meta_path(index_dir);
    let bytes = std::fs::read(&path).with_context(|| {
        format!(
            "读不到索引元数据 {}——索引可能是旧版本或已损坏,请重建索引",
            path.display()
        )
    })?;
    serde_json::from_slice(&bytes).context("索引元数据解析失败,请重建索引")
}

pub(crate) fn save_meta(index_dir: &Path, meta: &IndexMeta) -> Result<()> {
    let path = meta_path(index_dir);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let bytes = serde_json::to_vec_pretty(meta)?;
    std::fs::write(&path, bytes).context("写索引元数据失败")?;
    Ok(())
}

/// 读出已注册的 USN 游标(里程碑 6,按卷)。纯粹是"能不能走快速追平"的
/// 优化信号,读不到就当没有——不返回 Err、不影响调用方的主流程(对照
/// `usn_cursors` 字段本身"可选优化信息"的定位)。
pub(crate) fn load_usn_cursors(index_dir: &Path) -> HashMap<VolumeKey, UsnCursor> {
    load_meta(index_dir)
        .map(|meta| meta.usn_cursors)
        .unwrap_or_default()
}

/// 更新单个卷的 USN 游标,其余字段原样保留。读不到现有 meta(索引还没建过、
/// 或者刚好在被并发重建)就放弃,只打日志——游标持久化失败最多导致下次
/// 启动退回 mtime 全扫对账,不是数据安全问题,不值得把调用方搞挂。
pub(crate) fn save_usn_cursor(index_dir: &Path, volume: &VolumeKey, cursor: UsnCursor) {
    let mut meta = match load_meta(index_dir) {
        Ok(meta) => meta,
        Err(err) => {
            eprintln!("持久化 USN 游标失败(读不到索引元数据,{volume}): {err}");
            return;
        }
    };
    meta.usn_cursors.insert(volume.clone(), cursor);
    if let Err(err) = save_meta(index_dir, &meta) {
        eprintln!("持久化 USN 游标失败({volume}): {err}");
    }
}

/// 打开索引前校验 schema 版本。不匹配就报明确错误、提示重建,不静默兼容。
/// 校验通过时把 meta 返回给调用方复用(省一次读盘)。
pub(crate) fn ensure_schema_version(index_dir: &Path) -> Result<IndexMeta> {
    let meta = load_meta(index_dir)?;
    if meta.schema_version != SCHEMA_VERSION {
        bail!(
            "索引 schema 版本是 {},当前程序需要 {}——字段定义已升级,请重建索引\
             (托盘菜单或 CLI 的 `dowse index`)。",
            meta.schema_version,
            SCHEMA_VERSION
        );
    }
    Ok(meta)
}

/// 已注册的索引根目录列表,供启动对账和文件监听使用。
/// 顺带校验 schema 版本——版本不对时直接报错,不返回一份不可信的根列表。
pub fn registered_roots(index_dir: &Path) -> Result<Vec<PathBuf>> {
    Ok(ensure_schema_version(index_dir)?.roots)
}

/// 对一条路径做尽力而为的归一,把它拉到一种稳定拼法,供嵌套前缀比较使用:
/// 1. 先直接 `canonicalize()`——路径在磁盘上存在时这一步就把短文件名(8.3)
///    展开成长名、并加上扩展长度前缀。
/// 2. 路径还不存在(如指向一个尚未创建的子目录)时 `canonicalize()` 会失败,
///    退而求其次:沿祖先向上找到最深的那个真实存在的目录 canonicalize,再把
///    剩下不存在的那截路径拼回去。
/// 3. 连祖先都解析不了就保留原样,不报错。
/// 4. 最后统一剥掉 `\\?\`/`\\?\UNC\` 前缀(见 [`crate::display_path`]),跟
///    其余比较口径对齐。
///
/// 存在的意义:Windows 短文件名在部分账户/机器上会让同一目录的原始路径和
/// canonicalize 结果分属两种拼法(如 `RUNNERA~1` 对 `runneradmin`)。已有根
/// 往往落在磁盘上(能直接展开成长名),而候选可能指向一个还不存在的子目录
/// (`canonicalize` 直接失败、只能保留短名),两侧拼法一错位,纯前缀比较就
/// 漏判真实存在的嵌套。给两侧都跑同一套归一,才能把它们拉到同一种拼法再比。
fn best_effort_normalize(path: &Path) -> PathBuf {
    let resolved = path.canonicalize().unwrap_or_else(|_| {
        for ancestor in path.ancestors() {
            if let (Ok(base), Ok(rest)) = (ancestor.canonicalize(), path.strip_prefix(ancestor)) {
                return base.join(rest);
            }
        }
        path.to_path_buf()
    });
    PathBuf::from(crate::display_path(&resolved.to_string_lossy()))
}

/// 校验候选根跟已有根之间不存在嵌套关系(含双向:候选是某个已有根的子目录,
/// 或者候选是某个已有根的父目录),也拒绝跟已有根完全相同的重复添加。
/// 嵌套会让 `delete_tree` 的前缀圈选删除和对账的孤儿清理语义变成泥潭
/// (删 B 会连带删掉嵌在它里面的 A、反之亦然),产品上也没有正当需求
/// (设计文档"核心操作语义"一节)。
///
/// 不同调用路径传入的根写法本身不统一:全量重建(`finish_rebuild`)存的是
/// 未经 canonicalize 的原始路径,`add_root`/`append_root` 存的是 canonicalize
/// 过的路径;候选还可能指向一个磁盘上尚不存在的子目录。直接按原始字符串比较,
/// 会因为扩展长度前缀(`\\?\`/`\\?\UNC\`)带不带、Windows 短文件名(8.3)
/// 展没展开(如 `RUNNERA~1` 对 `runneradmin`)这类拼法差异,漏判真实存在的
/// 嵌套。这里对 `candidate` 和每一个 `existing` 都跑同一套 [`best_effort_normalize`],
/// 把两侧拉到同一种拼法再比较;报错文案仍然用 `root`/`candidate` 的原始值,
/// 不把内部的归一形态暴露给用户。
pub(crate) fn assert_no_root_nesting(existing: &[PathBuf], candidate: &Path) -> Result<()> {
    let candidate_norm = best_effort_normalize(candidate);
    for root in existing {
        let root_norm = best_effort_normalize(root);
        if root_norm == candidate_norm {
            bail!("目录 {} 已经是索引根,不用重复添加", candidate.display());
        }
        if candidate_norm.starts_with(&root_norm) {
            bail!(
                "目录 {} 是已有根 {} 的子目录,不允许嵌套添加",
                candidate.display(),
                root.display()
            );
        }
        if root_norm.starts_with(&candidate_norm) {
            bail!(
                "目录 {} 是已有根 {} 的父目录,不允许嵌套添加",
                candidate.display(),
                root.display()
            );
        }
    }
    Ok(())
}

/// 把一个新根追加进 meta.json 的 roots 列表,其余字段(schema_version/
/// usn_cursors)原样保留。调用方(`roots::add_root_with_progress`)必须先
/// 完成目录树 upsert、再调这个函数——顺序不能反:设计文档"边界与失败"一节
/// 要求半路崩溃不留幽灵根,只有 upsert 完成后才写 meta 才能保证这一点
/// (半程崩溃时根本还没走到这一步,索引里那些孤儿文档由对账新增的孤儿
/// 清理规则兜底清掉)。
pub(crate) fn append_root(index_dir: &Path, root: &Path) -> Result<()> {
    let mut meta = load_meta(index_dir)?;
    assert_no_root_nesting(&meta.roots, root)?;
    meta.roots.push(root.to_path_buf());
    save_meta(index_dir, &meta)
}

/// 候选根是否已经**精确**注册过(按 [`best_effort_normalize`] 归一后跟某个已有根
/// 相等)。增量补扫单个根(`roots::index_root_incremental`)的幂等语义要用它把
/// "精确重复"从"父子嵌套"里单独拎出来:重复补扫一个已注册的根要放行(先删后加
/// 天然幂等),但仍要挡住跟别的根形成的嵌套。用归一比较而不是裸字符串相等——
/// 全量重建存的是未 canonicalize 的原始路径、`add_root`/本入口存的是 canonicalize
/// 过的路径,两种拼法要先拉到一起才比得对(同 [`assert_no_root_nesting`])。
pub(crate) fn is_root_registered(existing: &[PathBuf], candidate: &Path) -> bool {
    let candidate_norm = best_effort_normalize(candidate);
    existing
        .iter()
        .any(|r| best_effort_normalize(r) == candidate_norm)
}

/// 同 [`assert_no_root_nesting`],但**允许**候选跟某个已有根完全相同——增量补扫
/// 单个根的幂等入口要求"重复补扫一个已注册的根"放行,只挡住真正的父子嵌套
/// (那会让 `delete_tree` 的前缀圈选删除和对账的孤儿清理语义变成泥潭,见
/// [`assert_no_root_nesting`] 的文档)。归一口径、报错文案都跟它一致。
pub(crate) fn assert_no_root_nesting_allow_exact(
    existing: &[PathBuf],
    candidate: &Path,
) -> Result<()> {
    let candidate_norm = best_effort_normalize(candidate);
    for root in existing {
        let root_norm = best_effort_normalize(root);
        if root_norm == candidate_norm {
            // 精确重复:放行,交给上层做幂等重扫。
            continue;
        }
        if candidate_norm.starts_with(&root_norm) {
            bail!(
                "目录 {} 是已有根 {} 的子目录,不允许嵌套添加",
                candidate.display(),
                root.display()
            );
        }
        if root_norm.starts_with(&candidate_norm) {
            bail!(
                "目录 {} 是已有根 {} 的父目录,不允许嵌套添加",
                candidate.display(),
                root.display()
            );
        }
    }
    Ok(())
}

/// 增量补扫单个新根(`roots::index_root_incremental`)收尾时的一次性 meta 落盘:
/// 确保新根在 roots 里(已经注册过就不重复追加——幂等重扫会走到这里),并为它
/// 所在的卷补上 USN 游标基线。一次 load-modify-save,省得追加根和写游标各存一次盘。
///
/// 游标只补 meta 里**还没有的卷**(`or_insert`):已有卷的游标是更早的、覆盖这个卷
/// 上其它根的基线,不能被这次更靠后的游标顶掉——否则下次启动快车道从新游标回放,
/// 会漏掉旧根在两次游标之间发生的变更(快车道靠游标回放追平,不再走 mtime 全扫,
/// 见 `usn::bootstrap_fast_roots`)。保留更早的游标只会多回放几条、幂等无害。
/// 新根落在一个全新的卷(meta 里还没有它的游标)时,这次补扫刚拍的基线正好补上。
pub(crate) fn register_root_with_cursors(
    index_dir: &Path,
    root: &Path,
    usn_cursors: HashMap<VolumeKey, UsnCursor>,
) -> Result<()> {
    let mut meta = load_meta(index_dir)?;
    if !is_root_registered(&meta.roots, root) {
        meta.roots.push(root.to_path_buf());
    }
    for (vol, cursor) in usn_cursors {
        meta.usn_cursors.entry(vol).or_insert(cursor);
    }
    save_meta(index_dir, &meta)
}

/// 把一个根从 meta.json 的 roots 列表里移除,其余字段原样保留。不存在的根
/// 直接报错(调用方应该先用 `registered_roots` 确认这个根确实注册过,避免
/// 手误传错路径时静默什么都不做)。
///
/// 调用方(`roots::remove_root`)必须先调这个函数、再删文档——顺序同样不能
/// 反:设计文档要求半路崩溃时残留文档由"不属于任何注册根就删"的对账规则
/// 兜底,这要求 meta 先于文档删除完成落盘。
pub(crate) fn remove_root_from_meta(index_dir: &Path, root: &Path) -> Result<()> {
    let mut meta = load_meta(index_dir)?;
    let before = meta.roots.len();
    meta.roots.retain(|r| r != root);
    if meta.roots.len() == before {
        bail!("目录 {} 不是已注册的索引根", root.display());
    }
    save_meta(index_dir, &meta)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rebuild_writes_meta_with_current_version_and_root() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;

        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        let meta = load_meta(index_dir.path())?;
        assert_eq!(meta.schema_version, SCHEMA_VERSION);
        assert_eq!(meta.roots, vec![target_dir.path().to_path_buf()]);

        // registered_roots 是对外读根列表的入口,应给出同样的结果
        assert_eq!(registered_roots(index_dir.path())?, meta.roots);
        Ok(())
    }

    #[test]
    fn open_index_with_mismatched_schema_version_errors() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        // 手动把 meta.json 改成一个未来的版本号,模拟字段定义升级过、索引没重建
        save_meta(
            index_dir.path(),
            &IndexMeta {
                schema_version: SCHEMA_VERSION + 1,
                roots: vec![target_dir.path().to_path_buf()],
                usn_cursors: HashMap::new(),
            },
        )?;

        let err = match crate::Searcher::open(index_dir.path()) {
            Ok(_) => panic!("版本不匹配时打开索引应当报错,而不是静默兼容"),
            Err(e) => e,
        };
        assert!(
            err.to_string().contains("重建"),
            "错误信息应提示用户重建索引,实际: {err}"
        );
        Ok(())
    }

    /// 查询语法升级把 schema 升到 v5(新增 path_text 字段)。用上一版 v4 建的旧索引
    /// 打开时必须报错、并明确引导重建,不能拿缺字段的旧布局硬搜出不可靠结果。
    #[test]
    fn opening_previous_schema_version_index_errors_with_rebuild_hint() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        // 把 meta 改回上一版版本号,模拟"程序升级了、索引还是旧的"。
        save_meta(
            index_dir.path(),
            &IndexMeta {
                schema_version: SCHEMA_VERSION - 1,
                roots: vec![target_dir.path().to_path_buf()],
                usn_cursors: HashMap::new(),
            },
        )?;

        // Searcher 不实现 Debug,用 match 取错误(不能用 expect_err)。
        let err = match crate::Searcher::open(index_dir.path()) {
            Ok(_) => panic!("旧 schema 版本打开索引应报错"),
            Err(e) => e,
        };
        assert!(
            err.to_string().contains("重建"),
            "错误信息应引导用户重建索引,实际: {err}"
        );
        Ok(())
    }

    #[test]
    fn open_index_without_meta_errors() -> Result<()> {
        // 里程碑 1 建的旧索引没有 meta.json:打开时应报错提示重建,而不是当好索引用。
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        // 删掉 meta.json 模拟旧版本索引
        std::fs::remove_file(meta_path(index_dir.path()))?;

        assert!(crate::Searcher::open(index_dir.path()).is_err());
        Ok(())
    }

    /// 旧版 meta.json(里程碑 6 之前写的,没有 usn_cursors 字段)应该照常解析
    /// 成功,`usn_cursors` 静默补成空表——不能因为字段升级就让老索引读不动。
    #[test]
    fn meta_without_usn_cursors_field_deserializes_with_empty_default() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let path = meta_path(index_dir.path());
        std::fs::create_dir_all(path.parent().unwrap())?;
        std::fs::write(&path, r#"{"schema_version":3,"roots":["C:\\watch"]}"#)?;

        let meta = load_meta(index_dir.path())?;
        assert!(meta.usn_cursors.is_empty());
        Ok(())
    }

    #[test]
    fn save_and_load_usn_cursor_round_trips() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        let cursor = UsnCursor {
            journal_id: 12345,
            next_usn: 6789,
        };
        save_usn_cursor(index_dir.path(), &"C:".to_string(), cursor);

        let cursors = load_usn_cursors(index_dir.path());
        assert_eq!(cursors.get("C:"), Some(&cursor));

        // roots/schema_version 不应该被这次更新动到
        let meta = load_meta(index_dir.path())?;
        assert_eq!(meta.schema_version, SCHEMA_VERSION);
        assert_eq!(meta.roots, vec![target_dir.path().to_path_buf()]);
        Ok(())
    }

    #[test]
    fn load_usn_cursors_on_missing_index_returns_empty_not_error() {
        let index_dir = tempfile::tempdir().unwrap();
        // 从没建过索引:不应该 panic,也不应该把错误传染给调用方。
        assert!(load_usn_cursors(index_dir.path()).is_empty());
    }

    #[test]
    fn nesting_rejects_child_and_parent_both_directions() {
        let existing = vec![PathBuf::from(r"C:\docs\project")];

        let child = PathBuf::from(r"C:\docs\project\sub");
        assert!(
            assert_no_root_nesting(&existing, &child).is_err(),
            "候选是已有根的子目录应该被拒绝"
        );

        let parent = PathBuf::from(r"C:\docs");
        assert!(
            assert_no_root_nesting(&existing, &parent).is_err(),
            "候选是已有根的父目录应该被拒绝"
        );

        let duplicate = PathBuf::from(r"C:\docs\project");
        assert!(
            assert_no_root_nesting(&existing, &duplicate).is_err(),
            "跟已有根完全相同应该被拒绝"
        );

        let sibling = PathBuf::from(r"C:\docs\other");
        assert!(
            assert_no_root_nesting(&existing, &sibling).is_ok(),
            "兄弟目录不该被误判为嵌套"
        );
    }

    /// 一边带 `\\?\` 扩展长度前缀、一边不带的同一个目录也应该判定为嵌套——
    /// `canonicalize()` 会加这个前缀,托盘"更改索引文件夹"目前不会,两条
    /// 路径产出的根写法不一致,嵌套校验不能因为这个漏判。
    #[test]
    fn nesting_check_normalizes_extended_length_prefix() {
        let existing = vec![PathBuf::from(r"\\?\C:\docs\project")];
        let child = PathBuf::from(r"C:\docs\project\sub");
        assert!(assert_no_root_nesting(&existing, &child).is_err());
    }

    /// `assert_no_root_nesting_allow_exact` 放行精确重复(幂等重扫要用),但仍挡住
    /// 真正的父子嵌套——跟 `assert_no_root_nesting` 只差"重复该不该报错"这一点。
    #[test]
    fn allow_exact_nesting_permits_duplicate_but_blocks_parent_child() {
        let existing = vec![PathBuf::from(r"C:\docs\project")];

        let duplicate = PathBuf::from(r"C:\docs\project");
        assert!(
            assert_no_root_nesting_allow_exact(&existing, &duplicate).is_ok(),
            "跟已有根完全相同应该放行(幂等重扫)"
        );
        assert!(is_root_registered(&existing, &duplicate));

        let child = PathBuf::from(r"C:\docs\project\sub");
        assert!(
            assert_no_root_nesting_allow_exact(&existing, &child).is_err(),
            "子目录仍应被拒绝"
        );
        let parent = PathBuf::from(r"C:\docs");
        assert!(
            assert_no_root_nesting_allow_exact(&existing, &parent).is_err(),
            "父目录仍应被拒绝"
        );

        let sibling = PathBuf::from(r"C:\docs\other");
        assert!(assert_no_root_nesting_allow_exact(&existing, &sibling).is_ok());
        assert!(!is_root_registered(&existing, &sibling));
    }

    #[test]
    fn append_and_remove_root_round_trip() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        let second = tempfile::Builder::new().prefix("dowse-test2-").tempdir()?;
        append_root(index_dir.path(), second.path())?;

        let meta = load_meta(index_dir.path())?;
        assert_eq!(
            meta.roots,
            vec![target_dir.path().to_path_buf(), second.path().to_path_buf()],
            "追加根不应该动到已有的根"
        );

        remove_root_from_meta(index_dir.path(), target_dir.path())?;
        let meta = load_meta(index_dir.path())?;
        assert_eq!(
            meta.roots,
            vec![second.path().to_path_buf()],
            "移除根不应该动到剩下的根"
        );
        Ok(())
    }

    #[test]
    fn append_root_rejects_nested_candidate() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        let nested = target_dir.path().join("sub");
        let err = append_root(index_dir.path(), &nested)
            .expect_err("子目录应该被拒绝,且不应该写入 meta");

        assert!(err.to_string().contains("嵌套"));
        let meta = load_meta(index_dir.path())?;
        assert_eq!(
            meta.roots,
            vec![target_dir.path().to_path_buf()],
            "校验失败时不应该污染已有的 roots"
        );
        Ok(())
    }

    #[test]
    fn remove_root_from_meta_errors_on_unregistered_root() -> Result<()> {
        let index_dir = tempfile::tempdir()?;
        let target_dir = tempfile::Builder::new().prefix("dowse-test-").tempdir()?;
        std::fs::write(target_dir.path().join("note.md"), "内容")?;
        crate::rebuild_index(index_dir.path(), target_dir.path())?;

        let unrelated = tempfile::tempdir()?;
        assert!(remove_root_from_meta(index_dir.path(), unrelated.path()).is_err());
        Ok(())
    }
}