use crate::app::AppState; use crate::context::RequestContext; use crate::document_buffer_store::{self, BufferKey, BufferStore}; use crate::error::WebError; use crate::routes::command_support::{ execute_runtime_command_via_legacy_cloud, execute_runtime_command_via_legacy_cloud_with_artifacts, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body, }; use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id, }; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; use serde_json::{json, Value}; use std::fs; use std::time::Duration; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentContentQuery { pub document_id: String, pub workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentMetaQuery { pub document_id: String, pub workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BufferStateQuery { pub document_id: Option, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub relative_path: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BufferDirtyRequest { pub document_id: String, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub relative_path: Option, pub content_hash: Option, } fn find_document_buffer_state( buffer_store: &BufferStore, query: &BufferStateQuery, ) -> Option { if let (Some(root_uri), Some(_source_kind), Some(document_id)) = ( query.root_uri.as_deref().filter(|v| !v.is_empty()), query.source_kind.as_deref().filter(|v| !v.is_empty()), query.document_id.as_deref().filter(|v| !v.is_empty()), ) { // 本地文件夹查询优先用明确 relativePath;缺省时退回扫描,避免前端只知道 documentId 时查不到已初始化 buffer。 if let Some(relative_path) = query.relative_path.as_deref().filter(|v| !v.is_empty()) { let ws_path = document_buffer_store::build_local_folder_workspace_path( query.workspace_id.as_deref().unwrap_or(""), root_uri, relative_path, document_id, ); if let Some(buffer) = buffer_store.get_by_path(&ws_path) { return Some(buffer); } return buffer_store.all_buffers().into_iter().find(|buf| { buf.workspace_path.root_uri == root_uri && buf.workspace_path.relative_path == relative_path && buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id) }); } return buffer_store.all_buffers().into_iter().find(|buf| { buf.workspace_path.root_uri == root_uri && buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id) }); } if let (Some(document_id), Some(workspace_id), Some(relative_path)) = ( query.document_id.as_deref().filter(|v| !v.is_empty()), query.workspace_id.as_deref().filter(|v| !v.is_empty()), query.relative_path.as_deref().filter(|v| !v.is_empty()), ) { // 通过完整 workspace path 精确查询。 let key = BufferKey::from_parts( workspace_id, "LocalFolder", "", relative_path, Some(document_id.to_string()), ); return buffer_store.get(&key); } if let Some(document_id) = query.document_id.as_deref().filter(|v| !v.is_empty()) { // 最后按 documentId 扫描,作为调试和兼容查询兜底。 return buffer_store.all_buffers().into_iter().find(|buf| { buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id) }); } None } fn buffer_state_payload(buf: core_protocol::DocumentBuffer) -> Value { json!({ "documentId": buf.workspace_path.object_identity.document_id, "workspaceId": buf.workspace_path.workspace_id, "dirtyState": format!("{:?}", buf.dirty_state), "fileVersion": buf.file_version, "baseContentHash": buf.base_content_hash, "currentContentHash": buf.current_content_hash, "externalActor": buf.external_actor, "lastLoadedAt": buf.last_loaded_at, "lastSavedAt": buf.last_saved_at, }) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentSaveRequest { pub document_id: String, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub revision: Option, pub conflict_detection_key: Option, pub expected_file_version: Option, pub write_intent_id: Option, pub save_operation_id: Option, pub base_content_hash: Option, pub content_format: Option, pub editor_source: Option, pub editor_document: Option, pub content: Value, pub tiptap_document: Option, pub snapshot_captured_at: Option, pub block_count: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentTitleRequest { pub document_id: String, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, pub title: String, pub command_name: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentOptionsRequest { pub document_id: String, pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, #[serde(default)] pub options: Value, pub command_name: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentPurgeRequest { pub document_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentEmptyTrashRequest { pub workspace_id: String, } const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport"; fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json) { let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); ( StatusCode::OK, headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "result": result, })), ) } fn execution_artifacts_json( execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution, ) -> Value { execution .artifacts .as_ref() .and_then(|artifacts| serde_json::to_value(artifacts).ok()) .unwrap_or(Value::Null) } fn stamp_documents_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_DOCUMENTS_TRANSPORT.as_bytes()) { headers.insert(name, HeaderValue::from_static("documents-api")); } } fn read_env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = fs::read_to_string(root).ok()?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } let Some((k, v)) = line.split_once('=') else { continue; }; if k.trim() != key { continue; } let trimmed = v.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } fn next_documents_base_url() -> String { read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV) .unwrap_or_else(|| "http://127.0.0.1:3000".into()) .trim() .trim_end_matches('/') .to_string() } fn should_proxy_via_next(_context: &RequestContext) -> bool { false } fn build_next_proxy_headers( context: &RequestContext, effective_workspace_id: Option<&str>, ) -> reqwest::header::HeaderMap { use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) { let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else { return; }; let Ok(header_value) = HeaderValue::from_str(value) else { return; }; headers.insert(header_name, header_value); } let mut headers = HeaderMap::new(); if let Some(cookie) = context.auth.cookie_header.as_deref() { insert(&mut headers, "cookie", cookie); } if let Some(authorization) = context.auth.authorization.as_deref() { insert(&mut headers, "authorization", authorization); } insert(&mut headers, "x-request-id", &context.trace.request_id); insert(&mut headers, "x-trace-id", &context.trace.trace_id); insert( &mut headers, "x-mnote-source-channel", &context.source.channel, ); insert( &mut headers, "x-mnote-source-client", &context.source.client, ); insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id); insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type); if let Some(session_id) = context.auth.session_id.as_deref() { insert(&mut headers, "x-mnote-session-id", session_id); } if let Some(workspace_id) = effective_workspace_id { insert(&mut headers, "x-mnote-workspace-id", workspace_id); } headers } async fn send_next_documents_request( context: &RequestContext, request: reqwest::RequestBuilder, phase: &'static str, ) -> Result { let response = request.send().await.map_err(|error| { let base = if error.is_timeout() { WebError::gateway_timeout_code( "next_proxy_timeout", format!("Next compat 请求超时: {error}"), ) } else { WebError::service_unavailable_code( "next_proxy_unavailable", format!("Next compat 请求失败: {error}"), ) }; base.with_context(context) .with_header("x-error-phase", phase) .with_header("x-upstream-service", "next") })?; let status = response.status(); let text = response.text().await.map_err(|error| { WebError::bad_gateway_code( "next_proxy_bad_response", format!("Next compat 响应读取失败: {error}"), ) .with_context(context) .with_header("x-error-phase", phase) .with_header("x-upstream-service", "next") .with_header("x-upstream-status", status.as_u16().to_string()) })?; let payload = serde_json::from_str::(&text).map_err(|_| { let snippet: String = text.chars().take(180).collect(); WebError::bad_gateway_code( "next_proxy_bad_response", format!("Next compat 返回了非 JSON 内容: {snippet}"), ) .with_context(context) .with_header("x-error-phase", phase) .with_header("x-upstream-service", "next") .with_header("x-upstream-status", status.as_u16().to_string()) })?; if !status.is_success() { let message = payload .get("error") .and_then(Value::as_str) .or_else(|| payload.get("message").and_then(Value::as_str)) .unwrap_or("Next compat 文档接口请求失败"); let web_error = match status { reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message) } reqwest::StatusCode::NOT_FOUND => { WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message) } _ => WebError::bad_gateway_code("next_proxy_error", message), }; return Err(web_error .with_context(context) .with_header("x-error-phase", phase) .with_header("x-upstream-service", "next") .with_header("x-upstream-status", status.as_u16().to_string())); } Ok(payload) } async fn proxy_next_documents_meta( context: &RequestContext, effective_workspace_id: Option<&str>, document_id: &str, ) -> Result { let base_url = next_documents_base_url(); let mut url = reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| { WebError::internal(format!("Next compat meta URL 非法: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_meta_url") .with_header("x-upstream-service", "next") })?; url.query_pairs_mut().append_pair("documentId", document_id); if let Some(workspace_id) = effective_workspace_id { url.query_pairs_mut() .append_pair("workspaceId", workspace_id); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| { WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_meta_client") .with_header("x-upstream-service", "next") })?; let payload = send_next_documents_request( context, client .get(url) .headers(build_next_proxy_headers(context, effective_workspace_id)), "next_proxy_meta", ) .await?; Ok(payload.get("doc").cloned().unwrap_or(Value::Null)) } async fn proxy_next_documents_content( context: &RequestContext, effective_workspace_id: Option<&str>, document_id: &str, ) -> Result { let base_url = next_documents_base_url(); let mut url = reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| { WebError::internal(format!("Next compat content URL 非法: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_content_url") .with_header("x-upstream-service", "next") })?; url.query_pairs_mut().append_pair("documentId", document_id); if let Some(workspace_id) = effective_workspace_id { url.query_pairs_mut() .append_pair("workspaceId", workspace_id); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| { WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_content_client") .with_header("x-upstream-service", "next") })?; let payload = send_next_documents_request( context, client .get(url) .headers(build_next_proxy_headers(context, effective_workspace_id)), "next_proxy_content", ) .await?; Ok(json!({ "content": payload.get("content").cloned().unwrap_or(Value::Null), "revision": payload.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null), "pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null), })) } async fn proxy_next_documents_save( context: &RequestContext, effective_workspace_id: Option<&str>, body: &DocumentSaveRequest, ) -> Result { let base_url = next_documents_base_url(); let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| { WebError::internal(format!("Next compat save URL 非法: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_save_url") .with_header("x-upstream-service", "next") })?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| { WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) .with_context(context) .with_header("x-error-phase", "next_proxy_save_client") .with_header("x-upstream-service", "next") })?; let payload = send_next_documents_request( context, client .post(url) .headers(build_next_proxy_headers(context, effective_workspace_id)) .json(&json!({ "documentId": body.document_id, "workspaceId": effective_workspace_id, "revision": body.revision, "conflictDetectionKey": body.conflict_detection_key, "expectedFileVersion": body.expected_file_version, "writeIntentId": body.write_intent_id, "saveOperationId": body.save_operation_id, "editorDocument": body.editor_document, "content": body.content, "tiptapDocument": body.tiptap_document, "snapshotCapturedAt": body.snapshot_captured_at, "blockCount": body.block_count, })), "next_proxy_save", ) .await?; Ok(json!({ "revision": payload.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": payload .get("conflictDetectionKey") .cloned() .unwrap_or(Value::Null), "ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)), })) } pub async fn load_document_meta_result( state: &AppState, context: &RequestContext, query: DocumentMetaQuery, ) -> Result { let document_id_owned = query.document_id.trim().to_string(); if document_id_owned.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(context), ); } let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; if should_proxy_via_next(context) { return proxy_next_documents_meta( context, effective_workspace_id.as_deref(), &document_id_owned, ) .await; } fetch_documents_meta_via_legacy_cloud( state.config(), context, effective_workspace_id.as_deref(), &document_id_owned, ) .await } pub async fn load_document_content_result( state: &AppState, context: &RequestContext, query: DocumentContentQuery, ) -> Result { let document_id_owned = query.document_id.trim().to_string(); if document_id_owned.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(context), ); } let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; if should_proxy_via_next(context) { return proxy_next_documents_content( context, effective_workspace_id.as_deref(), &document_id_owned, ) .await; } execute_runtime_query_via_legacy_cloud( state.config(), context, effective_workspace_id.as_deref(), RuntimeQueryEnvelopeWire { name: "documents.content.get".into(), payload: json!({ "documentId": document_id_owned, "workspaceId": effective_workspace_id, }), }, ) .await } pub async fn meta( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let result = load_document_meta_result(&state, &context, query).await?; Ok(ok_response(&context, result)) } pub async fn content( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let result = load_document_content_result(&state, &context, query).await?; Ok(ok_response(&context, result)) } /// 查询指定文档的当前 BufferStore 状态。 /// /// 支持两种查询方式: /// 1. 通过 documentId + sourceKind + rootUri 自动构造 workspace path /// 2. 通过 documentId + workspaceId + relativePath 精确查询 pub async fn buffer_state( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let result = find_document_buffer_state(&state.buffer_store, &query); match result { Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))), None => Err(WebError::new( StatusCode::NOT_FOUND, "buffer_state_not_found", "未找到该文档的 buffer 状态", ) .with_context(&context)), } } /// 标记指定打开文档的 BufferStore 状态为 Dirty。 /// /// 浏览器仍负责具体保存调度;Rust BufferStore 只承担跨 watcher、AI、保存冲突的统一打开态仲裁。 pub async fn mark_buffer_dirty( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err(WebError::new( StatusCode::BAD_REQUEST, "document_id_required", "documentId 不能为空", ) .with_context(&context)); } let query = BufferStateQuery { document_id: Some(document_id.to_string()), workspace_id: body.workspace_id.clone(), source_kind: body.source_kind.clone(), root_uri: body.root_uri.clone(), relative_path: body.relative_path.clone(), }; let existing = find_document_buffer_state(&state.buffer_store, &query).ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "buffer_state_not_found", "未找到该文档的 buffer 状态", ) .with_context(&context) })?; let content_hash = body .content_hash .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("browser-dirty") .to_string(); let marked = state .buffer_store .mark_dirty(&existing.workspace_path, content_hash) .unwrap_or(existing); Ok(ok_response(&context, buffer_state_payload(marked))) } pub async fn page_body_write( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder { return Err(WebError::bad_request_code( "page_body_write_source_unsupported", "page.body.write 当前只支持 local_folder 本地写入", ) .with_context(&context)); } let root_uri = body.root_uri.trim(); if root_uri.is_empty() { return Err(WebError::bad_request_code( "local_folder_root_required", "缺少本地文件夹 rootUri", ) .with_context(&context)); } ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let result = write_local_markdown_page_body(&body, Some(&state.buffer_store))?; Ok(ok_response(&context, result)) } pub async fn save( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } 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_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let expected_file_version = body .expected_file_version .as_deref() .or(body.conflict_detection_key.as_deref()); let result = write_local_markdown_page_body( &core_protocol::PageBodyWriteRequest { document_id: document_id.to_string(), workspace_id: body.workspace_id.clone().unwrap_or_default(), source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: root_uri.to_string(), expected_file_version: expected_file_version.map(ToOwned::to_owned), write_intent_id: body.write_intent_id.clone(), save_operation_id: body.save_operation_id.clone(), base_content_hash: body.base_content_hash.clone(), content_format: body .content_format .clone() .unwrap_or_else(|| "editorBlocks".into()), content: body.content.clone(), editor_source: body .editor_source .clone() .or_else(|| Some("documents/save-compat".into())), }, Some(&state.buffer_store), )?; return Ok(ok_response(&context, result)); } let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; if should_proxy_via_next(&context) { let result = proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?; return Ok(ok_response(&context, result)); } let command = RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: format!("document_save_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: effective_workspace_id.clone(), page_id: Some(document_id.to_string()), block_id: None, }), payload: json!({ "documentId": document_id, "workspaceId": effective_workspace_id, "revision": body.revision, "conflictDetectionKey": body.conflict_detection_key, "expectedFileVersion": body.expected_file_version, "writeIntentId": body.write_intent_id, "saveOperationId": body.save_operation_id, "editorDocument": body.editor_document, "content": body.content, "tiptapDocument": body.tiptap_document, "snapshotCapturedAt": body.snapshot_captured_at, "blockCount": body.block_count, }), preflight_data: None, reason: Some("mnote-web human editor save".into()), refs: vec!["mnote-web-editor-runtime".into()], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, effective_workspace_id.as_deref(), command, ) .await?; let mut result = execution.result; if let Value::Object(map) = &mut result { map.insert("executedCommand".into(), json!("page.body.save")); map.insert("canonicalCommand".into(), json!("page.body.save")); map.insert("compatRoute".into(), json!("/api/documents/save")); if let Some(artifact_error) = execution.artifact_error { map.insert("artifactError".into(), json!(artifact_error)); } } Ok(ok_response(&context, result)) } pub async fn purge( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } let command = RuntimeCommandEnvelopeWire { name: "documents.purge".into(), command_id: format!("document_purge_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: None, page_id: Some(document_id.to_string()), block_id: None, }), payload: json!({ "documentId": document_id, }), preflight_data: None, reason: Some("mnote-web documents purge compat".into()), refs: vec!["mnote-web-documents-compat".into()], dry_run: false, validate_only: false, }; let result = execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?; Ok(ok_response(&context, result)) } pub async fn empty_trash( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let workspace_id = body.workspace_id.trim(); if workspace_id.is_empty() { return Err( WebError::bad_request_code("workspace_id_required", "缺少有效 workspaceId") .with_context(&context), ); } let command = RuntimeCommandEnvelopeWire { name: "tree.trash.emptyWorkspace".into(), command_id: format!("tree_trash_empty_workspace_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: Some(workspace_id.to_string()), capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some(workspace_id.to_string()), page_id: None, block_id: None, }), payload: json!({ "workspaceId": workspace_id, }), preflight_data: None, reason: Some("mnote-web tree trash empty workspace".into()), refs: vec![ "mnote-web-documents-trash-compat".into(), "tree.trash.emptyWorkspace".into(), ], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, Some(workspace_id), command, ) .await?; let artifacts = execution_artifacts_json(&execution); let artifact_error = execution.artifact_error.clone(); let mut result = execution.result; if let Value::Object(map) = &mut result { map.insert( "canonicalCommand".into(), json!("tree.trash.emptyWorkspace"), ); map.insert("compatRoute".into(), json!("/api/documents/empty-trash")); } let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "meta": { "commandName": "tree.trash.emptyWorkspace", "canonicalCommand": "tree.trash.emptyWorkspace", "compatCommandName": "documents.emptyTrashByWorkspace", "artifacts": artifacts, "artifactError": artifact_error, }, "result": result, })), )) } pub async fn title( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } let title = body.title.trim(); if title.is_empty() { return Err( WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context), ); } 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_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let result = update_local_markdown_title(root_uri, document_id, title)?; return Ok(ok_response(&context, result)); } let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; let command = RuntimeCommandEnvelopeWire { name: "page.head.updateTitle".into(), command_id: format!("page_title_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: effective_workspace_id.clone(), page_id: Some(document_id.to_string()), block_id: None, }), payload: json!({ "documentId": document_id, "workspaceId": effective_workspace_id, "title": title, }), preflight_data: None, reason: Some("mnote-web page title update".into()), refs: vec![body .command_name .unwrap_or_else(|| "page.head.updateTitle".into())], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, effective_workspace_id.as_deref(), command, ) .await?; let artifacts = execution_artifacts_json(&execution); let artifact_error = execution.artifact_error.clone(); let result = execution.result; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "meta": { "commandName": "page.head.updateTitle", "canonicalCommand": "page.head.updateTitle", "artifacts": artifacts, "artifactError": artifact_error, }, "result": result, })), )) } pub async fn options( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } 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_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let result = crate::routes::ui_preferences::update_page_preferences_from_value( &state, &context, context.auth.actor_id.trim(), body.workspace_id.as_deref().unwrap_or_default(), body.source_kind.as_deref().unwrap_or("local_folder"), root_uri, document_id, &body.options, )?; return Ok(ok_response(&context, result)); } let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; let command = RuntimeCommandEnvelopeWire { name: "page.layout.updateOptions".into(), command_id: format!("page_options_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: effective_workspace_id.clone(), page_id: Some(document_id.to_string()), block_id: None, }), payload: json!({ "documentId": document_id, "workspaceId": effective_workspace_id, "options": body.options, }), preflight_data: None, reason: Some("mnote-web page layout update".into()), refs: vec![body .command_name .unwrap_or_else(|| "page.layout.updateOptions".into())], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, effective_workspace_id.as_deref(), command, ) .await?; let artifacts = execution_artifacts_json(&execution); let artifact_error = execution.artifact_error.clone(); let result = execution.result; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( StatusCode::OK, headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "meta": { "commandName": "page.layout.updateOptions", "canonicalCommand": "page.layout.updateOptions", "artifacts": artifacts, "artifactError": artifact_error, }, "result": result, })), )) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore}; use axum::body::{to_bytes, Body}; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use serde_json::Value; 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: Some( r#"{ "documents:getMeta": { "id": "doc_1", "workspace_id": "ws_demo", "title": "服务端页面", "updated_at": "2026-04-18T09:30:00Z", "can_edit": true, "disable_download": false, "disable_copy": false, "wide_layout": false, "use_small_text": false, "show_heading_numbers": true, "show_toc": true, "show_structure": true, "protect_editing": false, "show_word_count": true, "collapse_backlinks": false, "page_font": "default", "layout_density": "normal", "hide_child_pages": false, "show_block_ref_count": true, "embed_default_block_id": "heading_1", "word_count": 42, "character_count": 128, "block_count": 3, "todo_total": 1, "todo_done": 0 }, "documents:getContent": { "title": "服务端页面", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一" }] } ], "revision": 7, "conflict_detection_key": "doc_1:7" } }"# .into(), ), mutation_fixtures_json: Some( r#"{ "documents:updateTitle": { "ok": true, "title": "服务端页面(改名)" }, "documents:updateOptions": { "ok": true, "show_toc": false }, "documents:updateContent": { "ok": true, "updated_at": "2026-04-18T09:45:00Z", "revision": 8, "conflict_detection_key": "doc_1:8" }, "documents:emptyTrashByWorkspace": { "ok": true, "deletedCount": 2 }, "bridgeLogs:recordCommandLog": { "ok": true, "id": "clog_fixture" }, "bridgeLogs:recordDomainEvent": { "ok": true, "id": "evt_fixture" } }"# .into(), ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[test] fn documents_buffer_state_falls_back_to_document_id_without_relative_path() { let store = BufferStore::new(); let ws_path = build_local_folder_workspace_path( "ws_local", "file:///tmp/mnote-root", "docs/page.md", "local-md:docs~2Fpage.md", ); store.init_buffer(&ws_path, Some("version-1".into()), Some("hash-1".into())); let query = super::BufferStateQuery { document_id: Some("local-md:docs~2Fpage.md".into()), workspace_id: Some("ws_local".into()), source_kind: Some("local_folder".into()), root_uri: Some("file:///tmp/mnote-root".into()), relative_path: None, }; let result = super::find_document_buffer_state(&store, &query).expect("buffer state"); assert_eq!(result.file_version.as_deref(), Some("version-1")); assert_eq!(result.workspace_path.relative_path, "docs/page.md"); } #[test] fn convex_auth_cookie_does_not_force_next_proxy() { let mut headers = HeaderMap::new(); headers.insert( "cookie", HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"), ); let context = crate::context::RequestContext::from_http_parts( &Method::GET, &"/api/documents/meta".parse::().expect("uri"), &headers, ); assert!(!super::should_proxy_via_next(&context)); } #[tokio::test] async fn documents_api_meta_route_returns_document_metadata() { let response = app() .oneshot( Request::builder() .uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo") .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-documents-transport") .and_then(|value| value.to_str().ok()), Some("documents-api") ); 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["result"]["id"], "doc_1"); assert_eq!(payload["result"]["workspace_id"], "ws_demo"); assert_eq!(payload["result"]["title"], "服务端页面"); assert_eq!(payload["result"]["show_structure"], true); } #[tokio::test] async fn documents_api_content_route_returns_page_subtree() { let response = app() .oneshot( Request::builder() .uri("/api/documents/content?documentId=doc_1") .body(Body::empty()) .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["result"]["revision"], 7); assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1"); assert_eq!( payload["result"]["pageSubtree"]["outline"][0]["title"], "章节一" ); } #[tokio::test] async fn documents_save_route_executes_page_body_save_command() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/save") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "documentId": "doc_1", "workspaceId": "ws_demo", "revision": 7, "conflictDetectionKey": "doc_1:7", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一(已编辑)" }] } ], "blockCount": 1 }) .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["result"]["revision"], 8); assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8"); assert_eq!(payload["result"]["executedCommand"], "page.body.save"); assert_eq!(payload["result"]["canonicalCommand"], "page.body.save"); } #[tokio::test] async fn documents_empty_trash_route_executes_workspace_trash_purge() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/empty-trash") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "workspaceId": "ws_demo" }) .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["result"]["deletedCount"], 2); assert_eq!( payload["result"]["canonicalCommand"], "tree.trash.emptyWorkspace" ); assert_eq!( payload["meta"]["artifacts"]["commandLog"]["commandName"], "tree.trash.emptyWorkspace" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["eventType"], "tree.trash.documents.emptied" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], "resync_required" ); assert_eq!(payload["meta"]["artifactError"], Value::Null); } #[tokio::test] async fn documents_title_route_executes_page_head_update_title() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/title") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "documentId": "doc_1", "workspaceId": "ws_demo", "title": "服务端页面(改名)", "commandName": "page.head.updateTitle" }) .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"]["commandName"], "page.head.updateTitle"); assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle"); assert_eq!( payload["meta"]["artifacts"]["commandLog"]["commandName"], "page.head.updateTitle" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["eventType"], "tree.node.renamed" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], "upsert_document" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"] ["title"], "服务端页面(改名)" ); assert_eq!(payload["meta"]["artifactError"], Value::Null); } #[tokio::test] async fn documents_options_route_executes_page_layout_update_options() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/options") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "documentId": "doc_1", "workspaceId": "ws_demo", "options": { "showToc": false }, "commandName": "page.layout.updateOptions" }) .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"]["commandName"], "page.layout.updateOptions"); assert_eq!( payload["meta"]["canonicalCommand"], "page.layout.updateOptions" ); assert_eq!( payload["meta"]["artifacts"]["commandLog"]["commandName"], "page.layout.updateOptions" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["eventType"], "page.layout.options_updated" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], "resync_required" ); assert_eq!(payload["meta"]["artifactError"], Value::Null); } #[tokio::test] async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() { let root = std::env::temp_dir().join(format!( "mnote-local-documents-write-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("Old Local Title")).expect("create local page bundle"); std::fs::write( root.join("Old Local Title").join("Old Local Title.md"), "# Old\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_test", &root_uri, ) .expect("init local workspace"); let document_id = "local-md:Old~20Local~20Title~2FOld~20Local~20Title.md"; let title_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/title") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": document_id, "sourceKind": "local_folder", "rootUri": root_uri, "title": "New Local Title" }) .to_string(), )) .expect("request"), ) .await .expect("title response"); assert_eq!(title_response.status(), StatusCode::OK); let title_body = to_bytes(title_response.into_body(), usize::MAX) .await .expect("title body"); let title_payload: Value = serde_json::from_slice(&title_body).expect("title json"); let renamed_document_id = title_payload["result"]["documentId"] .as_str() .expect("renamed document id") .to_string(); let save_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/save") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": renamed_document_id, "sourceKind": "local_folder", "rootUri": root_uri, "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 2 }, "content": [{ "type": "text", "text": "Saved Heading" }] }, { "id": "paragraph_1", "type": "paragraph", "content": [{ "type": "text", "text": "Saved body" }] } ], "blockCount": 2 }) .to_string(), )) .expect("request"), ) .await .expect("save response"); assert_eq!(save_response.status(), StatusCode::OK); let save_body = to_bytes(save_response.into_body(), usize::MAX) .await .expect("save body"); let save_payload: Value = serde_json::from_slice(&save_body).expect("save json"); assert_eq!( save_payload["result"]["canonicalCommand"], "page.body.write" ); assert_eq!(save_payload["result"]["compatCommand"], "page.body.save"); let options_response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/options") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": renamed_document_id, "sourceKind": "local_folder", "rootUri": root_uri, "options": { "wideLayout": true, "showToc": false, "hideTitleHeader": false } }) .to_string(), )) .expect("request"), ) .await .expect("options response"); assert_eq!(options_response.status(), StatusCode::OK); let markdown = std::fs::read_to_string(root.join("New Local Title").join("New Local Title.md")) .expect("read md"); assert!(markdown.contains("## Saved Heading")); assert!(markdown.contains("Saved body")); assert!( !root.join(".mnote").join("page-options.json").exists(), "local folder UI 偏好不应继续写入 .mnote/page-options.json" ); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_folder_documents_save_rejects_stale_expected_file_version() { let root = std::env::temp_dir().join(format!( "mnote-local-documents-expected-file-version-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create local root"); std::fs::write( root.join("README.md"), "---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_test", &root_uri, ) .expect("init local workspace"); let document_id = "local-md:README.md"; let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate( &root_uri, document_id, ) .expect("aggregate"); let stale_file_version = aggregate .body .conflict_detection_key .as_str() .expect("file version") .to_string(); std::thread::sleep(std::time::Duration::from_millis(5)); std::fs::write( root.join("README.md"), "---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n", ) .expect("external write"); let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/save") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( serde_json::json!({ "documentId": document_id, "sourceKind": "local_folder", "rootUri": root_uri, "expectedFileVersion": stale_file_version, "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "Editor" }] } ], "blockCount": 1 }) .to_string(), )) .expect("request"), ) .await .expect("save response"); assert_eq!(response.status(), StatusCode::CONFLICT); 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["code"], "local_markdown_external_change"); assert_eq!( payload["details"]["conflict"]["documentId"].as_str(), Some(document_id) ); assert_eq!( payload["details"]["conflict"]["rootUri"].as_str(), Some(root_uri.as_str()) ); assert_eq!( payload["details"]["conflict"]["editorBaseVersion"].as_str(), Some(stale_file_version.as_str()) ); assert!(payload["details"]["conflict"]["currentDiskVersion"] .as_str() .unwrap_or("") .starts_with("local-md:local-md:README.md:")); assert!(payload["details"]["conflict"]["suggestedActions"] .as_array() .unwrap() .iter() .any(|action| action.as_str() == Some("merge"))); let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(markdown.contains("# External")); assert!(!markdown.contains("# Editor")); let _ = std::fs::remove_dir_all(&root); } }