Files
mnote/rust/crates/core-protocol/src/kernel.rs
T

821 lines
25 KiB
Rust
Raw Normal View History

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelNodeType {
Workspace,
Folder,
Page,
Section,
Asset,
Table,
Book,
Pdf,
Mindmap,
MindmapNode,
Summary,
AiNote,
ReferenceAnchor,
ContentNode,
IndexNode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelEdgeType {
ParentOf,
ChildOf,
Contains,
References,
BacklinksTo,
SourceOf,
DerivedFrom,
Summarizes,
Indexes,
PointsTo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelProjectionKind {
SidebarTree,
PageTree,
FileTree,
Mindmap,
ReadView,
SearchResults,
RagIndex,
}
2026-05-08 00:41:03 +08:00
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSourceKind {
LocalFolder,
ConvexWorkspace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceSourceCapability {
LoadSnapshot,
Watch,
PreflightCommand,
ExecuteCommand,
ResolvePageAggregate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceSource {
pub source_kind: WorkspaceSourceKind,
pub root_uri: String,
pub workspace_id: String,
#[serde(default)]
pub capabilities: Vec<WorkspaceSourceCapability>,
}
fn default_page_body_content_format() -> String {
"editorBlocks".into()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageBodyWriteRequest {
pub document_id: String,
pub workspace_id: String,
pub source_kind: WorkspaceSourceKind,
pub root_uri: String,
#[serde(
default,
alias = "expected_file_version",
alias = "conflictDetectionKey",
alias = "conflict_detection_key"
)]
pub expected_file_version: Option<String>,
#[serde(default)]
pub base_content_hash: Option<String>,
#[serde(default = "default_page_body_content_format")]
pub content_format: String,
pub content: Value,
#[serde(default)]
pub editor_source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum KernelProjectionCapability {
Expand,
Open,
Drag,
Drop,
Select,
CreateChild,
Rename,
Archive,
Restore,
ContextMenu,
Reorder,
OpenAsset,
Pick,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelProjectionRowKind {
Document,
Index,
Asset,
AssetFolder,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelProjectionResourceKind {
Workspace,
Document,
Index,
Asset,
AssetFolder,
Mindmap,
2026-05-13 22:43:16 +08:00
Attachment,
OnlyOffice,
Code,
Table,
Book,
Pdf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelProjectionAssetKind {
File,
Mindmap,
Table,
Book,
Pdf,
Image,
Video,
Audio,
Unknown,
}
2026-05-13 22:43:16 +08:00
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelObjectKind {
Page,
Index,
Mindmap,
Attachment,
OnlyOffice,
Code,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelObjectIdentity {
pub object_kind: KernelObjectKind,
pub document_id: Option<String>,
pub block_id: Option<String>,
pub asset_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelBlockAssetRelation {
pub document_id: String,
pub block_id: String,
pub asset_id: String,
pub asset_kind: KernelProjectionAssetKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelGraphDirection {
Outgoing,
Incoming,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelSubtreeRef {
pub root_node_id: String,
#[serde(default)]
pub path: Vec<String>,
pub depth: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelNodeMetadata {
pub title: Option<String>,
pub icon: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelContentPayload {
pub format: String,
pub body: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelRefsPayload {
#[serde(default)]
pub reference_node_ids: Vec<String>,
#[serde(default)]
pub evidence_node_ids: Vec<String>,
#[serde(default)]
pub source_node_ids: Vec<String>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelAuditStamp {
pub version: u64,
pub revision: Option<u64>,
pub request_id: Option<String>,
pub trace_id: Option<String>,
pub actor_id: Option<String>,
}
impl Default for KernelAuditStamp {
fn default() -> Self {
Self {
version: 1,
revision: None,
request_id: None,
trace_id: None,
actor_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelNode {
pub id: String,
pub node_type: KernelNodeType,
pub workspace_id: Option<String>,
pub parent_id: Option<String>,
pub subtree: Option<KernelSubtreeRef>,
pub metadata: KernelNodeMetadata,
pub content: Option<KernelContentPayload>,
pub refs: Option<KernelRefsPayload>,
#[serde(default)]
pub audit: KernelAuditStamp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelEdge {
pub id: String,
pub edge_type: KernelEdgeType,
pub workspace_id: Option<String>,
pub from_node_id: String,
pub to_node_id: String,
#[serde(default)]
pub metadata: BTreeMap<String, Value>,
#[serde(default)]
pub audit: KernelAuditStamp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionFilter {
#[serde(default)]
pub node_types: Vec<KernelNodeType>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
2026-04-26 19:35:52 +08:00
pub query: Option<String>,
pub max_results: Option<usize>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGetNode {
pub node_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGetSubtree {
pub subtree: KernelSubtreeRef,
pub workspace_id: Option<String>,
#[serde(default)]
pub include_edges: bool,
#[serde(default)]
pub filters: KernelProjectionFilter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelListChildren {
pub parent_node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub node_types: Vec<KernelNodeType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelListEdges {
pub node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
pub direction: Option<KernelGraphDirection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelTraverseGraph {
pub start_node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
pub max_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionRequest {
pub projection: KernelProjectionKind,
pub workspace_id: Option<String>,
pub root_node_id: Option<String>,
pub subtree: Option<KernelSubtreeRef>,
#[serde(default)]
pub filters: KernelProjectionFilter,
#[serde(default)]
pub include_content: bool,
#[serde(default)]
pub include_edges: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelSubtreeResult {
pub root_node_id: String,
pub nodes: Vec<KernelNode>,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelEdgeListResult {
pub node_id: String,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGraphVisit {
pub node_id: String,
pub depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGraphTraversalResult {
pub start_node_id: String,
pub visited: Vec<KernelGraphVisit>,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionItem {
pub row_id: String,
pub row_kind: KernelProjectionRowKind,
pub node_id: String,
pub parent_node_id: Option<String>,
pub node_type: KernelNodeType,
pub projection_kind: KernelProjectionKind,
pub title: Option<String>,
pub depth: u32,
pub position: Option<i64>,
pub child_count: u32,
pub expandable: bool,
pub expanded_by_default: bool,
#[serde(default)]
pub capabilities: Vec<KernelProjectionCapability>,
pub resource_meta: Option<KernelProjectionResourceMeta>,
pub icon_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionResourceMeta {
pub resource_kind: Option<KernelProjectionResourceKind>,
pub document_id: Option<String>,
pub asset_id: Option<String>,
pub workspace_id: Option<String>,
pub asset_kind: Option<KernelProjectionAssetKind>,
pub icon_hint: Option<String>,
2026-05-13 22:43:16 +08:00
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_identity: Option<KernelObjectIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_asset_relation: Option<KernelBlockAssetRelation>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionResult {
pub projection_id: String,
pub projection: KernelProjectionKind,
pub root_node_id: Option<String>,
pub items: Vec<KernelProjectionItem>,
pub edges: Vec<KernelEdge>,
2026-04-28 16:30:51 +08:00
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DocumentReadNodeType {
Page,
Section,
ContentNode,
ReferenceAnchor,
Mindmap,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadNodeMeta {
pub title: Option<String>,
pub text_snippet: Option<String>,
pub block_type: Option<String>,
pub heading_level: Option<u32>,
pub numbering: Option<String>,
pub child_count: u32,
pub order: u32,
#[serde(default)]
pub path: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadNode {
pub id: String,
pub parent_node_id: Option<String>,
pub node_type: DocumentReadNodeType,
pub block_id: Option<String>,
pub anchor_block_id: Option<String>,
pub depth: u32,
pub metadata: DocumentReadNodeMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadSubtree {
pub root_node_id: String,
pub nodes: Vec<DocumentReadNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadOutlineEntry {
pub id: String,
pub node_id: String,
pub anchor_block_id: Option<String>,
pub title: String,
pub level: u32,
pub numbering: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DocumentReadEvidenceKind {
Page,
Heading,
Paragraph,
List,
Todo,
Quote,
Code,
Media,
Reference,
Table,
Mindmap,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadEvidenceItem {
pub id: String,
pub node_id: String,
pub block_id: Option<String>,
pub kind: DocumentReadEvidenceKind,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadStats {
pub block_count: u32,
pub heading_count: u32,
pub evidence_count: u32,
pub max_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadPageSubtree {
pub projection_id: String,
pub projection: String,
pub root_node_id: String,
pub root_node: DocumentReadNode,
pub subtree: DocumentReadSubtree,
#[serde(default)]
pub outline: Vec<DocumentReadOutlineEntry>,
#[serde(default)]
pub evidence: Vec<DocumentReadEvidenceItem>,
pub stats: DocumentReadStats,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentResult {
pub content: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub editor_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tiptap_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_projection_version: Option<u32>,
pub revision: u64,
pub conflict_detection_key: String,
pub title: Option<String>,
pub page_subtree: DocumentReadPageSubtree,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelCreateNode {
pub node: KernelNode,
pub position: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelUpdateNode {
pub node_id: String,
pub metadata: Option<KernelNodeMetadata>,
pub content: Option<KernelContentPayload>,
pub refs: Option<KernelRefsPayload>,
pub audit: Option<KernelAuditStamp>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelMoveSubtree {
pub subtree: KernelSubtreeRef,
pub new_parent_node_id: Option<String>,
pub sort_order: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelAttachEdge {
pub edge: KernelEdge,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelDetachEdge {
pub edge_id: Option<String>,
pub from_node_id: Option<String>,
pub to_node_id: Option<String>,
pub edge_type: Option<KernelEdgeType>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn kernel_projection_request_roundtrip_keeps_subtree_and_filters() {
let request = KernelProjectionRequest {
projection: KernelProjectionKind::SidebarTree,
workspace_id: Some("ws_1".into()),
root_node_id: Some("page_root".into()),
subtree: Some(KernelSubtreeRef {
root_node_id: "page_root".into(),
path: vec!["page_root".into(), "page_child".into()],
depth: Some(2),
}),
filters: KernelProjectionFilter {
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
edge_types: vec![KernelEdgeType::ParentOf],
2026-04-26 19:35:52 +08:00
query: Some("预算".into()),
max_results: Some(20),
include_deleted: false,
},
include_content: false,
include_edges: true,
};
let value = serde_json::to_value(&request).expect("request 应可序列化");
assert_eq!(value["projection"], json!("sidebar_tree"));
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
2026-04-26 19:35:52 +08:00
assert_eq!(value["filters"]["query"], json!("预算"));
assert_eq!(value["filters"]["maxResults"], json!(20));
let decoded: KernelProjectionRequest =
serde_json::from_value(value).expect("request 应可反序列化");
assert_eq!(decoded, request);
}
#[test]
fn kernel_node_serializes_content_refs_and_audit_boundaries() {
let node = KernelNode {
id: "node_1".into(),
node_type: KernelNodeType::Summary,
workspace_id: Some("ws_1".into()),
parent_id: Some("page_1".into()),
subtree: Some(KernelSubtreeRef {
root_node_id: "page_1".into(),
path: vec!["page_1".into(), "node_1".into()],
depth: Some(1),
}),
metadata: KernelNodeMetadata {
title: Some("摘要节点".into()),
icon: None,
tags: vec!["summary".into()],
created_at: Some("2026-04-16T00:00:00Z".into()),
updated_at: Some("2026-04-16T00:00:00Z".into()),
extra: BTreeMap::new(),
},
content: Some(KernelContentPayload {
format: "markdown".into(),
body: json!({"text": "摘要正文"}),
}),
refs: Some(KernelRefsPayload {
reference_node_ids: vec!["page_1".into()],
evidence_node_ids: vec!["asset_1".into()],
source_node_ids: vec!["pdf_1".into()],
extra: BTreeMap::new(),
}),
audit: KernelAuditStamp {
version: 3,
revision: Some(9),
request_id: Some("req_1".into()),
trace_id: Some("trace_1".into()),
actor_id: Some("user_1".into()),
},
};
let value = serde_json::to_value(&node).expect("node 应可序列化");
assert_eq!(value["nodeType"], json!("summary"));
assert_eq!(value["content"]["format"], json!("markdown"));
assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"]));
assert_eq!(value["audit"]["traceId"], json!("trace_1"));
}
#[test]
fn kernel_projection_item_serializes_contract_fields() {
let item = KernelProjectionItem {
row_id: "asset:page_1".into(),
row_kind: KernelProjectionRowKind::Asset,
node_id: "page_1".into(),
parent_node_id: None,
node_type: KernelNodeType::Page,
projection_kind: KernelProjectionKind::FileTree,
title: Some("页面 1".into()),
depth: 0,
position: Some(1),
child_count: 2,
expandable: true,
expanded_by_default: true,
capabilities: vec![
KernelProjectionCapability::Expand,
KernelProjectionCapability::Open,
KernelProjectionCapability::CreateChild,
],
resource_meta: Some(KernelProjectionResourceMeta {
resource_kind: Some(KernelProjectionResourceKind::Document),
document_id: Some("page_1".into()),
asset_id: None,
workspace_id: Some("ws_1".into()),
asset_kind: Some(KernelProjectionAssetKind::File),
icon_hint: Some("page".into()),
2026-05-13 22:43:16 +08:00
object_identity: None,
block_asset_relation: None,
extra: BTreeMap::new(),
}),
icon_hint: Some("page".into()),
};
let value = serde_json::to_value(&item).expect("projection item 应可序列化");
assert_eq!(value["rowId"], json!("asset:page_1"));
assert_eq!(value["rowKind"], json!("asset"));
assert_eq!(value["projectionKind"], json!("file_tree"));
assert_eq!(
value["capabilities"],
json!(["expand", "open", "create-child"])
);
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
assert_eq!(value["iconHint"], json!("page"));
}
2026-05-08 00:41:03 +08:00
#[test]
fn workspace_source_serializes_minimal_command_source_contract() {
let source = WorkspaceSource {
source_kind: WorkspaceSourceKind::ConvexWorkspace,
root_uri: "convex://workspace/ws_1".into(),
workspace_id: "ws_1".into(),
capabilities: vec![
WorkspaceSourceCapability::LoadSnapshot,
WorkspaceSourceCapability::PreflightCommand,
WorkspaceSourceCapability::ExecuteCommand,
],
};
let value = serde_json::to_value(&source).expect("workspace source 应可序列化");
assert_eq!(value["sourceKind"], json!("convex_workspace"));
assert_eq!(value["rootUri"], json!("convex://workspace/ws_1"));
assert_eq!(value["workspaceId"], json!("ws_1"));
assert_eq!(
value["capabilities"],
json!(["load-snapshot", "preflight-command", "execute-command"])
);
let decoded: WorkspaceSource =
serde_json::from_value(value).expect("workspace source 应可反序列化");
assert_eq!(decoded, source);
}
#[test]
fn page_body_write_request_uses_file_version_contract() {
let request: PageBodyWriteRequest = serde_json::from_value(json!({
"documentId": "local-md:README.md",
"workspaceId": "local-ws:user:my-space",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
"baseContentHash": "sha256:base",
"contentFormat": "editorBlocks",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
"editorSource": "tiptap"
}))
.expect("page.body.write request 应可反序列化");
assert_eq!(request.document_id, "local-md:README.md");
assert_eq!(request.source_kind, WorkspaceSourceKind::LocalFolder);
assert_eq!(
request.expected_file_version.as_deref(),
Some("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
let value = serde_json::to_value(&request).expect("request 应可序列化");
assert_eq!(
value["expectedFileVersion"],
json!("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(value["baseContentHash"], json!("sha256:base"));
assert_eq!(value["contentFormat"], json!("editorBlocks"));
assert_eq!(value["editorSource"], json!("tiptap"));
}
#[test]
fn page_body_write_request_accepts_legacy_conflict_key_alias() {
let request: PageBodyWriteRequest = serde_json::from_value(json!({
"documentId": "local-md:README.md",
"workspaceId": "local-ws:user:my-space",
"sourceKind": "local_folder",
"rootUri": "file:///tmp/workspace",
"conflictDetectionKey": "legacy-key",
"content": []
}))
.expect("legacy compat request 应可反序列化");
assert_eq!(request.expected_file_version.as_deref(), Some("legacy-key"));
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.base_content_hash, None);
assert_eq!(request.editor_source, None);
}
}