Files
mnote/rust/crates/mnote-web/src/routes/search.rs
T
lix-2026 5f97800489 chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes

- preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs

- refresh design governance docs, Reasonix task templates, and bug records

- retire root .mcp.json local MCP config
2026-05-23 23:38:42 +08:00

958 lines
34 KiB
Rust

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_convex,
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsRequest {
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub query: Option<String>,
pub document_id: Option<String>,
pub limit: Option<u32>,
pub filters: Option<SearchDocumentsFilters>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsFilters {
pub title_only: Option<bool>,
pub exact: Option<bool>,
pub include_ocr: Option<bool>,
pub only_current_page: Option<bool>,
pub time_range: Option<String>,
pub time_field: Option<String>,
pub custom_range: Option<SearchCustomRange>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchCustomRange {
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchShellQuery {
pub workspace_id: Option<String>,
pub q: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSearchIndexRefreshRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSearchIndexQuery {
pub workspace_id: Option<String>,
pub root_uri: String,
pub document_id: Option<String>,
}
pub async fn shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<SearchShellQuery>,
) -> Result<Response, WebError> {
let workspace_id = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
let initial_results = load_search_results(
state.config(),
&context,
workspace_id,
search_query,
None,
20,
)
.await
.unwrap_or_else(|_| empty_search_projection());
let contract = json!({
"schema": "mnote.search_shell.v1",
"owner": "mnote-web",
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"shell": "search",
"workspaceId": workspace_id,
"query": search_query,
"initialResults": {
"queryName": "search.documents.query",
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![]))
},
"island": {
"kind": "search_interaction_island",
"mountId": "mnote-search-island",
"runtime": "SearchPaletteHost"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id
});
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
let body_content = crate::ssr::render_view(leptos::view! {
<SearchPage
workspace_id={workspace_id.to_string()}
search_query={search_query.to_string()}
sidebar_tree_html={sidebar_tree_html}
initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))}
/>
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>搜索</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
{}
<script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
body_content,
escape_script_json(&contract_json),
);
let mut response = Html(html).into_response();
stamp_search_headers(response.headers_mut());
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") {
response
.headers_mut()
.insert(name, HeaderValue::from_static("search"));
}
Ok(response)
}
pub async fn documents(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<SearchDocumentsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let filters = body.filters.unwrap_or_default();
let normalized_query = body.query.unwrap_or_default().trim().to_string();
let page_id = if filters.only_current_page.unwrap_or(false) {
body.document_id.filter(|value| !value.trim().is_empty())
} else {
None
};
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
local_search_index::query_local_search_index(
&root_path,
root_uri,
&effective_workspace_id,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?
};
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
"meta": {
"owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
},
})),
))
}
pub async fn refresh_local_index(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<LocalSearchIndexRefreshRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let root_uri = body.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_search_root_required",
"本地索引刷新缺少 rootUri",
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let refreshed = local_search_index::refresh_local_search_index(
&root_path,
root_uri,
&effective_workspace_id,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"index": refreshed,
"meta": {
"owner": "mnote-web",
"projectionOwner": "rust-kernel",
"queryName": "search.local_index.refresh",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}
})),
))
}
pub async fn local_index_backlinks(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let root_uri = query.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_search_root_required",
"本地反链查询缺少 rootUri",
)
.with_context(&context));
}
let document_id = query
.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"local_search_document_required",
"本地反链查询缺少 documentId",
)
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let backlinks = local_search_index::query_local_backlinks(
&root_path,
root_uri,
&effective_workspace_id,
document_id,
)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"result": backlinks,
"meta": {
"owner": "mnote-web",
"projectionOwner": "rust-kernel",
"queryName": "search.local_index.backlinks",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}
})),
))
}
pub async fn local_index_tags(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let root_uri = query.root_uri.trim();
if root_uri.is_empty() {
return Err(WebError::bad_request_code(
"local_search_root_required",
"本地标签查询缺少 rootUri",
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"result": tags,
"meta": {
"owner": "mnote-web",
"projectionOwner": "rust-kernel",
"queryName": "search.local_index.tags",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}
})),
))
}
async fn load_search_results(
config: &crate::app::AppConfig,
context: &RequestContext,
workspace_id: &str,
query: &str,
page_id: Option<String>,
limit: u32,
) -> Result<Value, WebError> {
load_search_results_with_filters(
config,
context,
workspace_id,
query,
page_id,
limit,
SearchDocumentsFilters::default(),
)
.await
}
async fn load_search_results_with_filters(
config: &crate::app::AppConfig,
context: &RequestContext,
workspace_id: &str,
query: &str,
page_id: Option<String>,
limit: u32,
filters: SearchDocumentsFilters,
) -> Result<Value, WebError> {
if query.trim().is_empty() {
return Ok(empty_search_projection());
}
let runtime_query = RuntimeQueryEnvelopeWire {
name: "search.documents.query".into(),
payload: json!({
"query": query,
"workspaceId": workspace_id,
"pageId": page_id,
"limit": limit,
"titleOnly": filters.title_only.unwrap_or(false),
"exact": filters.exact.unwrap_or(false),
"includeOcr": filters.include_ocr.unwrap_or(false),
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
}),
};
match execute_runtime_query_via_convex(
config,
context,
Some(workspace_id),
runtime_query.clone(),
)
.await
{
Ok(value) => Ok(value),
Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
context,
Some(workspace_id),
runtime_query,
fallback_search_dataset(workspace_id),
),
Err(error) => Ok(degraded_empty_search_projection(error.message())),
}
}
fn empty_search_projection() -> Value {
json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
"results": [],
})
}
fn degraded_empty_search_projection(reason: &str) -> Value {
json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
"results": [],
"degraded": true,
"degradedReason": reason,
})
}
fn fallback_search_dataset(workspace_id: &str) -> Value {
json!({
"documents": [
{
"id": "doc_1",
"workspaceId": workspace_id,
"title": "Rust Web 搜索结果",
"rawText": "mnote-web search documents transport",
"createdAt": "2026-04-28T00:00:00Z",
"updatedAt": "2026-04-28T00:00:00Z"
},
{
"id": "doc_hermes",
"workspaceId": workspace_id,
"title": "Hermes",
"rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
},
{
"id": "doc_hermes_skill",
"workspaceId": workspace_id,
"title": "技能知识图谱开发",
"rawText": "Hermes 页面路径 个人 软件开发",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
],
"mindmaps": [],
"tables": [],
"tableRows": [],
"assets": []
})
}
fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
let Some(results) = results else {
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
};
if results.is_empty() {
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
}
let items = results
.iter()
.map(|item| {
let title = item
.get("title")
.and_then(Value::as_str)
.unwrap_or("无标题");
let snippet = item
.get("snippet")
.and_then(Value::as_str)
.unwrap_or_default();
let href = item
.get("publicPath")
.and_then(Value::as_str)
.unwrap_or("#");
format!(
r#"<a class="search-result" data-search-result-owner="rust-kernel" href="{}"><strong>{}</strong><span>{}</span></a>"#,
escape_html(href),
escape_html(title),
escape_html(snippet)
)
})
.collect::<Vec<_>>()
.join("");
format!(r#"<div class="search-result-list">{items}</div>"#)
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn stamp_search_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_QUERY_NAME.as_bytes()) {
headers.insert(name, HeaderValue::from_static("search.documents"));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
fn query_escape(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
#[tokio::test]
async fn search_shell_returns_server_first_island_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/search?workspaceId=ws_demo&q=Rust")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("search")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-mnote-shell=\"search\""));
assert!(html.contains("mnote.search_shell.v1"));
assert!(html.contains("search_interaction_island"));
assert!(html.contains("search.documents.query"));
assert!(html.contains("search-result"));
assert!(html.contains("Rust Web 搜索结果"));
assert!(html.contains("data-mnote-dev-fixture=\"true\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
}
#[tokio::test]
async fn search_documents_route_is_owned_by_mnote_web() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Rust Web",
"filters": {
"titleOnly": false,
"exact": false,
"includeOcr": false,
"onlyCurrentPage": false,
"timeRange": "any",
"timeField": "updated"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-query-name")
.and_then(|value| value.to_str().ok()),
Some("search.documents")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["meta"]["owner"], "mnote-web");
assert_eq!(payload["meta"]["queryName"], "search.documents.query");
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
assert_eq!(payload["projectionOwner"], "rust-kernel");
}
#[tokio::test]
async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
let app = build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Hermes"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["results"], json!([]));
assert_eq!(payload["meta"]["degraded"], true);
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
}
#[tokio::test]
async fn search_documents_local_folder_uses_authorized_root_index() {
let root =
std::env::temp_dir().join(format!("mnote-local-search-route-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-search","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("README.md"),
"---\ntitle: Search Home\ntags: [alpha]\n---\n# Search Home\nAlpha body links [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n",
)
.expect("readme");
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-search",
"sourceKind": "local_folder",
"rootUri": root_uri,
"query": "alpha",
"limit": 10
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["meta"]["degraded"], false);
let results = payload["results"].as_array().expect("results");
let home = results
.iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result");
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
assert!(payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_local_index_refresh_rebuilds_authorized_root() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-refresh-route-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-refresh","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/local-index/refresh")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-refresh",
"rootUri": root_uri
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["index"]["documentCount"], 1);
assert_eq!(
payload["meta"]["queryName"].as_str(),
Some("search.local_index.refresh")
);
let index = fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
.expect("index");
assert!(index.contains("refresh-token"));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-backlinks-route-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-backlinks","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("README.md"),
"---\ntitle: Home\ntags: [alpha]\n---\n# Home\nSee [Child](docs/child.md).\n",
)
.expect("home");
fs::write(
root.join("docs").join("child.md"),
"---\ntitle: Child\ntags: [alpha, beta]\n---\n# Child\n",
)
.expect("child");
let root_uri = format!("file://{}", root.display());
let encoded_root = query_escape(&root_uri);
let backlinks_response = app()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/api/search/local-index/backlinks?workspaceId=local-ws-backlinks&rootUri={encoded_root}&documentId=local-md:docs~2Fchild.md"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("backlinks response");
assert_eq!(backlinks_response.status(), StatusCode::OK);
let backlinks_body = to_bytes(backlinks_response.into_body(), usize::MAX)
.await
.expect("backlinks body");
let backlinks_payload: Value = serde_json::from_slice(&backlinks_body).expect("json");
assert_eq!(
backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks")
);
assert!(backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let tags_response = app()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/api/search/local-index/tags?workspaceId=local-ws-backlinks&rootUri={encoded_root}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("tags response");
assert_eq!(tags_response.status(), StatusCode::OK);
let tags_body = to_bytes(tags_response.into_body(), usize::MAX)
.await
.expect("tags body");
let tags_payload: Value = serde_json::from_slice(&tags_body).expect("json");
assert!(tags_payload["result"]["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag["tag"].as_str() == Some("alpha") && tag["count"].as_u64() == Some(2)));
let _ = fs::remove_dir_all(&root);
}
}