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, } #[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, } 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, #[serde(default)] pub base_content_hash: Option, #[serde(default = "default_page_body_content_format")] pub content_format: String, pub content: Value, #[serde(default)] pub editor_source: Option, } #[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, 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, } #[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, pub block_id: Option, pub asset_id: Option, } /// DocumentBuffer 打开状态 — 表达当前编辑器缓冲区对文件的打开态、dirty 状态和文件版本仲裁。 /// /// 五种状态:Clean(未修改)、Dirty(已修改未保存)、Stale(版本过时)、 /// ExternalModified(外部已修改)、Deleted(外部已删除)。 /// /// Phase A2 收口后,tiptap autosave、AI 写入、外部 watcher 均围绕此状态仲裁。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DocBufferDirtyState { Clean, Dirty, Stale, ExternalModified, Deleted, } impl Default for DocBufferDirtyState { fn default() -> Self { Self::Clean } } /// DocumentBuffer 最小字段模型 — 表示一个已打开文档的缓冲区状态。 /// /// 不是持久化结构,而是运行时模型,用于仲裁写入冲突和外部修改检测。 /// 每个打开的编辑器(tiptap / AI session / external editor)对应一个实例。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DocumentBuffer { /// 文档在 workspace 中的统一路径身份 pub workspace_path: ObjectWorkspacePath, /// 最后已知文件版本(来自 aggregate.fileVersion 或 disk mtime content-hash) pub file_version: Option, /// 最后加载/保存时的内容 hash pub base_content_hash: Option, /// 当前编辑器内容 hash(dirty 时与 base 不同) pub current_content_hash: Option, /// 缓冲区 dirty 状态 pub dirty_state: DocBufferDirtyState, /// 最后加载时间戳 (ms since epoch) pub last_loaded_at: Option, /// 最后保存时间戳 (ms since epoch) pub last_saved_at: Option, /// 外部修改者 pub external_actor: Option, } impl DocumentBuffer { pub fn is_dirty(&self) -> bool { self.dirty_state == DocBufferDirtyState::Dirty || self.dirty_state == DocBufferDirtyState::Stale } pub fn mark_dirty(&mut self, content_hash: String) { self.current_content_hash = Some(content_hash); self.dirty_state = DocBufferDirtyState::Dirty; } pub fn mark_saved(&mut self, file_version: String, content_hash: String) { self.file_version = Some(file_version); self.base_content_hash = Some(content_hash); self.current_content_hash = None; self.dirty_state = DocBufferDirtyState::Clean; self.last_saved_at = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64, ); } pub fn mark_external_modified(&mut self, actor: Option) { self.external_actor = actor; if self.is_dirty() { self.dirty_state = DocBufferDirtyState::Stale; } else { self.dirty_state = DocBufferDirtyState::ExternalModified; } } pub fn mark_deleted(&mut self) { self.dirty_state = DocBufferDirtyState::Deleted; } } #[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, } /// 统一 workspace 路径身份 — 表达"哪个 workspace root 下的哪个对象/文件/资源"。 /// /// 这是 Phase A1 收口后的主身份类型,用于: /// - File Tree / Page Tree / Resource Tree 的 resourceMeta /// - 本地文件写入时的身份验证 /// - 前端 sidebar/filetree 的统一 identity 消费 /// /// 相比旧的 `KernelObjectIdentity`,增加了 workspace 上下文: /// `workspace_id`、`source_kind`、`root_uri`、`relative_path`。 /// /// 所有新代码应优先使用此类型;`KernelObjectIdentity` 保留为内核投影的兼容子集。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ObjectWorkspacePath { pub workspace_id: String, pub source_kind: WorkspaceSourceKind, pub root_uri: String, pub relative_path: String, pub object_identity: KernelObjectIdentity, pub resource_kind: Option, } #[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, pub depth: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct KernelNodeMetadata { pub title: Option, pub icon: Option, #[serde(default)] pub tags: Vec, pub created_at: Option, pub updated_at: Option, #[serde(default)] pub extra: BTreeMap, } #[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, #[serde(default)] pub evidence_node_ids: Vec, #[serde(default)] pub source_node_ids: Vec, #[serde(default)] pub extra: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct KernelAuditStamp { pub version: u64, pub revision: Option, pub request_id: Option, pub trace_id: Option, pub actor_id: Option, } 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, pub parent_id: Option, pub subtree: Option, pub metadata: KernelNodeMetadata, pub content: Option, pub refs: Option, #[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, pub from_node_id: String, pub to_node_id: String, #[serde(default)] pub metadata: BTreeMap, #[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, #[serde(default)] pub edge_types: Vec, pub query: Option, pub max_results: Option, #[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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelGetSubtree { pub subtree: KernelSubtreeRef, pub workspace_id: Option, #[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, #[serde(default)] pub node_types: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelListEdges { pub node_id: String, pub workspace_id: Option, #[serde(default)] pub edge_types: Vec, pub direction: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelTraverseGraph { pub start_node_id: String, pub workspace_id: Option, #[serde(default)] pub edge_types: Vec, pub max_depth: u32, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionRequest { pub projection: KernelProjectionKind, pub workspace_id: Option, pub root_node_id: Option, pub subtree: Option, #[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, pub edges: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelEdgeListResult { pub node_id: String, pub edges: Vec, } #[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, pub edges: Vec, } #[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, pub node_type: KernelNodeType, pub projection_kind: KernelProjectionKind, pub title: Option, pub depth: u32, pub position: Option, pub child_count: u32, pub expandable: bool, pub expanded_by_default: bool, #[serde(default)] pub capabilities: Vec, pub resource_meta: Option, pub icon_hint: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionResourceMeta { pub resource_kind: Option, pub document_id: Option, pub asset_id: Option, pub workspace_id: Option, pub asset_kind: Option, pub icon_hint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub object_identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub block_asset_relation: Option, #[serde(default)] pub extra: BTreeMap, } #[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, pub items: Vec, pub edges: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub meta: BTreeMap, } #[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, pub text_snippet: Option, pub block_type: Option, pub heading_level: Option, pub numbering: Option, pub child_count: u32, pub order: u32, #[serde(default)] pub path: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DocumentReadNode { pub id: String, pub parent_node_id: Option, pub node_type: DocumentReadNodeType, pub block_id: Option, pub anchor_block_id: Option, 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, } #[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, 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, 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, #[serde(default)] pub evidence: Vec, 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub tiptap_document: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub block_document: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub block_projection_version: Option, pub revision: u64, pub conflict_detection_key: String, pub title: Option, pub page_subtree: DocumentReadPageSubtree, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelCreateNode { pub node: KernelNode, pub position: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelUpdateNode { pub node_id: String, pub metadata: Option, pub content: Option, pub refs: Option, pub audit: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct KernelMoveSubtree { pub subtree: KernelSubtreeRef, pub new_parent_node_id: Option, pub sort_order: Option, } #[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, pub from_node_id: Option, pub to_node_id: Option, pub edge_type: Option, } #[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], 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")); 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()), 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")); } #[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); } #[test] fn object_workspace_path_round_trip() { let path = ObjectWorkspacePath { workspace_id: "local-ws:user:my-space".into(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space".into(), relative_path: "docs/README.md".into(), object_identity: KernelObjectIdentity { object_kind: KernelObjectKind::Page, document_id: Some("local-md:docs~2FREADME.md".into()), block_id: None, asset_id: None, }, resource_kind: Some("document".into()), }; let value = serde_json::to_value(&path).expect("ObjectWorkspacePath 应可序列化"); assert_eq!(value["workspaceId"], json!("local-ws:user:my-space")); assert_eq!(value["sourceKind"], json!("local_folder")); assert_eq!( value["rootUri"], json!("file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space") ); assert_eq!(value["relativePath"], json!("docs/README.md")); assert_eq!(value["objectIdentity"]["objectKind"], json!("page")); assert_eq!( value["objectIdentity"]["documentId"], json!("local-md:docs~2FREADME.md") ); assert_eq!(value["resourceKind"], json!("document")); let decoded: ObjectWorkspacePath = serde_json::from_value(value).expect("ObjectWorkspacePath 应可反序列化"); assert_eq!(decoded, path); } #[test] fn object_workspace_path_accepts_resource_kind_null() { #[derive(Deserialize)] struct Minimal { resource_kind: Option, } let value = serde_json::json!({ "workspaceId": "ws_1", "sourceKind": "convex_workspace", "rootUri": "convex://ws", "relativePath": "doc.md", "objectIdentity": { "objectKind": "page", "documentId": "doc_1", "blockId": null, "assetId": null } }); let path: ObjectWorkspacePath = serde_json::from_value(value).expect("resource_kind null 应接受"); assert_eq!(path.resource_kind, None); } #[test] fn document_buffer_round_trip() { let identity = KernelObjectIdentity { object_kind: KernelObjectKind::Page, document_id: Some("local-md:doc.md".into()), block_id: None, asset_id: None, }; let path = ObjectWorkspacePath { workspace_id: "ws_1".into(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: "file:///tmp/root".into(), relative_path: "doc.md".into(), object_identity: identity, resource_kind: Some("document".into()), }; let buf = DocumentBuffer { workspace_path: path, file_version: Some("v1".into()), base_content_hash: Some("sha256:abc".into()), current_content_hash: None, dirty_state: DocBufferDirtyState::Clean, last_loaded_at: Some(1_700_000_000_000i64), last_saved_at: Some(1_700_000_000_001i64), external_actor: None, }; let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化"); assert_eq!(value["dirtyState"], json!("clean")); assert_eq!(value["fileVersion"], json!("v1")); assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md")); let decoded: DocumentBuffer = serde_json::from_value(value).expect("DocumentBuffer 应可反序列化"); assert_eq!(decoded, buf); } #[test] fn document_buffer_state_transitions() { let identity = KernelObjectIdentity { object_kind: KernelObjectKind::Page, document_id: Some("local-md:doc.md".into()), block_id: None, asset_id: None, }; let path = ObjectWorkspacePath { workspace_id: "ws_1".into(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: "file:///tmp/root".into(), relative_path: "doc.md".into(), object_identity: identity, resource_kind: Some("document".into()), }; let mut buf = DocumentBuffer { workspace_path: path, file_version: Some("v1".into()), base_content_hash: Some("sha256:abc".into()), current_content_hash: None, dirty_state: DocBufferDirtyState::Clean, last_loaded_at: None, last_saved_at: None, external_actor: None, }; assert!(!buf.is_dirty()); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); buf.mark_dirty("sha256:dirty".into()); assert!(buf.is_dirty()); assert_eq!(buf.dirty_state, DocBufferDirtyState::Dirty); buf.mark_external_modified(Some("external-editor".into())); assert!(buf.is_dirty()); assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale); assert_eq!(buf.external_actor.as_deref(), Some("external-editor")); buf.mark_saved("v2".into(), "sha256:saved".into()); assert!(!buf.is_dirty()); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); assert_eq!(buf.file_version.as_deref(), Some("v2")); buf.mark_external_modified(None); assert_eq!(buf.dirty_state, DocBufferDirtyState::ExternalModified); buf.mark_deleted(); assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted); } }