use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::cursor::{UsnCursor, VolumeKey};
pub(crate) const SCHEMA_VERSION: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexMeta {
pub schema_version: u32,
pub roots: Vec<PathBuf>,
#[serde(default)]
pub usn_cursors: HashMap<VolumeKey, UsnCursor>,
}
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(())
}
pub(crate) fn load_usn_cursors(index_dir: &Path) -> HashMap<VolumeKey, UsnCursor> {
load_meta(index_dir)
.map(|meta| meta.usn_cursors)
.unwrap_or_default()
}
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}");
}
}
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)
}
pub fn registered_roots(index_dir: &Path) -> Result<Vec<PathBuf>> {
Ok(ensure_schema_version(index_dir)?.roots)
}
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()))
}
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(())
}
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)
}
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()]);
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())?;
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(())
}
#[test]
fn open_index_without_meta_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())?;
std::fs::remove_file(meta_path(index_dir.path()))?;
assert!(crate::Searcher::open(index_dir.path()).is_err());
Ok(())
}
#[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));
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();
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(),
"兄弟目录不该被误判为嵌套"
);
}
#[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());
}
#[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(())
}
}