diff --git a/frontend/src-tauri/src/record_commands.rs b/frontend/src-tauri/src/record_commands.rs index 53e49df..616e4a6 100644 --- a/frontend/src-tauri/src/record_commands.rs +++ b/frontend/src-tauri/src/record_commands.rs @@ -1,6 +1,9 @@ //! Main-window preference records; no generic credential or application-state accessor. use super::{with_workspace, Host}; -use notesagent_host::{records, workspace::HostError}; +use notesagent_host::{ + records, + workspace::{HostError, Result as HostResult, Workspace}, +}; use serde::Deserialize; use serde_json::{json, Value}; use tauri::State; @@ -19,43 +22,116 @@ pub struct Write { expected: String, operation_id: String, } -fn preference(kind: &str) -> Result<(), String> { - if matches!(kind, "theme_settings" | "preferences") { +fn preference(kind: &str) -> HostResult<()> { + if matches!(kind, "theme_settings" | "preferences" | "layout") { Ok(()) } else { - Err("RECORD_SCOPE_DENIED".into()) + Err(HostError::new("RECORD_SCOPE_DENIED")) } } +fn get_record(ws: &mut Workspace, request: Get) -> HostResult> { + preference(&request.kind)?; + if ws.vault_id != request.vault_id { + return Err(HostError::new("VAULT_CHANGED")); + } + ws.record_get_kind(&request.kind, &request.id) +} + +fn write_record(ws: &mut Workspace, request: Write) -> HostResult { + let kind = request.record["kind"] + .as_str() + .ok_or_else(|| HostError::new("RECORD_SCHEMA_INVALID"))?; + preference(kind)?; + if ws.vault_id != request.vault_id { + return Err(HostError::new("VAULT_CHANGED")); + } + let path = records::path_for(kind, request.record["id"].as_str().unwrap_or(""))?; + let bytes = + serde_json::to_vec(&request.record).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?; + let entry = ws.write_operation( + &path, + &request.expected, + &bytes, + "local", + &request.operation_id, + )?; + Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id})) +} + #[tauri::command] pub fn record_get(host: State<'_, Host>, request: Get) -> Result, String> { - preference(&request.kind)?; - with_workspace(&host, |ws| { - if ws.vault_id != request.vault_id { - return Err(HostError::new("VAULT_CHANGED")); - } - ws.record_get_kind(&request.kind, &request.id) - }) + with_workspace(&host, |ws| get_record(ws, request)) } #[tauri::command] pub fn record_write(host: State<'_, Host>, request: Write) -> Result { - let kind = request.record["kind"] - .as_str() - .ok_or("RECORD_SCHEMA_INVALID")?; - preference(kind)?; - let path = - records::path_for(kind, request.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?; - let bytes = serde_json::to_vec(&request.record).map_err(|_| "RECORD_SCHEMA_INVALID")?; - with_workspace(&host, |ws| { - if ws.vault_id != request.vault_id { - return Err(HostError::new("VAULT_CHANGED")); - } - let entry = ws.write_operation( - &path, - &request.expected, - &bytes, - "local", - &request.operation_id, - )?; - Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id})) - }) + with_workspace(&host, |ws| write_record(ws, request)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn layout_traverses_the_actual_command_policy_and_workspace_journal() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let record = json!({"schema":1,"kind":"layout","id":"sidebars","data":{"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320}}); + let operation = uuid::Uuid::new_v4().to_string(); + let make = |vault: &str| Write { + vault_id: vault.into(), + record: record.clone(), + expected: String::new(), + operation_id: operation.clone(), + }; + let vault = ws.vault_id.clone(); + let first = write_record(&mut ws, make(&vault)).unwrap(); + assert_eq!(write_record(&mut ws, make(&vault)).unwrap(), first); + assert_eq!(ws.pending_count().unwrap(), 1); + drop(ws); + let mut ws = Workspace::open(root.path()).unwrap(); + let loaded = get_record( + &mut ws, + Get { + vault_id: vault, + kind: "layout".into(), + id: "sidebars".into(), + }, + ) + .unwrap() + .unwrap(); + assert_eq!(loaded, first); + assert_eq!( + write_record(&mut ws, make("different-vault")) + .unwrap_err() + .code, + "VAULT_CHANGED" + ); + assert_eq!(ws.pending_count().unwrap(), 1); + } + #[test] + fn preference_commands_do_not_become_a_generic_record_accessor() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + for kind in ["persona", "task", "credentials", "user_skill"] { + let request = Get { + vault_id: ws.vault_id.clone(), + kind: kind.into(), + id: "default".into(), + }; + assert_eq!( + get_record(&mut ws, request).unwrap_err().code, + "RECORD_SCOPE_DENIED" + ); + } + let request = Write { + vault_id: ws.vault_id.clone(), + record: json!({"schema":1,"kind":"layout","id":"sidebars","data":{"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320,"token":"forbidden"}}), + expected: String::new(), + operation_id: uuid::Uuid::new_v4().to_string(), + }; + assert_eq!( + write_record(&mut ws, request).unwrap_err().code, + "RECORD_SCHEMA_INVALID" + ); + assert_eq!(ws.pending_count().unwrap(), 0); + } }