use crate::error::WebError; use crate::routes::local_folder_source::encode_local_id_segment; use crate::routes::local_markdown_parser::{ parse_markdown_attachment_link, parse_markdown_page, split_frontmatter, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; const LOCAL_SEARCH_INDEX_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalSearchIndex { version: u32, built_at: u128, root_uri: String, workspace_id: String, documents: Vec, #[serde(default)] resources: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalSearchDocument { document_id: String, title: String, path: String, raw_text: String, tags: Vec, backlinks: Vec, resource_refs: Vec, updated_at: u128, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalSearchResource { resource_id: String, resource_type: String, title: String, path: String, updated_at: u128, } pub(crate) fn query_local_search_index( root_path: &Path, root_uri: &str, workspace_id: &str, query: &str, page_id: Option<&str>, limit: u32, title_only: bool, exact: bool, ) -> Result { let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; let normalized_query = normalize_search_text(query); let page_id = page_id.map(str::trim).filter(|value| !value.is_empty()); let recent_changes = local_recent_changes_projection(&index.documents, root_uri); let mut results = Vec::new(); for document in index.documents.iter() { if let Some(page_id) = page_id { if document.document_id != page_id { continue; } } if !local_search_document_matches(document, &normalized_query, title_only, exact) { continue; } results.push(local_search_document_projection( document, root_uri, &normalized_query, )); if results.len() >= limit.max(1) as usize { break; } } if page_id.is_none() && results.len() < limit.max(1) as usize { for resource in index.resources.iter() { if !local_search_resource_matches(resource, &normalized_query, title_only, exact) { continue; } results.push(local_search_resource_projection(resource, root_uri)); if results.len() >= limit.max(1) as usize { break; } } } Ok(json!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", "sourceKind": "local_folder", "index": { "version": index.version, "rootUri": index.root_uri, "workspaceId": index.workspace_id, "builtAt": index.built_at, "documentCount": index.documents.len(), "resourceCount": index.resources.len() }, "recentChanges": recent_changes, "results": results })) } pub(crate) fn refresh_local_search_index( root_path: &Path, root_uri: &str, workspace_id: &str, ) -> Result { let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; Ok(json!({ "version": index.version, "rootUri": index.root_uri, "workspaceId": index.workspace_id, "builtAt": index.built_at, "documentCount": index.documents.len(), "resourceCount": index.resources.len() })) } pub(crate) fn refresh_local_search_index_for_path( root_path: &Path, root_uri: &str, workspace_id: &str, relative_path: &str, ) -> Result { let relative_path = normalize_index_relative_path(relative_path)?; let mut index = match read_local_search_index(root_path) { Ok(Some(index)) if index.version == LOCAL_SEARCH_INDEX_VERSION && index.root_uri == root_uri && index.workspace_id == workspace_id => { index } Ok(_) | Err(_) => { return refresh_local_search_index(root_path, root_uri, workspace_id); } }; index .documents .retain(|document| document.path != relative_path); index .resources .retain(|resource| resource.path != relative_path); let absolute_path = root_path.join(&relative_path); if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) { index .documents .push(index_markdown_file(root_path, &absolute_path)?); } else if absolute_path.exists() && absolute_path.is_file() && resource_type_from_path(&absolute_path).is_some() { index .resources .push(index_resource_file(root_path, &absolute_path)?); } index .documents .sort_by(|left, right| left.path.cmp(&right.path)); index .resources .sort_by(|left, right| left.path.cmp(&right.path)); index.built_at = now_ms(); write_local_search_index(root_path, &index)?; Ok(json!({ "version": index.version, "rootUri": index.root_uri, "workspaceId": index.workspace_id, "builtAt": index.built_at, "documentCount": index.documents.len(), "resourceCount": index.resources.len() })) } pub(crate) fn query_local_backlinks( root_path: &Path, root_uri: &str, workspace_id: &str, document_id: &str, ) -> Result { let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; let Some(target) = index .documents .iter() .find(|document| document.document_id == document_id) else { return Ok(json!({ "documentId": document_id, "rootUri": root_uri, "workspaceId": workspace_id, "backlinks": [] })); }; let target_keys = normalized_document_targets(target); let backlinks = index .documents .iter() .filter(|document| document.document_id != target.document_id) .filter_map(|document| { let matched_targets = document .backlinks .iter() .filter(|link| target_keys.contains(&normalize_search_text(link))) .cloned() .collect::>(); if matched_targets.is_empty() { return None; } Some(json!({ "documentId": document.document_id, "title": document.title, "path": document.path, "resourceType": "markdown", "matchedTargets": matched_targets, "snippet": search_snippet(document, &normalize_search_text(&target.title)) })) }) .collect::>(); Ok(json!({ "documentId": target.document_id, "title": target.title, "path": target.path, "rootUri": root_uri, "workspaceId": workspace_id, "backlinks": backlinks })) } pub(crate) fn query_local_tags( root_path: &Path, root_uri: &str, workspace_id: &str, ) -> Result { let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; let mut tags = std::collections::BTreeMap::>::new(); for document in &index.documents { for tag in &document.tags { tags.entry(tag.clone()).or_default().push(document); } } Ok(json!({ "rootUri": root_uri, "workspaceId": workspace_id, "tags": tags .into_iter() .map(|(tag, documents)| { json!({ "tag": tag, "count": documents.len(), "documents": documents .into_iter() .map(|document| { json!({ "documentId": document.document_id, "title": document.title, "path": document.path, "resourceType": "markdown" }) }) .collect::>() }) }) .collect::>() })) } fn rebuild_local_search_index( root_path: &Path, root_uri: &str, workspace_id: &str, ) -> Result { let mut documents = Vec::new(); let mut resources = Vec::new(); collect_markdown_documents(root_path, root_path, &mut documents, &mut resources)?; documents.sort_by(|left, right| left.path.cmp(&right.path)); resources.sort_by(|left, right| left.path.cmp(&right.path)); let index = LocalSearchIndex { version: LOCAL_SEARCH_INDEX_VERSION, built_at: now_ms(), root_uri: root_uri.to_string(), workspace_id: workspace_id.to_string(), documents, resources, }; write_local_search_index(root_path, &index)?; Ok(index) } fn read_local_search_index(root_path: &Path) -> Result, WebError> { let index_path = root_path .join(".mnote") .join("index") .join("search-index.json"); if !index_path.exists() { return Ok(None); } let content = fs::read_to_string(&index_path).map_err(|error| { WebError::bad_request_code( "local_search_index_read_failed", format!("无法读取本地搜索索引 {}: {error}", index_path.display()), ) })?; serde_json::from_str::(&content) .map(Some) .map_err(|error| { WebError::bad_request_code( "local_search_index_invalid", format!("本地搜索索引格式非法 {}: {error}", index_path.display()), ) }) } fn normalize_index_relative_path(relative_path: &str) -> Result { let normalized = relative_path.trim().replace('\\', "/"); if normalized.is_empty() { return Err(WebError::bad_request_code( "local_search_index_path_required", "本地搜索索引更新缺少相对路径", )); } let path = PathBuf::from(&normalized); if path.is_absolute() || path.components().any(|component| { matches!( component, Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }) { return Err(WebError::bad_request_code( "local_search_index_path_escape", "本地搜索索引相对路径不能越过 root", )); } Ok(normalized) } fn collect_markdown_documents( root_path: &Path, current: &Path, documents: &mut Vec, resources: &mut Vec, ) -> Result<(), WebError> { let entries = match fs::read_dir(current) { Ok(entries) => entries, Err(error) => { return Err(WebError::bad_request_code( "local_search_index_read_dir_failed", format!("无法读取本地搜索索引目录 {}: {error}", current.display()), )); } }; for entry in entries { let entry = entry.map_err(|error| { WebError::bad_request_code( "local_search_index_read_entry_failed", format!("无法读取本地搜索索引条目: {error}"), ) })?; let path = entry.path(); let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or_default(); if file_name == ".mnote" { continue; } let file_type = entry.file_type().map_err(|error| { WebError::bad_request_code( "local_search_index_stat_failed", format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()), ) })?; if file_type.is_dir() { collect_markdown_documents(root_path, &path, documents, resources)?; continue; } if !file_type.is_file() { continue; } if is_markdown_path(&path) { documents.push(index_markdown_file(root_path, &path)?); } else if resource_type_from_path(&path).is_some() { resources.push(index_resource_file(root_path, &path)?); } } Ok(()) } fn index_markdown_file(root_path: &Path, path: &Path) -> Result { let markdown = fs::read_to_string(path).map_err(|error| { WebError::bad_request_code( "local_search_index_read_markdown_failed", format!("无法读取本地 Markdown 索引文件 {}: {error}", path.display()), ) })?; let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); let parsed = parse_markdown_page(&markdown, file_name); let relative_path = path .strip_prefix(root_path) .unwrap_or(path) .to_string_lossy() .replace('\\', "/"); let document_id = format!("local-md:{}", encode_local_id_segment(&relative_path)); let metadata = fs::metadata(path).map_err(|error| { WebError::bad_request_code( "local_search_index_stat_failed", format!( "无法读取本地 Markdown 索引文件状态 {}: {error}", path.display() ), ) })?; Ok(LocalSearchDocument { document_id, title: parsed.title, path: relative_path, raw_text: parsed.body.clone(), tags: extract_tags(&markdown), backlinks: extract_backlinks(&parsed.body), resource_refs: extract_resource_refs(&parsed.body), updated_at: metadata .modified() .ok() .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|value| value.as_millis()) .unwrap_or_default(), }) } fn index_resource_file(root_path: &Path, path: &Path) -> Result { let resource_type = resource_type_from_path(path).ok_or_else(|| { WebError::bad_request_code( "local_search_index_resource_type_unsupported", format!("不支持索引该资源文件 {}", path.display()), ) })?; let relative_path = path .strip_prefix(root_path) .unwrap_or(path) .to_string_lossy() .replace('\\', "/"); let title = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or("resource") .trim() .trim_end_matches(".mindmap") .to_string(); let metadata = fs::metadata(path).map_err(|error| { WebError::bad_request_code( "local_search_index_stat_failed", format!("无法读取本地资源索引文件状态 {}: {error}", path.display()), ) })?; Ok(LocalSearchResource { resource_id: format!("local-resource:{}", relative_path.replace('/', "~2F")), resource_type: resource_type.to_string(), title, path: relative_path, updated_at: metadata .modified() .ok() .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|value| value.as_millis()) .unwrap_or_default(), }) } fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> { let index_dir = root_path.join(".mnote").join("index"); fs::create_dir_all(&index_dir).map_err(|error| { WebError::bad_request_code( "local_search_index_create_failed", format!("无法创建本地搜索索引目录 {}: {error}", index_dir.display()), ) })?; let index_path = index_dir.join("search-index.json"); let payload = serde_json::to_string_pretty(index) .map_err(|error| WebError::internal(format!("本地搜索索引序列化失败: {error}")))?; fs::write(&index_path, format!("{payload}\n")).map_err(|error| { WebError::bad_request_code( "local_search_index_write_failed", format!("无法写入本地搜索索引 {}: {error}", index_path.display()), ) }) } fn local_search_document_matches( document: &LocalSearchDocument, query: &str, title_only: bool, exact: bool, ) -> bool { if query.is_empty() { return false; } let haystack = if title_only { normalize_search_text(&document.title) } else { normalize_search_text(&format!( "{}\n{}\n{}\n{}", document.title, document.raw_text, document.tags.join(" "), document.resource_refs.join(" ") )) }; if exact { haystack == query } else { haystack.contains(query) } } fn local_search_resource_matches( resource: &LocalSearchResource, query: &str, title_only: bool, exact: bool, ) -> bool { if query.is_empty() { return false; } let haystack = if title_only { normalize_search_text(&resource.title) } else { normalize_search_text(&format!( "{}\n{}\n{}", resource.title, resource.path, resource.resource_type )) }; if exact { haystack == query } else { haystack.contains(query) } } fn local_search_document_projection( document: &LocalSearchDocument, root_uri: &str, query: &str, ) -> Value { json!({ "id": document.document_id, "documentId": document.document_id, "title": document.title, "path": document.path, "resourceType": "markdown", "sourceKind": "local_folder", "rootUri": root_uri, "snippet": search_snippet(document, query), "tags": document.tags, "backlinks": document.backlinks, "resourceRefs": document.resource_refs, "updatedAt": document.updated_at, "publicPath": format!( "/documents/{}?sourceKind=local_folder&rootUri={}", document.document_id, encode_query_component(root_uri), ) }) } fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &str) -> Value { json!({ "id": resource.resource_id, "documentId": resource.resource_id, "title": resource.title, "path": resource.path, "resourceType": resource.resource_type, "sourceKind": "local_folder", "rootUri": root_uri, "updatedAt": resource.updated_at, "publicPath": format!( "/?treeView=filetree&sourceKind=local_folder&rootUri={}", encode_query_component(root_uri), ) }) } fn encode_query_component(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(*byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded } fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value { let mut sorted = documents.iter().collect::>(); sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); Value::Array( sorted .into_iter() .take(20) .map(|document| { json!({ "id": document.document_id, "documentId": document.document_id, "title": document.title, "path": document.path, "resourceType": "markdown", "sourceKind": "local_folder", "rootUri": root_uri, "updatedAt": document.updated_at }) }) .collect(), ) } fn normalized_document_targets( document: &LocalSearchDocument, ) -> std::collections::BTreeSet { let mut values = std::collections::BTreeSet::new(); values.insert(normalize_search_text(&document.document_id)); values.insert(normalize_search_text(&document.path)); values.insert(normalize_search_text(&document.title)); if let Some(encoded) = document.document_id.strip_prefix("local-md:") { values.insert(normalize_search_text(&encoded.replace("~2F", "/"))); } values } fn extract_tags(markdown: &str) -> Vec { let Some(frontmatter) = split_frontmatter(markdown).0 else { return Vec::new(); }; for line in frontmatter.lines() { let trimmed = line.trim(); let Some(value) = trimmed .strip_prefix("tags:") .or_else(|| trimmed.strip_prefix("tag:")) else { continue; }; return value .trim() .trim_matches(['[', ']']) .split([',', ' ']) .map(|item| item.trim().trim_matches(['"', '\''])) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .collect(); } Vec::new() } fn extract_backlinks(markdown: &str) -> Vec { let mut values = extract_markdown_links(markdown) .into_iter() .filter(|target| is_markdown_reference(target)) .collect::>(); let mut rest = markdown; while let Some(start) = rest.find("[[") { let after_start = &rest[start + 2..]; let Some(end) = after_start.find("]]") else { break; }; let link = after_start[..end].trim(); if !link.is_empty() { values.push(link.to_string()); } rest = &after_start[end + 2..]; } values.sort(); values.dedup(); values } fn extract_resource_refs(markdown: &str) -> Vec { let mut values = markdown .lines() .filter_map(|line| parse_markdown_attachment_link(line.trim())) .map(|(_, target)| target) .chain( extract_markdown_links(markdown) .into_iter() .filter(|target| { !is_markdown_reference(target) && is_local_resource_reference(target) }), ) .collect::>(); values.sort(); values.dedup(); values } fn extract_markdown_links(markdown: &str) -> Vec { let mut links = Vec::new(); let mut rest = markdown; while let Some(label_end) = rest.find("](") { let after = &rest[label_end + 2..]; let Some(target_end) = after.find(')') else { break; }; let target = after[..target_end].trim(); if !target.is_empty() { links.push(target.to_string()); } rest = &after[target_end + 1..]; } links } fn is_markdown_reference(target: &str) -> bool { let lower = target.to_lowercase(); lower.ends_with(".md") || lower.ends_with(".markdown") || lower.starts_with("local-md:") } fn is_local_resource_reference(target: &str) -> bool { let lower = target.to_lowercase(); !lower.starts_with("http://") && !lower.starts_with("https://") && !lower.starts_with("mailto:") && !lower.starts_with('#') && Path::new(target).extension().is_some() } fn search_snippet(document: &LocalSearchDocument, query: &str) -> String { for line in document.raw_text.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if normalize_search_text(trimmed).contains(query) { return trimmed.chars().take(180).collect(); } } document.raw_text.chars().take(180).collect() } fn is_markdown_path(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) .map(|value| value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown")) .unwrap_or(false) } fn resource_type_from_path(path: &Path) -> Option<&'static str> { let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or_default() .to_lowercase(); if file_name.ends_with(".mindmap.json") { return Some("mindmap"); } let extension = path .extension() .and_then(|value| value.to_str()) .unwrap_or_default() .to_lowercase(); match extension.as_str() { "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"), _ => None, } } fn normalize_search_text(value: &str) -> String { value.trim().to_lowercase() } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_millis()) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn temp_root(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("docs")).expect("create root"); root } #[test] fn local_search_index_extracts_backlinks_resource_refs_and_tags() { let root = temp_root("mnote-local-search-index"); fs::write( root.join("README.md"), "---\ntitle: Home\ntags: [alpha, beta]\n---\n# Home\nSee [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n[Map](maps/idea.mindmap.json)\n[Sheet](office/report.xlsx)\n![Diagram](assets/diagram.png)\n", ) .expect("write readme"); fs::write( root.join("docs").join("child.md"), "---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n", ) .expect("write child"); fs::create_dir_all(root.join("maps")).expect("create maps"); fs::create_dir_all(root.join("office")).expect("create office"); fs::write( root.join("maps").join("idea.mindmap.json"), r#"{"title":"Idea Map","nodes":[]}"#, ) .expect("write mindmap"); fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office"); let projection = query_local_search_index( &root, &format!("file://{}", root.display()), "local-ws-test", "alpha", None, 10, false, false, ) .expect("projection"); let results = projection["results"].as_array().expect("results"); let home = results .iter() .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .expect("home result"); assert!(home["tags"] .as_array() .unwrap() .iter() .any(|tag| tag.as_str() == Some("alpha"))); assert!(home["backlinks"] .as_array() .unwrap() .iter() .any(|link| link.as_str() == Some("Daily"))); assert!(home["backlinks"] .as_array() .unwrap() .iter() .any(|link| link.as_str() == Some("docs/child.md"))); assert!(home["resourceRefs"] .as_array() .unwrap() .iter() .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); assert!(home["resourceRefs"] .as_array() .unwrap() .iter() .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))); assert!(home["resourceRefs"] .as_array() .unwrap() .iter() .any(|reference| reference.as_str() == Some("office/report.xlsx"))); assert!(home["publicPath"] .as_str() .is_some_and(|path| path.starts_with( "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" ))); // 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid: let child_search = query_local_search_index( &root, &format!("file://{}", root.display()), "local-ws-test", "office.xlsx", None, 10, false, false, ) .expect("child search"); let child_result = child_search["results"] .as_array() .unwrap() .iter() .find(|item| item["path"].as_str() == Some("docs/child.md")) .expect("child in search results"); assert_eq!( child_result["documentId"].as_str(), Some("local-md:docs~2Fchild.md") ); assert_ne!( child_result["documentId"].as_str(), Some("local-mdid:child-page") ); assert!(root .join(".mnote") .join("index") .join("search-index.json") .exists()); let mindmap_projection = query_local_search_index( &root, &format!("file://{}", root.display()), "local-ws-test", "idea.mindmap", None, 10, false, false, ) .expect("mindmap projection"); assert!(mindmap_projection["results"] .as_array() .unwrap() .iter() .any(|item| item["resourceType"].as_str() == Some("mindmap") && item["path"].as_str() == Some("maps/idea.mindmap.json") && item["publicPath"] .as_str() .is_some_and(|path| path .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri=")) && item["publicPath"] .as_str() .is_some_and(|path| !path.starts_with("/tree?")))); let office_projection = query_local_search_index( &root, &format!("file://{}", root.display()), "local-ws-test", "report.xlsx", None, 10, false, false, ) .expect("office projection"); assert!(office_projection["results"] .as_array() .unwrap() .iter() .any(|item| item["resourceType"].as_str() == Some("office") && item["path"].as_str() == Some("office/report.xlsx"))); let _ = fs::remove_dir_all(&root); } #[test] fn local_search_index_incrementally_updates_single_markdown_path() { let root = temp_root("mnote-local-search-index-incremental"); let root_uri = format!("file://{}", root.display()); let workspace_id = "local-ws-incremental"; fs::write(root.join("README.md"), "# Home\nRoot body.\n").expect("write home"); fs::write( root.join("docs").join("child.md"), "---\ntitle: Child\n---\n# Child\nOriginal body.\n", ) .expect("write child"); refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh"); fs::write( root.join("docs").join("child.md"), "---\ntitle: Child Updated\n---\n# Child Updated\nChangedToken body.\n", ) .expect("update child"); refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md") .expect("incremental update"); let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); assert!(index .documents .iter() .any(|document| document.path == "README.md")); let child = index .documents .iter() .find(|document| document.path == "docs/child.md") .expect("child document"); // frontmatter title "Child Updated" 优先于文件名 "child"。 assert_eq!(child.title, "Child Updated"); assert!(child.raw_text.contains("ChangedToken")); fs::remove_file(root.join("docs").join("child.md")).expect("remove child"); refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md") .expect("incremental remove"); let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); assert!(!index .documents .iter() .any(|document| document.path == "docs/child.md")); assert!(index .documents .iter() .any(|document| document.path == "README.md")); let _ = fs::remove_dir_all(&root); } }