From dec57585f8fce41eb797a2b952d46c61d9100823 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 20:46:47 +0800 Subject: [PATCH 1/9] refactor(mcp): complete trait-ization of remaining MCP tool layer - Extend RegistryClient with save_relation, query_relations, delete_relations, list_vault_notes - Add WorkflowClient trait (list_workflows, get_workflow, run_workflow, get_execution) - Add VaultClient trait (list_vault_notes, read_vault_note, get_backlinks, build_vault_graph) - Implement all traits on AppContext (registry.rs, workflow/mod.rs, vault/mod.rs) - Enable AppContext Clone via Arc> for spawn_blocking safety - Refactor relations.rs, workflow.rs, vault.rs to use trait calls exclusively - Eliminate all production-code inline crate:: calls in mcp/tools/ relations|workflow|vault --- src/clients.rs | 35 +++++ src/mcp/tools/relations.rs | 90 ++----------- src/mcp/tools/vault.rs | 261 ++++++++++--------------------------- src/mcp/tools/workflow.rs | 115 +--------------- src/registry.rs | 125 ++++++++++++++++++ src/storage.rs | 7 +- src/vault/mod.rs | 145 +++++++++++++++++++++ src/workflow/mod.rs | 131 +++++++++++++++++++ 8 files changed, 524 insertions(+), 385 deletions(-) diff --git a/src/clients.rs b/src/clients.rs index 738ead7..2b94c4a 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -74,6 +74,25 @@ pub trait RegistryClient: Send + Sync { ) -> Result; fn query_dead_code(&self, repo_id: &str, include_pub: bool, limit: usize) -> Result; + + fn save_relation( + &self, + from: &str, + to: &str, + relation_type: &str, + confidence: f64, + ) -> Result; + + fn query_relations( + &self, + entity_id: &str, + direction: &str, + relation_type: Option<&str>, + ) -> Result; + + fn delete_relations(&self, from: &str, to: &str, relation_type: Option<&str>) -> Result; + + fn list_vault_notes(&self) -> Result; } /// Knowledge engine operations. @@ -110,3 +129,19 @@ pub trait SearchClient: Send + Sync { limit: usize, ) -> Result>; } + +/// Workflow management exposed to MCP tools. +pub trait WorkflowClient: Send + Sync { + fn list_workflows(&self) -> Result; + fn get_workflow(&self, workflow_id: &str) -> Result; + fn run_workflow(&self, workflow_id: &str, inputs: Value) -> Result; + fn get_execution(&self, exec_id: i64) -> Result; +} + +/// Vault (Markdown knowledge-base) operations exposed to MCP tools. +pub trait VaultClient: Send + Sync { + fn list_vault_notes(&self) -> Result; + fn read_vault_note(&self, path: &str) -> Result; + fn get_backlinks(&self, note_id: &str) -> Result; + fn build_vault_graph(&self, repo_id: Option<&str>) -> Result; +} diff --git a/src/mcp/tools/relations.rs b/src/mcp/tools/relations.rs index 70ee592..cf88238 100644 --- a/src/mcp/tools/relations.rs +++ b/src/mcp/tools/relations.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +use crate::clients::RegistryClient; use crate::mcp::McpTool; #[derive(Clone)] @@ -83,10 +84,7 @@ Returns: success boolean and relation details."#, })); } - let conn = ctx.conn()?; - if let Err(e) = - crate::registry::relation::save_relation(&conn, &from, &to, &rel_type, confidence) - { + if let Err(e) = ctx.save_relation(&from, &to, &rel_type, confidence) { let msg = e.to_string(); if msg.contains("foreign key constraint") || msg.contains("FOREIGN KEY") { return Ok(serde_json::json!({ @@ -162,73 +160,9 @@ Returns: JSON array of relations with to_entity_id, relation_type, confidence, a })); } - let conn = ctx.conn()?; - let results = match direction { - "bidirectional" => { - let rows = crate::registry::relation::find_related_entities( - &conn, - &entity_id, - relation_type, - )?; - rows.into_iter() - .map(|(from, to, rt, conf, created)| { - serde_json::json!({ - "from_entity_id": from, - "to_entity_id": to, - "relation_type": rt, - "confidence": conf, - "created_at": created - }) - }) - .collect::>() - } - "incoming" => { - let mut stmt = conn.prepare( - "SELECT from_entity_id, relation_type, confidence, created_at FROM relations - WHERE to_entity_id = ?1 - ORDER BY confidence DESC", - )?; - let rows = stmt.query_map([&entity_id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, f64>(2)?, - row.get::<_, String>(3)?, - )) - })?; - let filtered: Vec<_> = if let Some(rt) = relation_type.filter(|s| !s.is_empty()) { - rows.filter(|r| r.as_ref().map(|(_, t, _, _)| t == rt).unwrap_or(false)) - .collect::, _>>()? - } else { - rows.collect::, _>>()? - }; - filtered - .into_iter() - .map(|(from, rt, conf, created)| { - serde_json::json!({ - "from_entity_id": from, - "relation_type": rt, - "confidence": conf, - "created_at": created - }) - }) - .collect::>() - } - _ => { - let rows = - crate::registry::relation::list_relations(&conn, &entity_id, relation_type)?; - rows.into_iter() - .map(|(to, rt, conf, created)| { - serde_json::json!({ - "to_entity_id": to, - "relation_type": rt, - "confidence": conf, - "created_at": created - }) - }) - .collect::>() - } - }; + let value = ctx.query_relations(&entity_id, direction, relation_type)?; + let results = + value.get("relations").and_then(|v| v.as_array()).cloned().unwrap_or_default(); Ok(serde_json::json!({ "success": true, @@ -297,17 +231,9 @@ Returns: success boolean and count of deleted relations."#, })); } - let conn = ctx.conn()?; - let count = match rel_type.as_deref().filter(|s| !s.is_empty()) { - Some(rt) => conn.execute( - "DELETE FROM relations WHERE from_entity_id = ?1 AND to_entity_id = ?2 AND relation_type = ?3", - rusqlite::params![&from, &to, rt], - )?, - None => conn.execute( - "DELETE FROM relations WHERE from_entity_id = ?1 AND to_entity_id = ?2", - rusqlite::params![&from, &to], - )?, - }; + let value = + ctx.delete_relations(&from, &to, rel_type.as_deref().filter(|s| !s.is_empty()))?; + let count = value.get("deleted").and_then(|v| v.as_u64()).unwrap_or(0) as usize; Ok(serde_json::json!({ "success": true, diff --git a/src/mcp/tools/vault.rs b/src/mcp/tools/vault.rs index 8b90f9a..f2461de 100644 --- a/src/mcp/tools/vault.rs +++ b/src/mcp/tools/vault.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +use crate::clients::{DigestClient, VaultClient}; use crate::mcp::McpTool; +use crate::registry::VaultNote; use anyhow::Context; #[derive(Clone)] @@ -51,42 +53,45 @@ Returns: JSON array of matching notes. Each includes: id, title, path, and tags. .and_then(|v| v.as_str()) .context("Missing required argument: query")?; - let pool = ctx.pool(); - let results = tokio::task::spawn_blocking({ - let query = query.to_string(); - move || { - let conn = pool.get()?; - let notes = crate::registry::vault::list_vault_notes(&conn)?; - let keywords: Vec<&str> = query.split_whitespace().collect(); - - let filtered: Vec<_> = notes - .into_iter() - .filter(|n| { - let content = crate::vault::fs_io::read_note_body(&n.path) - .map(|(body, _fm)| body) - .unwrap_or_default(); - let hay = format!( - "{} {} {} {}", - n.id, - n.title.as_deref().unwrap_or(""), - n.tags.join(","), - content - ) - .to_lowercase(); - keywords.iter().all(|kw| hay.contains(&kw.to_lowercase())) + let ctx = ctx.clone(); + let query_owned = query.to_string(); + let results = tokio::task::spawn_blocking(move || { + let value = ctx.list_vault_notes()?; + let notes: Vec = serde_json::from_value( + value.get("notes").cloned().unwrap_or(serde_json::json!([])), + ) + .unwrap_or_default(); + let keywords: Vec<&str> = query_owned.split_whitespace().collect(); + + let filtered: Vec<_> = notes + .into_iter() + .filter(|n| { + let content = ctx + .read_vault_note(&n.path) + .ok() + .and_then(|v| v.get("content").and_then(|c| c.as_str()).map(String::from)) + .unwrap_or_default(); + let hay = format!( + "{} {} {} {}", + n.id, + n.title.as_deref().unwrap_or(""), + n.tags.join(","), + content + ) + .to_lowercase(); + keywords.iter().all(|kw| hay.contains(&kw.to_lowercase())) + }) + .map(|n| { + serde_json::json!({ + "id": n.id, + "title": n.title, + "path": n.path, + "tags": n.tags, }) - .map(|n| { - serde_json::json!({ - "id": n.id, - "title": n.title, - "path": n.path, - "tags": n.tags, - }) - }) - .collect(); + }) + .collect(); - anyhow::Ok(filtered) - } + anyhow::Ok(filtered) }) .await .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; @@ -140,15 +145,18 @@ Returns: JSON with frontmatter (id, repo, tags, ai_context, created, updated) an async fn invoke( &self, args: serde_json::Value, - _ctx: &mut crate::storage::AppContext, + ctx: &mut crate::storage::AppContext, ) -> anyhow::Result { let path = args .get("path") .and_then(|v| v.as_str()) .context("Missing required argument: path")?; - let (body, frontmatter) = crate::vault::fs_io::read_note_body(path) + let value = ctx + .read_vault_note(path) .context("Failed to read note — file not found or unreadable")?; + let body = value.get("content").cloned().unwrap_or(serde_json::json!("")); + let frontmatter = value.get("frontmatter").cloned().unwrap_or(serde_json::json!(null)); Ok(serde_json::json!({ "success": true, @@ -325,30 +333,12 @@ Returns: JSON array of backlinking notes, each with id, title, and path."#, .and_then(|v| v.as_str()) .context("Missing required argument: note_id")?; - let vault_dir = ctx.storage.workspace_dir().ok().map(|ws| ws.join("vault")); - let backlinks = tokio::task::spawn_blocking({ - let note_id = note_id.to_string(); - let vault_dir = vault_dir.clone(); - move || { - if let Some(vd) = vault_dir { - match crate::vault::backlinks::build_backlink_index(&vd) { - Ok(index) => crate::vault::backlinks::get_backlinks(&index, ¬e_id), - Err(_) => Vec::new(), - } - } else { - Vec::new() - } - } - }) - .await - .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))?; - - Ok(serde_json::json!({ - "success": true, - "target": note_id, - "count": backlinks.len(), - "backlinks": backlinks, - })) + let ctx = ctx.clone(); + let note_id = note_id.to_string(); + let value = tokio::task::spawn_blocking(move || ctx.get_backlinks(¬e_id)) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; + Ok(value) } } @@ -386,39 +376,32 @@ Returns: JSON with success status and the generated file path."#, let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let rel_path = format!("99-Meta/Daily/{}.md", today); - let pool = ctx.pool(); - let config = ctx.config.clone(); - let i18n = ctx.i18n; - + let ctx = ctx.clone(); + let today_owned = today.clone(); let vault_root = ctx .storage .workspace_dir() .map(|ws| ws.join("vault")) .unwrap_or_else(|_| std::path::PathBuf::from("vault")); - let file_path = tokio::task::spawn_blocking({ - let rel_path = rel_path.clone(); - let today = today.clone(); - let vault_root = vault_root.clone(); - move || { - let conn = pool.get()?; - let digest = crate::digest::generate_daily_digest(&conn, &config, &i18n)?; - - let target = resolve_vault_path(&rel_path, &vault_root)?; - - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } + let file_path = tokio::task::spawn_blocking(move || { + let digest = ctx.generate_daily_digest()?; + let digest_str = digest.get("digest").and_then(|v| v.as_str()).unwrap_or(""); - let content = if target.exists() { - let existing = std::fs::read_to_string(&target)?; - format!("{}\n\n{}", existing, digest) - } else { - format!("---\ndate: {}\ntags: [\"daily\"]\n---\n\n{}", today, digest) - }; + let target = resolve_vault_path(&rel_path, &vault_root)?; - std::fs::write(&target, content)?; - anyhow::Ok(target.to_string_lossy().to_string()) + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; } + + let content = if target.exists() { + let existing = std::fs::read_to_string(&target)?; + format!("{}\n\n{}", existing, digest_str) + } else { + format!("---\ndate: {}\ntags: [\"daily\"]\n---\n\n{}", today_owned, digest_str) + }; + + std::fs::write(&target, content)?; + anyhow::Ok(target.to_string_lossy().to_string()) }) .await .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; @@ -468,110 +451,10 @@ Returns: JSON with nodes (id, title) and edges (source, target)."#, ) -> anyhow::Result { let repo_id = args.get("repo_id").and_then(|v| v.as_str()).map(|s| s.to_string()); - let vault_dir = ctx.storage.workspace_dir().ok().map(|ws| ws.join("vault")); - let graph = tokio::task::spawn_blocking({ - let repo_id = repo_id.clone(); - let vault_dir = vault_dir.clone(); - move || { - let Some(vd) = vault_dir else { - return anyhow::Ok(serde_json::json!({ - "success": true, - "count": 0, - "edge_count": 0, - "nodes": [], - "edges": [], - })); - }; - - let index = crate::vault::backlinks::build_backlink_index(&vd)?; - - let mut id_to_title: std::collections::HashMap = - std::collections::HashMap::new(); - let mut id_to_repo: std::collections::HashMap = - std::collections::HashMap::new(); - - for entry in walkdir::WalkDir::new(&vd) - .follow_links(false) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) - { - let path = entry.path(); - let rel_path = path.strip_prefix(&vd).unwrap_or(path); - let id = rel_path.to_string_lossy().replace('\\', "/"); - - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(_) => continue, - }; - - if let Some((fm, _)) = crate::vault::frontmatter::extract_frontmatter(&content) - { - id_to_title.insert(id.clone(), fm.title.unwrap_or_else(|| id.clone())); - if let Some(repo) = fm.repo { - id_to_repo.insert(id, repo); - } - } else { - id_to_title.insert(id.clone(), id.clone()); - } - } - - let allowed_ids: std::collections::HashSet = if let Some(ref rid) = repo_id - { - id_to_repo.iter().filter(|(_, r)| *r == rid).map(|(id, _)| id.clone()).collect() - } else { - id_to_title.keys().cloned().collect() - }; - - // Normalize wikilink targets (e.g. "b" -> "b.md") to vault file ids. - let mut id_lookup: std::collections::HashMap = - std::collections::HashMap::new(); - for id in id_to_title.keys() { - id_lookup.insert(id.clone(), id.clone()); - if let Some(stem) = id.strip_suffix(".md") { - id_lookup.insert(stem.to_string(), id.clone()); - } - } - - let nodes: Vec<_> = allowed_ids - .iter() - .map(|id| { - serde_json::json!({ - "id": id, - "title": id_to_title.get(id).unwrap_or(id), - }) - }) - .collect(); - - let mut edges = Vec::new(); - for (target, sources) in &index { - let normalized = - id_lookup.get(target).cloned().unwrap_or_else(|| target.clone()); - if !allowed_ids.contains(&normalized) { - continue; - } - for source in sources { - if allowed_ids.contains(source) { - edges.push(serde_json::json!({ - "source": source, - "target": &normalized, - })); - } - } - } - - anyhow::Ok(serde_json::json!({ - "success": true, - "count": nodes.len(), - "edge_count": edges.len(), - "nodes": nodes, - "edges": edges, - })) - } - }) - .await - .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; + let ctx = ctx.clone(); + let graph = tokio::task::spawn_blocking(move || ctx.build_vault_graph(repo_id.as_deref())) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; Ok(graph) } diff --git a/src/mcp/tools/workflow.rs b/src/mcp/tools/workflow.rs index bb17bc7..0dfd7bb 100644 --- a/src/mcp/tools/workflow.rs +++ b/src/mcp/tools/workflow.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +use crate::clients::WorkflowClient; use crate::mcp::McpTool; -use std::collections::HashMap; #[derive(Clone)] pub struct DevkitWorkflowListTool; @@ -35,23 +35,7 @@ Returns: JSON array of workflows with id, name, and version."#, _args: serde_json::Value, ctx: &mut crate::storage::AppContext, ) -> anyhow::Result { - let conn = ctx.conn()?; - let workflows = crate::workflow::state::list_workflows(&conn)?; - let items: Vec = workflows - .into_iter() - .map(|(id, name, version)| { - serde_json::json!({ - "id": id, - "name": name, - "version": version - }) - }) - .collect(); - Ok(serde_json::json!({ - "success": true, - "count": items.len(), - "workflows": items - })) + ctx.list_workflows() } } @@ -104,81 +88,7 @@ Returns: execution summary with status, step results, and execution_id."#, })); } - let conn = ctx.conn()?; - let wf = match crate::workflow::state::get_workflow(&conn, &workflow_id)? { - Some(wf) => wf, - None => { - return Ok(serde_json::json!({ - "success": false, - "error": format!("workflow '{}' not found", workflow_id) - })); - } - }; - - // Parse inputs into HashMap - let inputs: HashMap = if let Some(obj) = inputs_value.as_object() { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - } else { - HashMap::new() - }; - - let inputs_json = inputs_value.to_string(); - let exec_id = crate::workflow::state::create_execution(&conn, &workflow_id, &inputs_json)?; - crate::workflow::state::update_execution( - &conn, - exec_id, - &crate::workflow::model::ExecutionStatus::Running, - None, - None, - )?; - - let pool = ctx.pool(); - let start = std::time::Instant::now(); - let result = crate::workflow::executor::execute_workflow(&conn, &pool, &wf, inputs); - let duration_ms = start.elapsed().as_millis() as i64; - - match result { - Ok(step_results) => { - crate::workflow::state::update_execution( - &conn, - exec_id, - &crate::workflow::model::ExecutionStatus::Completed, - None, - Some(duration_ms), - )?; - let results_json: HashMap = step_results - .into_iter() - .map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(serde_json::json!(null)))) - .collect(); - Ok(serde_json::json!({ - "success": true, - "execution_id": exec_id, - "workflow_id": workflow_id, - "status": "Completed", - "duration_ms": duration_ms, - "step_results": results_json - })) - } - Err(e) => { - crate::workflow::state::update_execution( - &conn, - exec_id, - &crate::workflow::model::ExecutionStatus::Failed, - None, - Some(duration_ms), - )?; - Ok(serde_json::json!({ - "success": false, - "execution_id": exec_id, - "workflow_id": workflow_id, - "status": "Failed", - "duration_ms": duration_ms, - "error": e.to_string() - })) - } - } + ctx.run_workflow(&workflow_id, inputs_value) } } @@ -227,24 +137,7 @@ Returns: execution record with status, current_step, timestamps, and duration."# })); } - let conn = ctx.conn()?; - match crate::workflow::state::get_execution(&conn, exec_id)? { - Some(exec) => Ok(serde_json::json!({ - "success": true, - "execution_id": exec.id, - "workflow_id": exec.workflow_id, - "status": format!("{:?}", exec.status), - "current_step": exec.current_step, - "started_at": exec.started_at, - "finished_at": exec.finished_at, - "duration_ms": exec.duration_ms, - "inputs": exec.inputs_json - })), - None => Ok(serde_json::json!({ - "success": false, - "error": format!("execution {} not found", exec_id) - })), - } + ctx.get_execution(exec_id) } } diff --git a/src/registry.rs b/src/registry.rs index 0d89121..1ffaccc 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -382,6 +382,131 @@ impl crate::clients::RegistryClient for crate::storage::AppContext { "dead_functions": out })) } + + fn save_relation( + &self, + from: &str, + to: &str, + relation_type: &str, + confidence: f64, + ) -> anyhow::Result { + let conn = self.conn()?; + crate::registry::relation::save_relation(&conn, from, to, relation_type, confidence)?; + Ok(serde_json::json!({ "success": true })) + } + + fn query_relations( + &self, + entity_id: &str, + direction: &str, + relation_type: Option<&str>, + ) -> anyhow::Result { + let conn = self.conn()?; + let results = match direction { + "bidirectional" => { + let rows = crate::registry::relation::find_related_entities( + &conn, + entity_id, + relation_type, + )?; + rows.into_iter() + .map(|(from, to, rt, conf, created)| { + serde_json::json!({ + "from_entity_id": from, + "to_entity_id": to, + "relation_type": rt, + "confidence": conf, + "created_at": created + }) + }) + .collect::>() + } + "incoming" => { + let mut stmt = conn.prepare( + "SELECT from_entity_id, relation_type, confidence, created_at FROM relations + WHERE to_entity_id = ?1 + ORDER BY confidence DESC", + )?; + let rows = stmt.query_map([entity_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let filtered: Vec<_> = if let Some(rt) = relation_type.filter(|s| !s.is_empty()) { + rows.filter(|r| r.as_ref().map(|(_, t, _, _)| t == rt).unwrap_or(false)) + .collect::, _>>()? + } else { + rows.collect::, _>>()? + }; + filtered + .into_iter() + .map(|(from, rt, conf, created)| { + serde_json::json!({ + "from_entity_id": from, + "relation_type": rt, + "confidence": conf, + "created_at": created + }) + }) + .collect::>() + } + _ => { + let rows = + crate::registry::relation::list_relations(&conn, entity_id, relation_type)?; + rows.into_iter() + .map(|(to, rt, conf, created)| { + serde_json::json!({ + "to_entity_id": to, + "relation_type": rt, + "confidence": conf, + "created_at": created + }) + }) + .collect::>() + } + }; + Ok(serde_json::json!({ "success": true, "relations": results })) + } + + fn delete_relations( + &self, + from: &str, + to: &str, + relation_type: Option<&str>, + ) -> anyhow::Result { + let conn = self.conn()?; + let count = match relation_type.filter(|s| !s.is_empty()) { + Some(rt) => conn.execute( + "DELETE FROM relations WHERE from_entity_id = ?1 AND to_entity_id = ?2 AND relation_type = ?3", + rusqlite::params![from, to, rt], + )?, + None => conn.execute( + "DELETE FROM relations WHERE from_entity_id = ?1 AND to_entity_id = ?2", + rusqlite::params![from, to], + )?, + }; + Ok(serde_json::json!({ "success": true, "deleted": count })) + } + + fn list_vault_notes(&self) -> anyhow::Result { + let conn = self.conn()?; + let notes = crate::registry::vault::list_vault_notes(&conn)?; + let results: Vec = notes + .into_iter() + .map(|n| { + serde_json::json!({ + "id": n.id, + "path": n.path, + "title": n.title, + "tags": n.tags, + }) + }) + .collect(); + Ok(serde_json::json!({ "success": true, "count": results.len(), "notes": results })) + } } #[cfg(test)] diff --git a/src/storage.rs b/src/storage.rs index c5004f1..4f14713 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -116,12 +116,13 @@ impl StorageBackend for DefaultStorageBackend { /// /// 命令处理函数应通过此结构体获取所有外部依赖, /// 避免直接调用全局函数或读取环境变量。 +#[derive(Clone)] pub struct AppContext { pub storage: Arc, pub config: Config, pub i18n: I18n, pool: Pool, - env_cache: std::sync::Mutex, + env_cache: Arc>, } impl AppContext { @@ -146,7 +147,7 @@ impl AppContext { config, i18n, pool, - env_cache: std::sync::Mutex::new(EnvVersionCache::default()), + env_cache: Arc::new(std::sync::Mutex::new(EnvVersionCache::default())), }) } @@ -169,7 +170,7 @@ impl AppContext { config, i18n, pool, - env_cache: std::sync::Mutex::new(EnvVersionCache::default()), + env_cache: Arc::new(std::sync::Mutex::new(EnvVersionCache::default())), }) } diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 5168b14..91e130a 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -6,3 +6,148 @@ pub mod fs_io; pub mod indexer; pub mod scanner; pub mod wikilink; + +use crate::storage::AppContext; + +impl crate::clients::VaultClient for AppContext { + fn list_vault_notes(&self) -> anyhow::Result { + let conn = self.conn()?; + let notes = crate::registry::vault::list_vault_notes(&conn)?; + let results: Vec = notes + .into_iter() + .map(|n| { + serde_json::json!({ + "id": n.id, + "path": n.path, + "title": n.title, + "tags": n.tags, + }) + }) + .collect(); + Ok(serde_json::json!({"success": true, "count": results.len(), "notes": results})) + } + + fn read_vault_note(&self, path: &str) -> anyhow::Result { + let (body, frontmatter) = fs_io::read_note_body(path) + .ok_or_else(|| anyhow::anyhow!("note not found or unreadable"))?; + Ok(serde_json::json!({ + "success": true, + "path": path, + "content": body, + "frontmatter": frontmatter, + })) + } + + fn get_backlinks(&self, note_id: &str) -> anyhow::Result { + let vault_dir = self.storage.workspace_dir().ok().map(|ws| ws.join("vault")); + let backlinks = if let Some(vd) = vault_dir { + match backlinks::build_backlink_index(&vd) { + Ok(index) => backlinks::get_backlinks(&index, note_id), + Err(_) => Vec::new(), + } + } else { + Vec::new() + }; + Ok(serde_json::json!({ + "success": true, + "target": note_id, + "count": backlinks.len(), + "backlinks": backlinks, + })) + } + + fn build_vault_graph(&self, repo_id: Option<&str>) -> anyhow::Result { + let vault_dir = self.storage.workspace_dir().ok().map(|ws| ws.join("vault")); + let Some(vd) = vault_dir else { + return Ok(serde_json::json!({ + "success": true, + "count": 0, + "edge_count": 0, + "nodes": [], + "edges": [], + })); + }; + + let index = backlinks::build_backlink_index(&vd)?; + + let mut id_to_title: std::collections::HashMap = + std::collections::HashMap::new(); + let mut id_to_repo: std::collections::HashMap = + std::collections::HashMap::new(); + + for entry in walkdir::WalkDir::new(&vd) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) + { + let path = entry.path(); + let rel_path = path.strip_prefix(&vd).unwrap_or(path); + let id = rel_path.to_string_lossy().replace('\\', "/"); + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + if let Some((fm, _)) = frontmatter::extract_frontmatter(&content) { + id_to_title.insert(id.clone(), fm.title.unwrap_or_else(|| id.clone())); + if let Some(repo) = fm.repo { + id_to_repo.insert(id, repo); + } + } else { + id_to_title.insert(id.clone(), id.clone()); + } + } + + let allowed_ids: std::collections::HashSet = if let Some(rid) = repo_id { + id_to_repo.iter().filter(|(_, r)| *r == rid).map(|(id, _)| id.clone()).collect() + } else { + id_to_title.keys().cloned().collect() + }; + + let mut id_lookup: std::collections::HashMap = + std::collections::HashMap::new(); + for id in id_to_title.keys() { + id_lookup.insert(id.clone(), id.clone()); + if let Some(stem) = id.strip_suffix(".md") { + id_lookup.insert(stem.to_string(), id.clone()); + } + } + + let nodes: Vec<_> = allowed_ids + .iter() + .map(|id| { + serde_json::json!({ + "id": id, + "title": id_to_title.get(id).unwrap_or(id), + }) + }) + .collect(); + + let mut edges = Vec::new(); + for (target, sources) in &index { + let normalized = id_lookup.get(target).cloned().unwrap_or_else(|| target.clone()); + if !allowed_ids.contains(&normalized) { + continue; + } + for source in sources { + if allowed_ids.contains(source) { + edges.push(serde_json::json!({ + "source": source, + "target": &normalized, + })); + } + } + } + + Ok(serde_json::json!({ + "success": true, + "count": nodes.len(), + "edge_count": edges.len(), + "nodes": nodes, + "edges": edges, + })) + } +} diff --git a/src/workflow/mod.rs b/src/workflow/mod.rs index b66cc28..eb534bc 100644 --- a/src/workflow/mod.rs +++ b/src/workflow/mod.rs @@ -17,3 +17,134 @@ pub use state::{ update_execution, }; pub use validator::validate_workflow; + +use crate::storage::AppContext; + +impl crate::clients::WorkflowClient for AppContext { + fn list_workflows(&self) -> anyhow::Result { + let conn = self.conn()?; + let workflows = state::list_workflows(&conn)?; + let items: Vec = workflows + .into_iter() + .map(|(id, name, version)| { + serde_json::json!({"id": id, "name": name, "version": version}) + }) + .collect(); + Ok(serde_json::json!({"success": true, "count": items.len(), "workflows": items})) + } + + fn get_workflow(&self, workflow_id: &str) -> anyhow::Result { + let conn = self.conn()?; + match state::get_workflow(&conn, workflow_id)? { + Some(wf) => Ok(serde_json::json!({ + "success": true, + "id": wf.id, + "name": wf.name, + "version": wf.version, + "description": wf.description, + "steps": wf.steps.len(), + })), + None => Ok(serde_json::json!({"success": false, "error": "workflow not found"})), + } + } + + fn run_workflow( + &self, + workflow_id: &str, + inputs: serde_json::Value, + ) -> anyhow::Result { + let conn = self.conn()?; + let wf = match state::get_workflow(&conn, workflow_id)? { + Some(wf) => wf, + None => { + return Ok(serde_json::json!({ + "success": false, + "error": format!("workflow '{}' not found", workflow_id) + })); + } + }; + + let inputs_map: std::collections::HashMap = + if let Some(obj) = inputs.as_object() { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + } else { + std::collections::HashMap::new() + }; + + let inputs_json = inputs.to_string(); + let exec_id = state::create_execution(&conn, workflow_id, &inputs_json)?; + state::update_execution(&conn, exec_id, &model::ExecutionStatus::Running, None, None)?; + + let pool = self.pool(); + let start = std::time::Instant::now(); + let result = executor::execute_workflow(&conn, &pool, &wf, inputs_map); + let duration_ms = start.elapsed().as_millis() as i64; + + match result { + Ok(step_results) => { + state::update_execution( + &conn, + exec_id, + &model::ExecutionStatus::Completed, + None, + Some(duration_ms), + )?; + let results_json: std::collections::HashMap = + step_results + .into_iter() + .map(|(k, v)| { + (k, serde_json::to_value(v).unwrap_or(serde_json::json!(null))) + }) + .collect(); + Ok(serde_json::json!({ + "success": true, + "execution_id": exec_id, + "workflow_id": workflow_id, + "status": "Completed", + "duration_ms": duration_ms, + "step_results": results_json + })) + } + Err(e) => { + state::update_execution( + &conn, + exec_id, + &model::ExecutionStatus::Failed, + None, + Some(duration_ms), + )?; + Ok(serde_json::json!({ + "success": false, + "execution_id": exec_id, + "workflow_id": workflow_id, + "status": "Failed", + "duration_ms": duration_ms, + "error": e.to_string() + })) + } + } + } + + fn get_execution(&self, exec_id: i64) -> anyhow::Result { + let conn = self.conn()?; + match state::get_execution(&conn, exec_id)? { + Some(exec) => Ok(serde_json::json!({ + "success": true, + "execution_id": exec.id, + "workflow_id": exec.workflow_id, + "status": format!("{:?}", exec.status), + "current_step": exec.current_step, + "started_at": exec.started_at, + "finished_at": exec.finished_at, + "duration_ms": exec.duration_ms, + "inputs": exec.inputs_json, + })), + None => Ok(serde_json::json!({ + "success": false, + "error": format!("execution {} not found", exec_id) + })), + } + } +} From 8a36e72d18bcc3e37fe32b4c27b21ad789abf4b8 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 21:12:48 +0800 Subject: [PATCH 2/9] refactor(knowledge_engine): extract Config load + prepare_repos, eliminate loop-level I/O - index_repo: accept Config by parameter instead of loading internally - daemon.rs: load Config once before indexing loop - run_index_with_progress: hoist Config::load() out of repo loop - Extract prepare_repos() pure helper for path resolution / auto-registration - Result: ~20 fewer inline crate:: calls, eliminated repeated disk I/O in hot loop --- src/daemon.rs | 3 ++- src/knowledge_engine/index.rs | 47 +++++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 6ad0bc8..746a8db 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -105,6 +105,7 @@ impl Daemon { let pool = self.pool.clone(); match tokio::task::spawn_blocking(move || { let mut conn = pool.get()?; + let config = crate::config::Config::load().ok(); let repos = if let Some(threshold) = index_threshold { crate::registry::repo::list_repos_need_index(&conn, &threshold)? } else { @@ -112,7 +113,7 @@ impl Daemon { }; let mut count = 0; for repo in repos { - if let Err(e) = index_repo(&mut conn, &repo) { + if let Err(e) = index_repo(&mut conn, &repo, config.as_ref()) { tracing::warn!("Failed to index {}: {}", repo.id, e); } else { count += 1; diff --git a/src/knowledge_engine/index.rs b/src/knowledge_engine/index.rs index 2c59804..fb668be 100644 --- a/src/knowledge_engine/index.rs +++ b/src/knowledge_engine/index.rs @@ -20,10 +20,10 @@ fn index_repo_in_search( pub fn index_repo( conn: &mut rusqlite::Connection, repo: &crate::registry::RepoEntry, + config: Option<&crate::config::Config>, ) -> anyhow::Result<()> { use tracing::{info, warn}; - let config = crate::config::Config::load().ok(); let (summary, keywords) = config .as_ref() .and_then(|cfg| super::try_llm_summary(&repo.local_path, &cfg.llm)) @@ -66,6 +66,31 @@ pub fn run_index( run_index_with_progress(conn, path, None, skip_embeddings) } +/// Resolve the list of repositories to index for a given path. +/// If `path` is empty, returns all registered repos. +/// If `path` points to an unregistered repo, auto-registers it before returning. +fn prepare_repos(conn: &mut rusqlite::Connection, path: &str) -> anyhow::Result> { + use tracing::info; + + if path.is_empty() { + return crate::registry::repo::list_repos(conn); + } + + let p = PathBuf::from(path); + if !p.exists() { + anyhow::bail!("Path does not exist: {}", path); + } + let registered = crate::registry::repo::list_repos(conn)?; + if let Some(repo) = registered.into_iter().find(|r| r.local_path == p) { + Ok(vec![repo]) + } else { + info!("Registering {} before indexing", path); + let repo = crate::scan::inspect_repo(&p, None)?; + crate::registry::repo::save_repo(conn, &repo)?; + Ok(vec![repo]) + } +} + /// 带进度上报的索引逻辑。 /// `progress_tx` 接收阶段性进度消息,用于 MCP streaming 等实时反馈场景。 pub fn run_index_with_progress( @@ -82,23 +107,7 @@ pub fn run_index_with_progress( } }; - let repos: Vec = if path.is_empty() { - crate::registry::repo::list_repos(conn)? - } else { - let p = PathBuf::from(path); - if !p.exists() { - anyhow::bail!("Path does not exist: {}", path); - } - let registered = crate::registry::repo::list_repos(conn)?; - if let Some(repo) = registered.into_iter().find(|r| r.local_path == p) { - vec![repo] - } else { - info!("Registering {} before indexing", path); - let repo = crate::scan::inspect_repo(&p, None)?; - crate::registry::repo::save_repo(conn, &repo)?; - vec![repo] - } - }; + let repos = prepare_repos(conn, path)?; // Initialize Tantivy search index writer once for the batch let (search_index, _reader) = crate::search::init_index()?; @@ -112,10 +121,10 @@ pub fn run_index_with_progress( .filter_map(Result::ok) .collect(); + let config = crate::config::Config::load().ok(); let mut count = 0; for repo in &repos { let t0 = std::time::Instant::now(); - let config = crate::config::Config::load().ok(); let (summary, keywords) = config .as_ref() .and_then(|cfg| super::try_llm_summary(&repo.local_path, &cfg.llm)) From a9bea887ba8324084e6a2c3485a96326f9fe01fa Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 21:41:42 +0800 Subject: [PATCH 3/9] perf(knowledge_engine): reuse Tantivy writer across daemon batch indexing - Extract index_repo_core() with explicit writer/schema parameters - index_repo() retains legacy behavior (creates standalone writer) - Add index_repo_with_writer() for batch callers - daemon.rs: init writer once before loop, commit once after loop - Eliminates N-1 redundant Tantivy init/get_writer/commit cycles --- src/daemon.rs | 16 +++++++++-- src/knowledge_engine/index.rs | 50 ++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 746a8db..20891f6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -5,7 +5,6 @@ use crate::digest::generate_daily_digest; use crate::discovery_engine::{discover_dependencies, discover_similar_projects}; use crate::health::analyze_repo; use crate::i18n::from_language; -use crate::knowledge_engine::index_repo; use crate::registry::{ HealthEntry, health as reg_health, knowledge as reg_knowledge, relation as reg_relation, repo, }; @@ -111,14 +110,27 @@ impl Daemon { } else { crate::registry::repo::list_repos(&conn)? }; + // Batch-index: reuse a single Tantivy writer across all repos + let (search_index, _reader) = crate::search::init_index()?; + let mut writer = crate::search::get_writer(&search_index)?; + let schema = search_index.schema(); let mut count = 0; for repo in repos { - if let Err(e) = index_repo(&mut conn, &repo, config.as_ref()) { + if let Err(e) = crate::knowledge_engine::index_repo_with_writer( + &mut conn, + &repo, + config.as_ref(), + &mut writer, + &schema, + ) { tracing::warn!("Failed to index {}: {}", repo.id, e); } else { count += 1; } } + if let Err(e) = crate::search::commit_writer(&mut writer) { + tracing::warn!("Failed to commit search index: {}", e); + } Ok::<_, anyhow::Error>(count) }) .await diff --git a/src/knowledge_engine/index.rs b/src/knowledge_engine/index.rs index fb668be..21183b9 100644 --- a/src/knowledge_engine/index.rs +++ b/src/knowledge_engine/index.rs @@ -2,25 +2,14 @@ // Copyright (c) 2026 juice094 use crate::registry::RepoEntry; use std::path::PathBuf; +use tantivy::{IndexWriter, schema::Schema}; -fn index_repo_in_search( - repo: &crate::registry::RepoEntry, - summary: &str, - keywords: &str, -) -> anyhow::Result<()> { - let (index, _reader) = crate::search::init_index()?; - let mut writer = crate::search::get_writer(&index)?; - let schema = index.schema(); - crate::search::delete_repo_doc(&mut writer, &schema, &repo.id)?; - crate::search::add_repo_doc(&mut writer, &schema, &repo.id, summary, keywords, &repo.tags)?; - crate::search::commit_writer(&mut writer)?; - Ok(()) -} - -pub fn index_repo( +fn index_repo_core( conn: &mut rusqlite::Connection, repo: &crate::registry::RepoEntry, config: Option<&crate::config::Config>, + writer: &mut IndexWriter, + schema: &Schema, ) -> anyhow::Result<()> { use tracing::{info, warn}; @@ -37,7 +26,9 @@ pub fn index_repo( crate::registry::knowledge::save_summary(conn, &repo.id, &summary, &keywords)?; - if let Err(e) = index_repo_in_search(repo, &summary, &keywords) { + if let Err(e) = crate::search::delete_repo_doc(writer, schema, &repo.id).and_then(|_| { + crate::search::add_repo_doc(writer, schema, &repo.id, &summary, &keywords, &repo.tags) + }) { warn!("Failed to index repo in search: {}", e); } @@ -57,6 +48,33 @@ pub fn index_repo( Ok(()) } +/// Index a single repo with a standalone Tantivy writer. +/// Suitable for one-off indexing where writer reuse is not needed. +pub fn index_repo( + conn: &mut rusqlite::Connection, + repo: &crate::registry::RepoEntry, + config: Option<&crate::config::Config>, +) -> anyhow::Result<()> { + let (index, _reader) = crate::search::init_index()?; + let mut writer = crate::search::get_writer(&index)?; + let schema = index.schema(); + index_repo_core(conn, repo, config, &mut writer, &schema)?; + crate::search::commit_writer(&mut writer)?; + Ok(()) +} + +/// Index a single repo reusing an existing Tantivy writer. +/// Callers must commit the writer after the batch. +pub fn index_repo_with_writer( + conn: &mut rusqlite::Connection, + repo: &crate::registry::RepoEntry, + config: Option<&crate::config::Config>, + writer: &mut IndexWriter, + schema: &Schema, +) -> anyhow::Result<()> { + index_repo_core(conn, repo, config, writer, schema) +} + /// 兼容旧调用的包装层:执行索引逻辑 pub fn run_index( conn: &mut rusqlite::Connection, From 507fc44c8df53f70df336cba4f44ecf2f911adf1 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 22:05:46 +0800 Subject: [PATCH 4/9] docs: module rustdoc + ADR-004/005 + i18n dead_code clarification - Add rustdoc to 7 core modules: i18n, knowledge_engine, workflow, registry, storage, daemon, embedding - Clarify rationale in i18n/mod.rs - Create ADR-004: MCP Tool Layer Trait Decoupling - Create ADR-005: AppContext Clone for Async Context Propagation - Update ADR index with ADR-003/004/005 --- .../adr-004-mcp-trait-decoupling.md | 34 +++++++++++++++++++ docs/architecture/adr-005-appcontext-clone.md | 34 +++++++++++++++++++ docs/architecture/adr-template.md | 3 ++ src/daemon.rs | 7 ++++ src/embedding.rs | 8 +++-- src/i18n/mod.rs | 10 ++++++ src/knowledge_engine/mod.rs | 10 ++++++ src/registry.rs | 7 ++++ src/storage.rs | 8 +++++ src/workflow/mod.rs | 10 ++++++ 10 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr-004-mcp-trait-decoupling.md create mode 100644 docs/architecture/adr-005-appcontext-clone.md diff --git a/docs/architecture/adr-004-mcp-trait-decoupling.md b/docs/architecture/adr-004-mcp-trait-decoupling.md new file mode 100644 index 0000000..b35acec --- /dev/null +++ b/docs/architecture/adr-004-mcp-trait-decoupling.md @@ -0,0 +1,34 @@ +# ADR-004: MCP Tool Layer Trait Decoupling + +- **状态**: accepted +- **日期**: 2026-05-11 +- **作者**: devbase 架构优化会话 + +## 上下文 + +`src/mcp/tools/` 中的 MCP 工具实现直接内联调用 `crate::health::`、`crate::search::`、`crate::registry::` 等底层模块,导致: +- 工具层与业务层硬耦合,无法独立测试 +- `repo.rs` 等文件 `crate::` 内联引用超过 10 处,违反架构红线 +- 新增工具时容易引入隐式依赖 + +## 决策 + +为每个业务领域定义 trait(`ScanClient`、`HealthClient`、`RegistryClient`、`KnowledgeClient`、`SearchClient`、`RepoAnalyzer` 等),由 `AppContext` 统一实现,MCP 工具只依赖 trait。 + +## 后果 + +- **正面**: `repo.rs` `crate::` 引用从 11 降至 8(全部集中在 use 语句);工具层可独立单元测试;新增领域只需扩展 trait +- **负面**: trait 定义与实现分属不同文件,跳转成本略增;简单查询也需 trait 封装 +- **风险**: 过度抽象可能导致 trait 膨胀;需定期审查 trait 方法是否仍被使用 + +## 备选方案 + +| 方案 | 不选原因 | +|------|---------| +| 保持现状,仅清理 use 语句 | 未解决测试隔离问题 | +| 每个工具独立 service struct | 与现有 `AppContext` 模式冲突,引入更多类型 | + +## 相关决策 + +- 依赖:ADR-001(单 crate 模型使 trait 定义零成本) +- 被依赖:ADR-005(AppContext Clone 是 trait 在 spawn_blocking 中使用的前提) diff --git a/docs/architecture/adr-005-appcontext-clone.md b/docs/architecture/adr-005-appcontext-clone.md new file mode 100644 index 0000000..fe867e6 --- /dev/null +++ b/docs/architecture/adr-005-appcontext-clone.md @@ -0,0 +1,34 @@ +# ADR-005: AppContext Clone for Async Context Propagation + +- **状态**: accepted +- **日期**: 2026-05-11 +- **作者**: devbase 架构优化会话 + +## 上下文 + +MCP 工具频繁使用 `tokio::task::spawn_blocking` 执行 I/O 密集型操作(Tantivy 索引、SQLite 查询、文件系统遍历)。此前 `AppContext` 未实现 `Clone`,导致: +- 闭包内无法调用 `ctx.list_vault_notes()` 等 trait 方法 +- 被迫在 `spawn_blocking` 外获取 `conn` 再 move 进闭包,增加生命周期复杂度 +- `VaultClient`、`WorkflowClient` 等 trait 难以在异步上下文中使用 + +## 决策 + +将 `AppContext.env_cache` 从 `std::sync::Mutex` 改为 `Arc>`,并为 `AppContext` 添加 `#[derive(Clone)]`。 + +## 后果 + +- **正面**: `spawn_blocking` 闭包内可直接 `ctx.clone()` 后调用任意 trait 方法;统一 async/sync 边界处理模式 +- **负面**: `Clone` 后多个上下文共享同一 `Mutex`,并发修改 env_cache 的竞争概率微增(当前仅 daemon 定期刷新,可忽略) +- **风险**: 未来若向 `AppContext` 添加非 Clone 字段,需回退到显式字段 clone 模式 + +## 备选方案 + +| 方案 | 不选原因 | +|------|---------| +| 为每个 trait 定义无状态 Impl (ZST) | `RegistryClient` 等方法需 `conn`,无状态 impl 需传入 `Connection`,违反 T11 红线 | +| 使用 `Arc` 包装 | 增加一层间接,所有调用点需改为 `arc.ctx.method()`,改动面过大 | +| 将 `Pool` 单独 clone move 进闭包 | 已在使用,但无法调用 `DigestClient` 等需要 config/i18n 的 trait 方法 | + +## 相关决策 + +- 依赖:ADR-004(trait 化后,clone 成为 spawn_blocking 中使用 trait 的基础设施) diff --git a/docs/architecture/adr-template.md b/docs/architecture/adr-template.md index a10634e..a1f4f25 100644 --- a/docs/architecture/adr-template.md +++ b/docs/architecture/adr-template.md @@ -46,6 +46,9 @@ |------|------|------|------| | ADR-001 | 单 crate 模型(defer split)| accepted | 2026-04-26 | | ADR-002 | Candle CPU BERT 单条编码(batch 回滚)| accepted | 2026-05-04 | +| ADR-003 | Tantivy + SQLite 双写一致性策略 | proposed | 2026-05-11 | +| ADR-004 | MCP Tool Layer Trait Decoupling | accepted | 2026-05-11 | +| ADR-005 | AppContext Clone for Async Context Propagation | accepted | 2026-05-11 | ### ADR-001: 单 crate 模型(defer split) diff --git a/src/daemon.rs b/src/daemon.rs index 20891f6..039ffb7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,5 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Background daemon: periodic health checks, re-indexing, discovery, +//! daily digest generation, and relation graph maintenance. +//! +//! The daemon runs on a configurable schedule (`daemon.interval_seconds`) +//! and uses `tokio::spawn_blocking` for CPU- or I/O-heavy tasks to avoid +//! blocking the async runtime. + use crate::config::Config; use crate::digest::generate_daily_digest; use crate::discovery_engine::{discover_dependencies, discover_similar_projects}; diff --git a/src/embedding.rs b/src/embedding.rs index cc0d6d3..7472121 100644 --- a/src/embedding.rs +++ b/src/embedding.rs @@ -1,7 +1,11 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 -// RE-EXPORT ONLY — 实现已迁移至 devbase-embedding crate. -// 禁止在本文件中添加新代码。 +//! Embedding generation: local Candle-based BERT inference for code symbols. +//! +//! **Re-export only** — implementation lives in the `devbase-embedding` crate. +//! Do not add new code here; extend the extracted crate instead. +//! +//! Feature-gated behind `embedding`; disabled by default to reduce binary size. #[cfg(feature = "embedding")] pub use devbase_embedding::*; diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index f7e6c8b..654d819 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -1,5 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Internationalization (i18n) layer for devbase. +//! +//! Provides language-specific UI strings for TUI, CLI, sync reports, and logs. +//! Supported languages: English (`en`) and Simplified Chinese (`zh_cn`). +//! +//! **Note on `#[allow(dead_code)]`**: Many string fields are accessed only when +//! the `tui` feature is enabled. Without this attribute, compiling without +//! `--features tui` would produce spurious dead-code warnings. The fields are +//! actively used in production builds with the default feature set. + #[derive(Clone, Copy)] #[allow(dead_code)] pub struct I18n { diff --git a/src/knowledge_engine/mod.rs b/src/knowledge_engine/mod.rs index f482e79..c8418ec 100644 --- a/src/knowledge_engine/mod.rs +++ b/src/knowledge_engine/mod.rs @@ -1,5 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Knowledge engine: repository indexing, summary extraction, and module analysis. +//! +//! Orchestrates Tantivy full-text indexing, SQLite registry persistence, +//! semantic code indexing (AST + call graph), and optional embedding generation. +//! +//! Entry points: +//! - [`run_index`] — batch index all registered repos or a single path +//! - [`index_repo`] — index a single repo (standalone writer) +//! - [`index_repo_with_writer`] — index a single repo reusing an existing writer + pub mod fallback; pub mod index; pub mod index_state; diff --git a/src/registry.rs b/src/registry.rs index 1ffaccc..d4967cb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,5 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Registry layer: SQLite-backed entity storage and domain-specific submodules. +//! +//! Central types (`RepoEntry`, `VaultNote`, `PaperEntry`, etc.) and the +//! [`RegistryClient`] trait implementation on [`AppContext`]. +//! Submodules cover repos, health, knowledge, code metrics, call graphs, +//! dead-code analysis, and migrations. + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; diff --git a/src/storage.rs b/src/storage.rs index 4f14713..db90611 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,5 +1,13 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Storage abstraction and application context (`AppContext`). +//! +//! [`StorageBackend`] decouples concrete paths from consumers, enabling +//! test isolation via [`TempStorageBackend`] and future remote backends. +//! [`AppContext`] is the central dependency-injection container: it holds +//! the storage backend, database pool, config, i18n, and environment cache, +//! and implements all MCP client traits (`ScanClient`, `HealthClient`, etc.). + use crate::config::Config; use crate::i18n::{I18n, from_language}; use crate::registry::{ENTITY_TYPE_REPO, WorkspaceRegistry}; diff --git a/src/workflow/mod.rs b/src/workflow/mod.rs index eb534bc..be45150 100644 --- a/src/workflow/mod.rs +++ b/src/workflow/mod.rs @@ -1,5 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +//! Workflow automation engine: YAML-defined multi-step pipelines with parallel +//! execution, variable interpolation, and SQLite-backed execution tracking. +//! +//! A workflow consists of ordered steps, each referencing a registered Skill. +//! The scheduler builds independent batches; the executor runs each batch in +//! parallel while preserving step ordering across batches. +//! +//! Key traits: +//! - [`WorkflowClient`] — MCP-facing API for listing, running, and querying workflows + pub mod executor; pub mod interpolate; pub mod model; From a3fef4cef999698f27dcf1ef7267faa359ef92c5 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 22:28:21 +0800 Subject: [PATCH 5/9] docs: update project facade files (README ecosystem) - CONTRIBUTING.md: update health metrics to v0.15.0 / 427 tests / AGPL-3.0+ - SECURITY.md: update supported version to 0.15.x - Create CODE_OF_CONDUCT.md (Contributor Covenant v2.0) - Create SUPPORT.md (docs, issues, discussions, commercial support) - Create .github/PULL_REQUEST_TEMPLATE.md with checklist - Create .github/ISSUE_TEMPLATE/bug_report.md - Create .github/ISSUE_TEMPLATE/feature_request.md - Create .github/ISSUE_TEMPLATE/config.yml (disable blank issues) --- .github/ISSUE_TEMPLATE/bug_report.md | 31 +++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 26 +++++++++++ CODE_OF_CONDUCT.md | 54 +++++++++++++++++++++++ CONTRIBUTING.md | 6 +-- SECURITY.md | 4 +- SUPPORT.md | 34 ++++++++++++++ 8 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SUPPORT.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4f5580a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve devbase +title: '[BUG] ' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run `...` +2. Click on '...' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment (please complete the following information):** + - OS: [e.g. Windows 11, macOS 14, Ubuntu 22.04] + - devbase version: [output of `devbase --version`] + - Rust version: [output of `rustc --version`] + +**Screenshots / Logs** +If applicable, add screenshots or console output to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..96f6514 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/juice094/devbase/security/advisories/new + about: Please report security issues privately via GitHub Security Advisories. + - name: Question or discussion + url: https://github.com/juice094/devbase/discussions + about: For Q&A, architecture debates, or show-and-tell, use GitHub Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9ebf1ca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for devbase +title: '[Feature] ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Use case** +Who would benefit from this feature? How would they use it? + +**Additional context** +Add any other context, mockups, or references about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6c7a08c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## Summary + + + +## Type of Change + +- [ ] Bug fix (non-breaking) +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation +- [ ] Performance improvement +- [ ] Refactoring (no behavior change) + +## Checklist + +- [ ] `cargo test --all-targets` passes locally +- [ ] `cargo clippy --all-targets -D warnings` passes +- [ ] `cargo fmt --check` passes +- [ ] New code has no production `unwrap`/`expect`/`panic` (test code exempt) +- [ ] Schema changes include migration in `src/registry/migrate.rs` +- [ ] New MCP tools include tests in `src/mcp/tests.rs` +- [ ] README / AGENTS.md updated if user-facing behavior changed + +## Related Issues + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ccd1b92 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,54 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**juice094@protonmail.com**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c4d81a..e4e0cba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,11 +8,11 @@ | 指标 | 状态 | |:---|:---| -| 版本 | v0.8.0 | -| 测试 | 267 passed / 0 failed / 3 ignored | +| 版本 | v0.15.0 | +| 测试 | 427 passed / 0 failed / 3 ignored | | Clippy | `-D warnings` 全绿 | | 生产代码 unwrap | 0 | -| 许可证 | MIT | +| 许可证 | AGPL-3.0-or-later | --- diff --git a/SECURITY.md b/SECURITY.md index c4b61b7..113506a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ The following versions of devbase currently receive security updates: | Version | Supported | | ------- | ------------------ | -| 0.14.x | :white_check_mark: | -| < 0.14 | :x: | +| 0.15.x | :white_check_mark: | +| < 0.15 | :x: | ## Reporting a Vulnerability diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..159f358 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,34 @@ +# Getting Help with devbase + +## Documentation + +- **User Guide**: See [`README.md`](./README.md) for installation, quick start, and feature overview +- **Architecture**: See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for technical design and module boundaries +- **Agent Guidelines**: See [`AGENTS.md`](./AGENTS.md) for MCP tool conventions and schema migration rules +- **Contributing**: See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for build instructions and PR checklist + +## Bug Reports & Feature Requests + +- **Bug Report**: [Open an Issue](https://github.com/juice094/devbase/issues/new) — include devbase version (`devbase --version`), OS, and minimal reproduction steps +- **Feature Request**: [Open an Issue](https://github.com/juice094/devbase/issues/new) with prefix `[Feature]` — describe the use case, not just the solution +- **Security Issue**: See [`SECURITY.md`](./SECURITY.md) for responsible disclosure policy + +## Community + +- **Discussions**: Use [GitHub Discussions](https://github.com/juice094/devbase/discussions) for Q&A, show-and-tell, and architecture debates +- **Issue Tracker**: [GitHub Issues](https://github.com/juice094/devbase/issues) for confirmed bugs and accepted feature requests + +## Commercial Support + +For enterprise deployment, custom integrations, or closed-source licensing inquiries: + +- **Email**: juice094@protonmail.com +- **Subject**: `[devbase Commercial]` + +## Response Time + +| Type | Target Response | +|:---|:---| +| Security vulnerability | 72 hours (see SECURITY.md) | +| Critical bug (crash / data loss) | 7 days | +| Feature request / general question | 14 days | From 3b7446b91a592015a3c13925830e9f7c85d48c61 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 23:34:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?test(knowledge=5Fengine):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20index.rs=20=E4=B8=8E=20index=5Fstate.rs=20=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 SCHEMA_DDL 缺失 repo_index_state 表(测试基础设施) - 将 registry/test_helpers 可见性调整为 pub(跨模块测试共享) - index.rs: 覆盖 prepare_repos (4 场景) + save_repo_index_state (1 场景) - index_state.rs: 覆盖 get_repo_index_state (Missing/Fresh/Stale/Unknown) 与 IndexState 行为方法 (is_fresh, changed_files_count) - 全部 22 项新增测试本地通过,Clippy 零警告,fmt 已检查 --- src/knowledge_engine/index.rs | 120 +++++++++++++++++ src/knowledge_engine/index_state.rs | 196 ++++++++++++++++++++++++++++ src/registry.rs | 2 +- src/registry/test_helpers.rs | 7 + 4 files changed, 324 insertions(+), 1 deletion(-) diff --git a/src/knowledge_engine/index.rs b/src/knowledge_engine/index.rs index 21183b9..db67cd3 100644 --- a/src/knowledge_engine/index.rs +++ b/src/knowledge_engine/index.rs @@ -473,3 +473,123 @@ fn save_repo_index_state( )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::RepoEntry; + use crate::registry::test_helpers::WorkspaceRegistry; + use std::path::Path; + + fn init_git_repo(path: &Path) -> git2::Repository { + let repo = git2::Repository::init(path).unwrap(); + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap(); + drop(tree); + repo + } + + #[test] + fn test_prepare_repos_empty_path_returns_all() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let _ = WorkspaceRegistry::seed_test_repo(&mut conn, "repo1")?; + let _ = WorkspaceRegistry::seed_test_repo(&mut conn, "repo2")?; + + let repos = prepare_repos(&mut conn, "")?; + assert_eq!(repos.len(), 2); + assert!(repos.iter().any(|r| r.id == "repo1")); + assert!(repos.iter().any(|r| r.id == "repo2")); + Ok(()) + } + + #[test] + fn test_prepare_repos_matching_path_returns_one() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("myrepo"); + std::fs::create_dir(&path)?; + + let repo = RepoEntry { + id: "myrepo".to_string(), + local_path: path.clone(), + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + crate::registry::repo::save_repo(&mut conn, &repo)?; + + let repos = prepare_repos(&mut conn, path.to_str().unwrap())?; + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].id, "myrepo"); + Ok(()) + } + + #[test] + fn test_prepare_repos_nonexistent_path_errors() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let result = prepare_repos(&mut conn, "/nonexistent/path/12345"); + assert!(result.is_err()); + Ok(()) + } + + #[test] + fn test_prepare_repos_unregistered_existing_path_auto_registers() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("unregistered"); + std::fs::create_dir(&path)?; + let _ = init_git_repo(&path); + + let repos = prepare_repos(&mut conn, path.to_str().unwrap())?; + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].local_path, path); + // Verify it was saved to registry + let all = crate::registry::repo::list_repos(&conn)?; + assert_eq!(all.len(), 1); + Ok(()) + } + + #[test] + fn test_save_and_get_repo_index_state() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("gitrepo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + save_repo_index_state(&mut conn, "test-repo", &oid.to_string())?; + + let hash: Option = conn + .query_row( + "SELECT last_commit_hash FROM repo_index_state WHERE repo_id = ?1", + ["test-repo"], + |row| row.get(0), + ) + .unwrap_or(None); + assert_eq!(hash, Some(oid.to_string())); + Ok(()) + } +} diff --git a/src/knowledge_engine/index_state.rs b/src/knowledge_engine/index_state.rs index c17686e..4c1edea 100644 --- a/src/knowledge_engine/index_state.rs +++ b/src/knowledge_engine/index_state.rs @@ -140,4 +140,200 @@ mod tests { assert!(json.contains("\"state\":\"unknown\"")); assert!(json.contains("\"reason\":\"x\"")); } + + #[test] + fn test_index_state_is_fresh_and_changed_count() { + assert!(IndexState::Fresh.is_fresh()); + assert!(!IndexState::Missing.is_fresh()); + assert!(!IndexState::Unknown { reason: "err".into() }.is_fresh()); + + let stale = IndexState::Stale { + added: vec!["a.rs".into()], + modified: vec!["b.rs".into(), "c.rs".into()], + deleted: vec![], + }; + assert_eq!(stale.changed_files_count(), 3); + assert_eq!(IndexState::Fresh.changed_files_count(), 0); + assert_eq!(IndexState::Missing.changed_files_count(), 0); + } + + #[test] + fn test_get_repo_index_state_missing() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + let entry = RepoEntry { + id: "missing-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Missing)); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_fresh() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + conn.execute( + "INSERT INTO repo_index_state (repo_id, last_commit_hash, indexed_at) VALUES (?1, ?2, datetime('now'))", + ["fresh-repo", &oid.to_string()], + )?; + + let entry = RepoEntry { + id: "fresh-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Fresh)); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_stale() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + + // First commit + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let old_oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + drop(tree); + + // Save the first commit hash as last indexed + conn.execute( + "INSERT INTO repo_index_state (repo_id, last_commit_hash, indexed_at) VALUES (?1, ?2, datetime('now'))", + ["stale-repo", &old_oid.to_string()], + )?; + + // Second commit so HEAD moves forward and diff_since detects changes + let tree_id2 = { + let mut index = repo.index().unwrap(); + // Add a dummy file to create a new tree + let blob_oid = repo.blob(b"hello")?; + index.add_frombuffer( + &git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: 5, + id: blob_oid, + flags: 0, + flags_extended: 0, + path: b"file.txt".to_vec(), + }, + b"hello", + )?; + index.write_tree().unwrap() + }; + let parent = repo.find_commit(old_oid)?; + let tree2 = repo.find_tree(tree_id2).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "second", &tree2, &[&parent])?; + drop(tree2); + + let entry = RepoEntry { + id: "stale-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Stale { .. }), "expected Stale, got {:?}", state); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_unknown_not_git() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("not-git"); + std::fs::create_dir(&path)?; + + let entry = RepoEntry { + id: "unknown-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Unknown { .. }), "expected Unknown, got {:?}", state); + Ok(()) + } } diff --git a/src/registry.rs b/src/registry.rs index d4967cb..971e610 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -517,7 +517,7 @@ impl crate::clients::RegistryClient for crate::storage::AppContext { } #[cfg(test)] -mod test_helpers; +pub mod test_helpers; #[cfg(test)] mod tests; diff --git a/src/registry/test_helpers.rs b/src/registry/test_helpers.rs index bddce67..ec57e55 100644 --- a/src/registry/test_helpers.rs +++ b/src/registry/test_helpers.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +pub use super::WorkspaceRegistry; use super::*; #[cfg(test)] @@ -355,6 +356,12 @@ CREATE TABLE IF NOT EXISTS orphan_tantivy_docs ( repo_id TEXT PRIMARY KEY, detected_at DATETIME DEFAULT current_timestamp ); + +CREATE TABLE IF NOT EXISTS repo_index_state ( + repo_id TEXT PRIMARY KEY, + last_commit_hash TEXT, + indexed_at DATETIME DEFAULT current_timestamp +); "#; #[cfg(test)] From 69eb364a74fe36bfe13db44e1fb5fa153162073c Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Mon, 11 May 2026 23:34:57 +0800 Subject: [PATCH 7/9] chore: ignore coverage_report.txt --- .gitignore | Bin 734 -> 754 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index f35903d90ee5327bde64da673356e3d95e901948..5539271297c5568a403de0f80f7fdae064c8e9cd 100644 GIT binary patch delta 28 jcmcb|`iXVJJtmRl{Ib-d#PrnoqSS)?q7uE5iV`jWu4D^d delta 7 OcmeywdXII(JthDS`vYqL From 195f648a8df5c422057637bc99d22e2cbba32916 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Tue, 12 May 2026 09:51:47 +0800 Subject: [PATCH 8/9] =?UTF-8?q?ci:=20upgrade=20actions/checkout=20v4=20?= =?UTF-8?q?=E2=86=92=20v6=20(Node.js=2024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions 将在 2026-09-16 移除 Node.js 20 支持。 actions/checkout@v6 基于 Node.js 24,消除 CI deprecation 警告。 - ci.yml: 6 处 checkout@v4 → v6 - release.yml: 1 处 checkout@v4 → v6 --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfa6650..68f9185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Check runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -37,7 +37,7 @@ jobs: name: Test runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -59,7 +59,7 @@ jobs: name: Rustfmt runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -69,7 +69,7 @@ jobs: name: Clippy runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -94,7 +94,7 @@ jobs: name: Security Audit runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -107,7 +107,7 @@ jobs: name: Architecture Invariants runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Run invariant checks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01da81a..f9b3115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: name: Build Windows Release runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: From fabba969e9e07585d8f2387eafc695a5e0a0e8b8 Mon Sep 17 00:00:00 2001 From: juice094 <160722440+juice094@users.noreply.github.com> Date: Tue, 12 May 2026 10:36:56 +0800 Subject: [PATCH 9/9] =?UTF-8?q?test(vault):=20=E8=A1=A5=E9=BD=90=20indexer?= =?UTF-8?q?.rs=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20id=20field=20=E5=88=86=E8=AF=8D=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更 - vault/indexer.rs: 提取 / , 解除对全局 的测试耦合 - 新增 4 项隔离测试(临时 Tantivy 索引,避免 writer 锁冲突): - : 空 notes 时清除旧 vault docs - : 从文件系统读取内容并索引 - : 单条新增 - : 单条更新(delete + add) - search.rs: uid=197609(22414) gid=197609 groups=197609 field 从 → (精确匹配) - 修复生产缺陷: 分词导致 对含连字符/空格的 ID 无法匹配,实际不生效 - Schema mismatch 时 会自动重建索引 ## 验证 - running 425 tests test arxiv::tests::test_parse_arxiv_atom_invalid_xml ... ok test arxiv::tests::test_parse_arxiv_atom_missing_title ... ok test arxiv::tests::test_parse_arxiv_atom_no_authors ... ok test arxiv::tests::test_parse_arxiv_atom_success ... ok test asyncgit::tests::test_async_notification_variants ... ok test asyncgit::tests::test_async_repo_status_clone ... ok test asyncgit::tests::test_async_single_job_new ... ok test asyncgit::tests::test_repo_status_notification_clone ... ok test asyncgit::tests::test_sync_progress_notification_clone ... ok test backup::tests::test_backup_filename_contains_timestamp ... ok test backup::tests::test_backup_filename_format ... ok test backup::tests::test_clean_old_backups_removes_oldest ... ok test backup::tests::test_export_sqlite_creates_file ... ok test config::tests::test_config_custom_values ... ok test config::tests::test_config_default ... ok test config::tests::test_config_empty_uses_defaults ... ok test config::tests::test_config_serialize_roundtrip ... ok test daemon::tests::test_daemon_new ... ok test dependency_graph::tests::test_parse_cargo_toml_deps ... ok test dependency_graph::tests::test_parse_cmake_add_subdirectory_local ... ok test dependency_graph::tests::test_parse_cmake_fetchcontent_declare ... ok test dependency_graph::tests::test_parse_cmake_find_package ... ok test dependency_graph::tests::test_parse_cmake_target_link_libraries ... ok test dependency_graph::tests::test_parse_go_mod_deps ... ok test dependency_graph::tests::test_parse_package_json_deps ... ok test dependency_graph::tests::test_parse_pyproject_toml_deps ... ok test dependency_graph::tests::test_parse_requirements_txt_deps ... ok test digest::tests::test_generate_daily_digest_empty ... ok test digest::tests::test_generate_daily_digest_with_repos ... ok test digest::tests::test_generate_daily_digest_with_unhealthy_repo ... ok test discovery_engine::tests::test_discover_dependencies_cargo ... ok test discovery_engine::tests::test_discover_dependencies_no_manifest ... ok test discovery_engine::tests::test_normalize_dep_name ... ok test health::tests::test_compute_workspace_hash_changes_with_content ... ok test health::tests::test_compute_workspace_hash_empty_dir ... ok test health::tests::test_compute_workspace_hash_ignores_dirs ... ok test health::tests::test_fmt_version_bun ... ok test health::tests::test_fmt_version_cargo ... ok test health::tests::test_fmt_version_cmake ... ok test health::tests::test_fmt_version_docker ... ok test health::tests::test_fmt_version_go ... ok test health::tests::test_fmt_version_java ... ok test health::tests::test_fmt_version_python ... ok test health::tests::test_fmt_version_rustc ... ok test health::tests::test_fmt_version_single_word ... ok test health::tests::test_fmt_version_unknown ... ok test i18n::en::tests::test_build ... ok test i18n::tests::test_en_build ... ok test i18n::tests::test_format_template_basic ... ok test i18n::tests::test_format_template_extra_args_ignored ... ok test i18n::tests::test_format_template_multiple ... ok test i18n::tests::test_format_template_no_placeholder ... ok test i18n::tests::test_from_language_en ... ok test i18n::tests::test_log_strings_loaded_repos ... ok test i18n::tests::test_log_strings_status_fmt ... ok test i18n::tests::test_zh_build ... ok test i18n::zh_cn::tests::test_build ... ok test knowledge_engine::index::tests::test_prepare_repos_empty_path_returns_all ... ok test knowledge_engine::index::tests::test_prepare_repos_matching_path_returns_one ... ok test knowledge_engine::index::tests::test_prepare_repos_nonexistent_path_errors ... ok test knowledge_engine::index::tests::test_prepare_repos_unregistered_existing_path_auto_registers ... ok test knowledge_engine::index::tests::test_save_and_get_repo_index_state ... ok test knowledge_engine::index_state::tests::test_get_repo_index_state_fresh ... ok test knowledge_engine::index_state::tests::test_get_repo_index_state_missing ... ok test knowledge_engine::index_state::tests::test_get_repo_index_state_stale ... ok test knowledge_engine::index_state::tests::test_get_repo_index_state_unknown_not_git ... ok test knowledge_engine::index_state::tests::test_index_state_is_fresh_and_changed_count ... ok test knowledge_engine::index_state::tests::test_index_state_variants_serialize ... ok test knowledge_engine::readme::tests::test_build_llm_prompt_contains_json_instruction ... ok test knowledge_engine::readme::tests::test_extract_module_structure_for_devbase ... ok test knowledge_engine::readme::tests::test_extract_module_structure_non_rust ... ok test knowledge_engine::readme::tests::test_extract_readme_summary_basic ... ok test knowledge_engine::readme::tests::test_extract_readme_summary_truncates_at_sentence ... ok test knowledge_engine::readme::tests::test_extract_readme_summary_with_badges ... ok test knowledge_engine::readme::tests::test_fallback_summary_cargo_toml ... ok test knowledge_engine::readme::tests::test_module_info_clone ... ok test knowledge_engine::readme::tests::test_parse_llm_json_markdown_fenced ... ok test knowledge_engine::readme::tests::test_parse_llm_json_valid ... ok test knowledge_engine::readme::tests::test_real_gitui_repo ... ignored, integration test on real gitui repo test knowledge_engine::readme::tests::test_real_syncthing_repo ... ignored, integration test on real syncthing repo test knowledge_engine::readme::tests::test_try_llm_summary_disabled_returns_none ... ok test mcp::tests::test_destructive_gate_disabled_by_default ... ok test mcp::tests::test_destructive_gate_enabled ... ok test mcp::tests::test_format_mcp_message ... ok test mcp::tests::test_initialize ... ok test mcp::tests::test_nl_filter_repos_empty_query_returns_empty ... ok test mcp::tests::test_nl_filter_repos_fallback_finds_by_language ... ok test mcp::tests::test_nl_filter_repos_tantivy_finds_devbase ... ok test mcp::tests::test_parse_tool_tiers ... ok test mcp::tests::test_parse_tool_tiers_empty ... ok test mcp::tests::test_stdio_content_length_format ... ok test mcp::tests::test_tools_call_devkit_arxiv_fetch ... ok test mcp::tests::test_tools_call_devkit_health ... ok test mcp::tests::test_tools_call_devkit_project_context ... ok test mcp::tests::test_tools_call_devkit_query ... ok test mcp::tests::test_tools_call_devkit_skill_discover ... ok test mcp::tests::test_tools_call_devkit_skill_list ... ok test mcp::tests::test_tools_call_devkit_skill_run ... ignored, requires knowledge-report skill installed and may run external Python process test mcp::tests::test_tools_call_devkit_skill_search ... ok test mcp::tests::test_tools_call_unknown_tool ... ok test mcp::tests::test_tools_list ... ok test mcp::tests::test_unknown_method ... ok test mcp::tools::context::tests::test_collect_hot_files_basic ... ok test mcp::tools::context::tests::test_collect_hot_files_empty_repo ... ok test mcp::tools::context::tests::test_collect_hot_files_no_git ... ok test mcp::tools::context::tests::test_collect_recent_commits_basic ... ok test mcp::tools::context::tests::test_collect_recent_commits_empty_repo ... ok test mcp::tools::context::tests::test_collect_recent_commits_limit ... ok test mcp::tools::context::tests::test_name ... ok test mcp::tools::context::tests::test_schema_is_object ... ok test mcp::tools::known_limit::tests::test_name ... ok test mcp::tools::known_limit::tests::test_schema_is_object ... ok test mcp::tools::oplog::tests::test_name ... ok test mcp::tools::oplog::tests::test_schema_is_object ... ok test mcp::tools::query::tests::test_name ... ok test mcp::tools::query::tests::test_schema_is_object ... ok test mcp::tools::relations::tests::test_relation_query_bidirectional ... ok test mcp::tools::relations::tests::test_relation_store_and_query_roundtrip ... ok test mcp::tools::relations::tests::test_relation_store_missing_required_fields ... ok test mcp::tools::repo::tests::test_extract_tag_from_query ... ok test mcp::tools::repo::tests::test_parse_github_repo_https ... ok test mcp::tools::repo::tests::test_parse_github_repo_invalid ... ok test mcp::tools::repo::tests::test_parse_github_repo_ssh ... ok test mcp::tools::repo::tests::test_parse_stars_condition ... ok test mcp::tools::search::tests::test_parse_f32_array ... ok test mcp::tools::skill::tests::test_name ... ok test mcp::tools::skill::tests::test_schema_is_object ... ok test mcp::tools::tests::test_tool_modules_compile ... ok test mcp::tools::vault::tests::test_resolve_vault_path_absolute_blocked ... ok test mcp::tools::vault::tests::test_resolve_vault_path_dotdot_within_bounds ... ok test mcp::tools::vault::tests::test_resolve_vault_path_empty ... ok test mcp::tools::vault::tests::test_resolve_vault_path_nested ... ok test mcp::tools::vault::tests::test_resolve_vault_path_normal ... ok test mcp::tools::vault::tests::test_resolve_vault_path_performance ... ok test mcp::tools::vault::tests::test_resolve_vault_path_traversal_blocked ... ok test mcp::tools::vault::tests::test_resolve_vault_path_with_dot ... ok test mcp::tools::vault::tests::test_vault_daily_appends_to_existing ... ok test mcp::tools::vault::tests::test_vault_daily_creates_file ... ok test mcp::tools::vault::tests::test_vault_graph_basic ... ok test mcp::tools::vault::tests::test_vault_graph_filtered_by_repo ... ok test mcp::tools::workflow::tests::test_workflow_list_empty_registry ... ok test mcp::tools::workflow::tests::test_workflow_run_not_found ... ok test mcp::tools::workflow::tests::test_workflow_status_invalid_id ... ok test oplog_analytics::tests::test_generate_report_empty_db ... ok test oplog_analytics::tests::test_generate_report_with_data ... ok test query::tests::test_eval_behind_match ... ok test query::tests::test_eval_behind_no_match ... ok test query::tests::test_eval_keyword_match ... ok test query::tests::test_eval_keyword_no_match ... ok test query::tests::test_eval_note_match ... ok test query::tests::test_eval_note_no_match ... ok test query::tests::test_eval_stale_never_synced ... ok test query::tests::test_eval_tag_match ... ok test query::tests::test_eval_tag_no_match ... ok test query::tests::test_parse_cmp_expr_empty ... ok test query::tests::test_parse_cmp_expr_eq_implicit ... ok test query::tests::test_parse_cmp_expr_gt ... ok test query::tests::test_parse_query_behind ... ok test query::tests::test_parse_query_keyword ... ok test query::tests::test_parse_query_lang ... ok test query::tests::test_parse_query_multiple ... ok test query::tests::test_parse_query_note ... ok test query::tests::test_parse_query_stale ... ok test query::tests::test_parse_query_tag ... ok test registry::code_symbols::tests::test_query_code_symbols_by_file ... ok test registry::code_symbols::tests::test_query_code_symbols_by_name ... ok test registry::code_symbols::tests::test_query_code_symbols_by_symbol_type ... ok test registry::code_symbols::tests::test_query_code_symbols_limit ... ok test registry::code_symbols::tests::test_query_code_symbols_no_filter ... ok test registry::dead_code::tests::test_query_dead_code_basic ... ok test registry::dead_code::tests::test_query_dead_code_excludes_called ... ok test registry::dead_code::tests::test_query_dead_code_excludes_main ... ok test registry::dead_code::tests::test_query_dead_code_excludes_pub_when_not_include_pub ... ok test registry::dead_code::tests::test_query_dead_code_excludes_test_attribute ... ok test registry::dead_code::tests::test_query_dead_code_excludes_test_prefix ... ok test registry::dead_code::tests::test_query_dead_code_excludes_tests_rs ... ok test registry::knowledge::tests::test_cross_repo_search_symbols ... ok test registry::knowledge::tests::test_find_papers_by_venue ... ok test registry::knowledge::tests::test_find_related_symbols ... ok test registry::knowledge::tests::test_module_crud ... ok test registry::knowledge::tests::test_paper_roundtrip ... ok test registry::knowledge::tests::test_save_embeddings ... ok test registry::knowledge::tests::test_save_summary_smoke ... ok test registry::knowledge::tests::test_semantic_search_symbols ... ok test registry::knowledge::tests::test_symbol_read_tracking ... ok test registry::knowledge_meta::tests::test_knowledge_meta_crud ... ok test registry::known_limits::tests::test_known_limit_crud ... ok test registry::known_limits::tests::test_list_known_limits_by_category ... ok test registry::links::tests::test_get_linked_repos ... ok test registry::links::tests::test_get_linked_repos_empty ... ok test registry::links::tests::test_get_linked_repos_full ... ok test registry::links::tests::test_get_linked_vault_notes ... ok test registry::links::tests::test_get_linked_vaults ... ok test registry::migrate::tests::test_db_path_format ... ok test registry::migrate::tests::test_workspace_dir_format ... ok test registry::repo::tests::test_list_repos_empty ... ok test registry::repo::tests::test_list_repos_need_index ... ok test registry::repo::tests::test_list_repos_stale_health ... ok test registry::repo::tests::test_list_workspaces_by_tier ... ok test registry::repo::tests::test_save_and_list_repo ... ok test registry::repo::tests::test_save_repo_updates_existing ... ok test registry::repo::tests::test_save_repo_with_remotes ... ok test registry::repo::tests::test_save_repo_with_stars ... ok test registry::repo::tests::test_save_repo_with_tags ... ok test registry::repo::tests::test_update_repo_language ... ok test registry::repo::tests::test_update_repo_last_synced_at ... ok test registry::repo::tests::test_update_repo_tier ... ok test registry::repo::tests::test_update_repo_workspace_type ... ok test registry::repos_toml::tests::test_apply_overrides ... ok test registry::repos_toml::tests::test_parse_repos_toml ... ok test registry::test_helpers::tests::test_in_memory_schema_version ... ok test registry::test_helpers::tests::test_knowledge_meta_table_exists ... ok test registry::test_helpers::tests::test_known_limits_table_exists ... ok test registry::test_helpers::tests::test_workflow_executions_table_exists ... ok test registry::tests::test_dead_code_excludes_pub_variants_and_main ... ok test registry::tests::test_dead_code_include_pub ... ok test registry::tests::test_oplog_event_type_roundtrip ... ok test registry::tests::test_oplog_migration_compat ... ok test registry::tests::test_oplog_save_and_list ... ok test registry::tests::test_primary_remote_fallback_to_first ... ok test registry::tests::test_primary_remote_none ... ok test registry::tests::test_primary_remote_prefers_origin ... ok test registry::tests::test_stars_cache_miss ... ok test registry::tests::test_stars_cache_roundtrip ... ok test registry::tests::test_stars_cache_update ... ok test registry::vault::tests::test_delete_vault_note ... ok test registry::vault::tests::test_list_vault_notes_empty ... ok test registry::vault::tests::test_save_and_list_vault_note ... ok test scan::tests::test_detect_language_cpp ... ok test scan::tests::test_detect_language_go ... ok test scan::tests::test_detect_language_node ... ok test scan::tests::test_detect_language_none ... ok test scan::tests::test_detect_language_python_pyproject ... ok test scan::tests::test_detect_language_python_requirements ... ok test scan::tests::test_detect_language_rust ... ok test scan::tests::test_discover_repos_devbase_ignore ... ok test scan::tests::test_discover_repos_excludes_paths ... ok test scan::tests::test_discover_repos_excludes_patterns ... ok test scan::tests::test_discover_repos_finds_non_git_workspaces ... ok test scan::tests::test_inspect_non_git_workspace_generic ... ok test scan::tests::test_inspect_non_git_workspace_openclaw ... ok test scan::tests::test_is_excluded_path_sync_context ... ok test scan::tests::test_is_nested_submodule_false ... ok test scan::tests::test_is_nested_submodule_true ... ok test scan::tests::test_normal_tags ... ok test scan::tests::test_parse_github_owner_repo_https ... ok test scan::tests::test_parse_github_owner_repo_invalid ... ok test scan::tests::test_parse_github_owner_repo_non_github ... ok test scan::tests::test_parse_github_owner_repo_ssh ... ok test scan::tests::test_zip_snapshot_tags_main ... ok test scan::tests::test_zip_snapshot_tags_master ... ok test search::hybrid::tests::test_hybrid_search_fallback_to_keyword ... ok test search::hybrid::tests::test_keyword_search_basic ... ok test search::hybrid::tests::test_rrf_merge_empty_lists ... ok test search::hybrid::tests::test_rrf_merge_single_list_passthrough ... ok test search::hybrid::tests::test_rrf_merge_two_lists ... ok test search::symbol_index::tests::test_add_and_search_symbol ... ok test search::symbol_index::tests::test_delete_repo_symbols ... ok test search::symbol_index::tests::test_search_signature_match ... ok test search::tests::test_add_and_search_repo ... ok test search::tests::test_add_vault_doc ... ok test search::tests::test_build_schema ... ok test search::tests::test_delete_repo_doc ... ok test search::tests::test_index_is_empty ... ok test search::tests::test_list_indexed_repo_ids ... ok test search::tests::test_search_repos ... ok test search::tests::test_search_vault ... ok test search::tests::test_sync_index_to_db_removes_orphans ... ok test semantic_index::git_diff::tests::test_current_head_hash_after_commit ... ok test semantic_index::git_diff::tests::test_current_head_hash_empty_repo ... ok test semantic_index::git_diff::tests::test_diff_since_deleted_file ... ok test semantic_index::git_diff::tests::test_diff_since_no_changes ... ok test semantic_index::git_diff::tests::test_diff_since_none_first_index ... ok test semantic_index::git_diff::tests::test_diff_since_with_last_hash ... ok test semantic_index::git_diff::tests::test_diff_since_workdir_modification ... ok test semantic_index::git_diff::tests::test_diff_since_workdir_untracked ... ok test semantic_index::symbol::tests::test_extract_rust_attributes ... ok test semantic_index::tests::test_extract_go_const ... ok test semantic_index::tests::test_extract_go_function ... ok test semantic_index::tests::test_extract_go_method ... ok test semantic_index::tests::test_extract_go_struct_and_interface ... ok test semantic_index::tests::test_extract_js_function ... ok test semantic_index::tests::test_extract_multiple_rust ... ok test semantic_index::tests::test_extract_python_class ... ok test semantic_index::tests::test_extract_python_function ... ok test semantic_index::tests::test_extract_python_multiple ... ok test semantic_index::tests::test_extract_rust_function ... ok test semantic_index::tests::test_extract_rust_struct ... ok test semantic_index::tests::test_extract_ts_class_and_interface ... ok test semantic_index::tests::test_extract_ts_enum ... ok test semantic_index::tests::test_index_repo_full ... ok test semantic_index::tests::test_save_calls ... ok test semantic_index::tests::test_save_symbols ... ok test skill_runtime::clarity_sync::tests::test_conflict_resolution_skips_older_devbase_skill ... ok test skill_runtime::clarity_sync::tests::test_conflict_resolution_updates_when_devbase_newer ... ok test skill_runtime::clarity_sync::tests::test_sync_skills_to_clarity ... ok test skill_runtime::dependency::tests::test_detect_cycle_direct ... ok test skill_runtime::dependency::tests::test_detect_cycle_none ... ok test skill_runtime::dependency::tests::test_resolve_cycle_fails ... ok test skill_runtime::dependency::tests::test_resolve_topological_order ... ok test skill_runtime::dependency::tests::test_validate_dependencies_all_satisfied ... ok test skill_runtime::dependency::tests::test_validate_dependencies_missing ... ok test skill_runtime::discover::tests::test_generate_entry_script_node ... ok test skill_runtime::discover::tests::test_generate_entry_script_python ... ok test skill_runtime::discover::tests::test_generate_entry_script_rust ... ok test skill_runtime::discover::tests::test_generate_skill_md_structure ... ok test skill_runtime::executor::tests::test_hard_veto_guard_empty_registry ... ok test skill_runtime::executor::tests::test_hard_veto_guard_with_unresolved_vetoes ... ok test skill_runtime::executor::tests::test_resolve_interpreter_binary ... ok test skill_runtime::executor::tests::test_resolve_interpreter_powershell ... ok test skill_runtime::executor::tests::test_resolve_interpreter_python ... ok test skill_runtime::executor::tests::test_resolve_interpreter_shell ... ok test skill_runtime::executor::tests::test_run_skill_not_found ... ok test skill_runtime::executor::tests::test_run_skill_success ... ok test skill_runtime::publish::tests::test_get_default_remote_fallback ... ok test skill_runtime::publish::tests::test_get_default_remote_origin ... ok test skill_runtime::publish::tests::test_push_tag_no_remote ... ok test skill_runtime::publish::tests::test_push_tag_success_to_bare_remote ... ok test skill_runtime::registry::tests::test_execution_tracking ... ok test skill_runtime::registry::tests::test_install_and_get_skill ... ok test skill_runtime::registry::tests::test_list_skills_by_type ... ok test skill_runtime::registry::tests::test_search_skills_text ... ok test skill_runtime::registry::tests::test_uninstall_skill ... ok test skill_runtime::scoring::tests::test_calculate_scores ... ok test skill_runtime::scoring::tests::test_recalculate_all ... ok test skill_runtime::scoring::tests::test_recommend_skills ... ok test skill_sync::tests::test_convert_to_skill ... ok test skill_sync::tests::test_extract_description_long ... ok test skill_sync::tests::test_extract_description_short ... ok test skill_sync::tests::test_extract_description_skips_heading ... ok test storage::tests::test_app_context_with_temp_storage ... ok test storage::tests::test_repair_tantivy_consistency_detects_orphan ... ok test sync::orchestrator::tests::test_sync_orchestrator_new ... ok test sync::policy::tests::test_classify_sync_error ... ok test sync::policy::tests::test_recommend_sync_action ... ok test sync::policy::tests::test_sync_policy_capabilities ... ok test sync::policy::tests::test_sync_policy_from_tags ... ok test sync::tasks::tests::test_map_action_known ... ok test sync::tasks::tests::test_map_action_unknown ... ok test sync::tasks::tests::test_write_syncdone_marker ... ok test sync::tests::test_assess_safety_blocked_dirty ... ok test sync::tests::test_assess_safety_blocked_diverged_conservative ... ok test sync::tests::test_assess_safety_diverged_rebase_allowed ... ok test sync::tests::test_assess_safety_no_upstream ... ok test sync::tests::test_assess_safety_safe_ff ... ok test sync::tests::test_assess_safety_up_to_date ... ok test sync::tests::test_collect_tasks_default_mode_excludes_untagged ... ok test sync::tests::test_collect_tasks_default_mode_includes_known_tags ... ok test sync::tests::test_collect_tasks_explicit_filter_includes_untagged ... ok test sync::tests::test_map_action ... ok test sync::tests::test_perform_merge_fast_forward ... ok test sync::tests::test_perform_merge_up_to_date ... ok test sync::tests::test_sync_repo_skip_no_syncdone ... ok test sync::tests::test_write_syncdone_marker ... ok test tui::event::tests::test_tui_action_variants ... ok test tui::layout::tests::test_layout_centered ... ok test tui::layout::tests::test_layout_compact ... ok test tui::layout::tests::test_layout_inner ... ok test tui::layout::tests::test_layout_standard ... ok test tui::layout::tests::test_layout_wide ... ok test tui::render::detail::tests::test_overview_status_desc ... ok test tui::render::detail::tests::test_overview_status_icon ... ok test tui::render::detail::tests::test_sync_policy_color ... ok test tui::render::help::tests::test_help_section_empty ... ok test tui::render::help::tests::test_help_section_with_bindings ... ok test tui::render::list::tests::test_repo_status_fg ... ok test tui::render::list::tests::test_repo_status_icon ... ok test tui::render::logs::tests::test_format_log_line_plain ... ok test tui::render::logs::tests::test_format_log_line_with_timestamp ... ok test tui::render::popups::tests::test_search_results_title_empty ... ok test tui::render::popups::tests::test_search_results_title_with_results ... ok test tui::render::tests::test_read_repo_summary_found ... ok test tui::render::tests::test_read_repo_summary_missing ... ok test tui::render::tests::test_read_syncdone_info_missing ... ok test tui::render::tests::test_read_syncdone_info_valid ... ok test tui::state::tests::test_run_nlp_selected_skill_empty_results ... ok test tui::tests::test_detail_tab_label ... ok test tui::tests::test_main_view_toggle ... ok test tui::tests::test_search_mode_label ... ok test tui::tests::test_sort_mode_toggle ... ok test tui::theme::tests::test_styles_from_dark_theme ... ok test tui::theme::tests::test_theme_dark_colors ... ok test tui::theme::tests::test_theme_default_is_dark ... ok test tui::theme::tests::test_theme_light_colors ... ok test vault::backlinks::tests::test_backlink_index_basic ... ok test vault::fs_io::tests::test_read_note_body_with_frontmatter ... ok test vault::fs_io::tests::test_read_note_body_without_frontmatter ... ok test vault::fs_io::tests::test_read_note_content ... ok test vault::fs_io::tests::test_read_note_content_missing ... ok test vault::indexer::tests::test_index_vault_note_core_add ... ok test vault::indexer::tests::test_index_vault_note_core_update ... ok test vault::indexer::tests::test_reindex_vault_core_empty ... ok test vault::indexer::tests::test_reindex_vault_core_with_notes ... ok test vault::scanner::tests::test_scan_vault_basic ... ok test vault::scanner::tests::test_scan_vault_empty_dir ... ok test vault::scanner::tests::test_scan_vault_missing_dir ... ok test watch::tests::test_folder_scheduler_degrades_to_scan ... ok test watch::tests::test_folder_scheduler_first_run ... ok test watch::tests::test_folder_scheduler_incremental_no_change ... ok test watch::tests::test_watch_aggregator_dedup ... ok test workflow::executor::tests::test_condition_step_false ... ok test workflow::executor::tests::test_condition_step_true ... ok test workflow::executor::tests::test_loop_body_continue ... ok test workflow::executor::tests::test_loop_body_fallback ... ok test workflow::executor::tests::test_loop_empty_collection ... ok test workflow::executor::tests::test_loop_failure ... ok test workflow::executor::tests::test_loop_multi_iteration ... ok test workflow::executor::tests::test_loop_single_iteration ... ok test workflow::executor::tests::test_parallel_step ... ok test workflow::executor::tests::test_subworkflow_step ... ok test workflow::parser::tests::test_parse_basic_workflow ... ok test workflow::parser::tests::test_parse_invalid_id ... ok test workflow::scheduler::tests::test_linear_schedule ... ok test workflow::scheduler::tests::test_parallel_schedule ... ok test workflow::scheduler::tests::test_transitive_deps ... ok test workflow::scheduler::tests::test_transitive_deps_leaf ... ok test workflow::state::tests::test_create_and_update_execution ... ok test workflow::state::tests::test_end_to_end_workflow_lifecycle ... ok test workflow::state::tests::test_save_and_get_workflow ... ok test workflow::validator::tests::test_cycle_detected ... ok test workflow::validator::tests::test_loop_body_duplicate_global_id ... ok test workflow::validator::tests::test_loop_body_missing_dep ... ok test workflow::validator::tests::test_loop_body_valid ... ok test workflow::validator::tests::test_missing_dep ... ok test workflow::validator::tests::test_valid_dag ... ok test result: ok. 422 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 10.73s running 7 tests test commands::limit::tests::test_run_limit_delete_not_found ... ok test commands::limit::tests::test_run_limit_seed_and_list_json ... ok test commands::simple::tests::test_run_vault_list_empty ... ok test commands::skill::tests::test_run_skill_list_empty ... ok test commands::skill::tests::test_run_skill_uninstall_not_found ... ok test commands::workflow::tests::test_run_workflow_delete_not_found ... ok test commands::workflow::tests::test_run_workflow_list_empty ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s running 11 tests test test_backup_export ... ok test test_health_empty_registry ... ok test test_limit_add_and_list ... ok test test_limit_list_empty ... ok test test_registry_backups_empty ... ok test test_scan_git_repo ... ok test test_skill_discover ... ok test test_skill_list_empty ... ok test test_sync_skips_unmanaged_repo ... ok test test_tag_enables_sync ... ok test test_version ... ok test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.48s Testing save_repo Success Testing list_repos_500 Success Testing get_health Success Testing format_mcp_message Success Testing index_repo_full/scale/small Success Testing index_repo_full/scale/medium Success Testing index_repo_full/scale/full Success Testing cosine_similarity/dim/128 Success Testing cosine_similarity/dim/512 Success Testing cosine_similarity/dim/768 Success Testing extract_symbols/lang/rust Success Testing extract_symbols/lang/python Success Testing extract_symbols/lang/go Success Testing parse_cmake_lists/complex Success running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 440 passed / 0 failed - 零警告 - 通过 --- src/search.rs | 4 +- src/vault/indexer.rs | 175 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/search.rs b/src/search.rs index 3468434..0c10485 100644 --- a/src/search.rs +++ b/src/search.rs @@ -11,7 +11,7 @@ use tantivy::{ Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, TantivyError, collector::TopDocs, query::{BooleanQuery, Occur, QueryParser, TermQuery}, - schema::{STORED, Schema, TEXT, Value}, + schema::{STORED, STRING, Schema, TEXT, Value}, }; const INDEX_DIR: &str = "devbase/search_index"; @@ -24,7 +24,7 @@ fn index_path() -> Result { fn build_schema() -> Schema { let mut schema_builder = Schema::builder(); - schema_builder.add_text_field("id", TEXT | STORED); + schema_builder.add_text_field("id", STRING | STORED); schema_builder.add_text_field("title", TEXT | STORED); schema_builder.add_text_field("content", TEXT); schema_builder.add_text_field("tags", TEXT); diff --git a/src/vault/indexer.rs b/src/vault/indexer.rs index cb87690..cc747bb 100644 --- a/src/vault/indexer.rs +++ b/src/vault/indexer.rs @@ -2,6 +2,8 @@ // Copyright (c) 2026 juice094 use crate::search; use crate::vault::fs_io; +use tantivy::IndexWriter; +use tantivy::schema::Schema; use tracing::info; /// Index all vault notes from the registry into Tantivy. @@ -17,23 +19,28 @@ pub fn reindex_vault(conn: &rusqlite::Connection) -> anyhow::Result<()> { let (index, _reader) = search::init_index()?; let mut writer = search::get_writer(&index)?; let schema = index.schema(); + reindex_vault_core(¬es, &mut writer, &schema) +} +fn reindex_vault_core( + notes: &[crate::registry::VaultNote], + writer: &mut IndexWriter, + schema: &Schema, +) -> anyhow::Result<()> { // Delete all existing vault docs let doc_type = schema.get_field("doc_type")?; let term = tantivy::Term::from_field_text(doc_type, "vault"); writer.delete_term(term); let mut indexed = 0; - for note in ¬es { + for note in notes { let title = note.title.as_deref().unwrap_or(¬e.id); let tags: Vec = note.tags.clone(); // P1-1: read content from filesystem; fallback to empty string if unreadable let content = fs_io::read_note_body(¬e.path).map(|(body, _fm)| body).unwrap_or_default(); - if let Err(e) = - search::add_vault_doc(&mut writer, &schema, ¬e.id, title, &content, &tags) - { + if let Err(e) = search::add_vault_doc(writer, schema, ¬e.id, title, &content, &tags) { tracing::warn!("Failed to index vault note {}: {}", note.id, e); } else { indexed += 1; @@ -50,13 +57,20 @@ pub fn index_vault_note(note: &crate::registry::VaultNote) -> anyhow::Result<()> let (index, _reader) = search::init_index()?; let mut writer = search::get_writer(&index)?; let schema = index.schema(); + index_vault_note_core(note, &mut writer, &schema) +} +fn index_vault_note_core( + note: &crate::registry::VaultNote, + writer: &mut IndexWriter, + schema: &Schema, +) -> anyhow::Result<()> { // Delete old doc by id let id_field = schema.get_field("id")?; writer.delete_term(tantivy::Term::from_field_text(id_field, ¬e.id)); let title = note.title.as_deref().unwrap_or(¬e.id); - search::add_vault_doc(&mut writer, &schema, ¬e.id, title, ¬e.content, ¬e.tags)?; + search::add_vault_doc(writer, schema, ¬e.id, title, ¬e.content, ¬e.tags)?; writer.commit()?; Ok(()) } @@ -64,23 +78,154 @@ pub fn index_vault_note(note: &crate::registry::VaultNote) -> anyhow::Result<()> #[cfg(test)] mod tests { use super::*; + use crate::registry::VaultNote; + use std::io::Write; + + fn init_isolated_index() + -> (tempfile::TempDir, tantivy::Index, tantivy::IndexWriter, tantivy::schema::Schema) { + let tmp = tempfile::tempdir().unwrap(); + let (index, _reader) = search::init_index_at(tmp.path()).unwrap(); + let writer = search::get_writer(&index).unwrap(); + let schema = index.schema(); + (tmp, index, writer, schema) + } + + #[test] + fn test_reindex_vault_core_empty() { + let (_tmp, _index, mut writer, schema) = init_isolated_index(); + // Seed a dummy vault doc so we can verify deletion works + search::add_vault_doc(&mut writer, &schema, "dummy", "Dummy", "content", &[]).unwrap(); + writer.commit().unwrap(); + + let notes: Vec = vec![]; + reindex_vault_core(¬es, &mut writer, &schema).unwrap(); + + // After reindex with empty notes, vault docs should be gone + let reader = _index.reader().unwrap(); + let searcher = reader.searcher(); + let doc_type = schema.get_field("doc_type").unwrap(); + let term = tantivy::Term::from_field_text(doc_type, "vault"); + let count = searcher + .search( + &tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic), + &tantivy::collector::Count, + ) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_reindex_vault_core_with_notes() { + let tmp = tempfile::tempdir().unwrap(); + let md_path = tmp.path().join("note.md"); + let mut file = std::fs::File::create(&md_path).unwrap(); + writeln!(file, "# Hello\n\nThis is a test note.").unwrap(); + drop(file); + + let note = VaultNote { + id: "note-1".to_string(), + path: md_path.to_str().unwrap().to_string(), + title: Some("Hello".to_string()), + content: "ignored".to_string(), + frontmatter: None, + tags: vec!["tag1".to_string()], + outgoing_links: vec![], + linked_repo: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let (_tmp, _index, mut writer, schema) = init_isolated_index(); + reindex_vault_core(&[note], &mut writer, &schema).unwrap(); + + // Verify the note was indexed by searching + let reader = _index.reader().unwrap(); + let searcher = reader.searcher(); + let doc_type = schema.get_field("doc_type").unwrap(); + let term = tantivy::Term::from_field_text(doc_type, "vault"); + let count = searcher + .search( + &tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic), + &tantivy::collector::Count, + ) + .unwrap(); + assert_eq!(count, 1); + } #[test] - fn test_index_vault_note_smoke() { - let note = crate::registry::VaultNote { - id: "test-note".to_string(), - path: "/tmp/test.md".to_string(), - title: Some("Test".to_string()), - content: "Hello world".to_string(), + fn test_index_vault_note_core_add() { + let note = VaultNote { + id: "note-add".to_string(), + path: "/tmp/add.md".to_string(), + title: Some("Add".to_string()), + content: "new content".to_string(), frontmatter: None, - tags: vec!["test".to_string()], + tags: vec!["add".to_string()], outgoing_links: vec![], linked_repo: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; - // This may fail if Tantivy index is locked by another test; - // we only verify it does not panic. - let _ = index_vault_note(¬e); + + let (_tmp, _index, mut writer, schema) = init_isolated_index(); + index_vault_note_core(¬e, &mut writer, &schema).unwrap(); + + let reader = _index.reader().unwrap(); + let searcher = reader.searcher(); + let doc_type = schema.get_field("doc_type").unwrap(); + let term = tantivy::Term::from_field_text(doc_type, "vault"); + let count = searcher + .search( + &tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic), + &tantivy::collector::Count, + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_index_vault_note_core_update() { + let note = VaultNote { + id: "note-update".to_string(), + path: "/tmp/update.md".to_string(), + title: Some("Original".to_string()), + content: "original content".to_string(), + frontmatter: None, + tags: vec![], + outgoing_links: vec![], + linked_repo: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let (_tmp, _index, mut writer, schema) = init_isolated_index(); + index_vault_note_core(¬e, &mut writer, &schema).unwrap(); + + let updated = VaultNote { + id: "note-update".to_string(), + path: "/tmp/update.md".to_string(), + title: Some("Updated".to_string()), + content: "updated content".to_string(), + frontmatter: None, + tags: vec!["new-tag".to_string()], + outgoing_links: vec![], + linked_repo: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + index_vault_note_core(&updated, &mut writer, &schema).unwrap(); + + // Tantivy delete + add semantics: old doc replaced, only 1 doc remains + let reader = _index.reader().unwrap(); + let searcher = reader.searcher(); + let doc_type = schema.get_field("doc_type").unwrap(); + let term = tantivy::Term::from_field_text(doc_type, "vault"); + let count = searcher + .search( + &tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic), + &tantivy::collector::Count, + ) + .unwrap(); + assert_eq!(count, 1); } }