diff --git a/Audit.md b/Audit.md new file mode 100644 index 0000000..3a28260 --- /dev/null +++ b/Audit.md @@ -0,0 +1,10 @@ +# Audit Log + +## 2026-04-15 + +Found and fixed 4 issues: + +1. **Bug: debouncedSave shared timer loses edits** (TaskDetailView.svelte) - When user edits both title and description within 400ms, only the last-edited field was saved. Fixed by always saving both fields in the debounced callback. +2. **Code duplication: atomic_write_bytes** (google_tasks.rs) - Identical copy of `atomic_write` from storage.rs. Removed duplicate and reused the shared `pub(crate)` function. +3. **Bug: silent success on missing workspace** (lib.rs) - Four Tauri commands (`set_webdav_config`, `set_workspace_theme`, `set_sync_interval`, `set_sync_interval_unfocused`) silently succeeded when given a nonexistent workspace ID. Fixed to return an error. +4. **Bug: failing test due to wrong frontmatter field name** (storage.rs) - `test_parse_frontmatter_with_optional_fields` used `due:` instead of `date:` in frontmatter YAML, causing the assertion on `fm.date.is_some()` to fail. diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index ec076a5..7bdb221 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -549,9 +549,9 @@ fn set_webdav_config( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { - ws.webdav_url = Some(webdav_url); - } + let ws = s.config.workspaces.get_mut(&workspace_id) + .ok_or_else(|| format!("Workspace '{}' not found", workspace_id))?; + ws.webdav_url = Some(webdav_url); s.save_config() } @@ -562,9 +562,9 @@ fn set_workspace_theme( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { - ws.theme = theme; - } + let ws = s.config.workspaces.get_mut(&workspace_id) + .ok_or_else(|| format!("Workspace '{}' not found", workspace_id))?; + ws.theme = theme; s.save_config() } @@ -575,9 +575,9 @@ fn set_sync_interval( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { - ws.sync_interval_secs = interval_secs; - } + let ws = s.config.workspaces.get_mut(&workspace_id) + .ok_or_else(|| format!("Workspace '{}' not found", workspace_id))?; + ws.sync_interval_secs = interval_secs; s.save_config() } @@ -588,9 +588,9 @@ fn set_sync_interval_unfocused( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { - ws.sync_interval_unfocused_secs = interval_secs; - } + let ws = s.config.workspaces.get_mut(&workspace_id) + .ok_or_else(|| format!("Workspace '{}' not found", workspace_id))?; + ws.sync_interval_unfocused_secs = interval_secs; s.save_config() } diff --git a/apps/tauri/src/lib/components/TaskDetailView.svelte b/apps/tauri/src/lib/components/TaskDetailView.svelte index b1eb8c0..e0d99fe 100644 --- a/apps/tauri/src/lib/components/TaskDetailView.svelte +++ b/apps/tauri/src/lib/components/TaskDetailView.svelte @@ -32,19 +32,19 @@ if (isDesktop) appWindow.startDragging(); } - function debouncedSave(fields: Partial) { + function debouncedSave() { clearTimeout(saveTimer); saveTimer = setTimeout(() => { - app.updateTask({ ...task, ...fields }); + app.updateTask({ ...task, title: title.trim() || task.title, description }); }, 400); } function handleTitleInput() { - debouncedSave({ title: title.trim() || task.title }); + debouncedSave(); } function handleDescInput() { - debouncedSave({ description }); + debouncedSave(); } function handleDateChange(iso: string | null, hasTime: boolean = false) { diff --git a/crates/onyx-core/src/google_tasks.rs b/crates/onyx-core/src/google_tasks.rs index 1f4fb34..8bf3fb1 100644 --- a/crates/onyx-core/src/google_tasks.rs +++ b/crates/onyx-core/src/google_tasks.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::error::{Error, Result}; use crate::models::{Task, TaskStatus}; -use crate::storage::{ListMetadata, RootMetadata}; +use crate::storage::{ListMetadata, RootMetadata, atomic_write}; const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -348,7 +348,7 @@ pub async fn sync_google_tasks( }; let content = render_task_markdown(&task); - if let Err(e) = atomic_write_bytes(&task_path, content.as_bytes()) { + if let Err(e) = atomic_write(&task_path, content.as_bytes()) { errors.push(format!("Failed to write task '{}': {}", task.title, e)); } else { downloaded += 1; @@ -359,7 +359,7 @@ pub async fn sync_google_tasks( list_meta.updated_at = Utc::now(); if let Ok(meta_content) = serde_json::to_string_pretty(&list_meta) { - let _ = atomic_write_bytes(&listdata_path, meta_content.as_bytes()); + let _ = atomic_write(&listdata_path, meta_content.as_bytes()); } } @@ -375,7 +375,7 @@ pub async fn sync_google_tasks( }; root_meta.list_order = new_list_order; if let Ok(meta_content) = serde_json::to_string_pretty(&root_meta) { - let _ = atomic_write_bytes(&root_meta_path, meta_content.as_bytes()); + let _ = atomic_write(&root_meta_path, meta_content.as_bytes()); } Ok(GoogleSyncResult { downloaded, errors }) @@ -464,13 +464,3 @@ fn sanitize_name(name: &str) -> String { if s.is_empty() { "Untitled".to_string() } else { s } } -/// Write bytes to a file atomically (write to `.tmp`, then rename). -fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { - let temp = path.with_extension("tmp"); - std::fs::write(&temp, content)?; - if let Err(e) = std::fs::rename(&temp, path) { - let _ = std::fs::remove_file(&temp); - return Err(e); - } - Ok(()) -} diff --git a/crates/onyx-core/src/storage.rs b/crates/onyx-core/src/storage.rs index 9796b22..42d5801 100644 --- a/crates/onyx-core/src/storage.rs +++ b/crates/onyx-core/src/storage.rs @@ -27,7 +27,7 @@ const DEFAULT_TASK_VERSION: u64 = 1; /// Write data to a temporary file then atomically rename to the target path. /// Prevents corruption from partial writes on crash. Cleans up temp file on /// rename failure to prevent accumulation. -fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { +pub(crate) fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { let temp = path.with_extension("tmp"); fs::write(&temp, content)?; if let Err(e) = fs::rename(&temp, path) { @@ -759,7 +759,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let storage = init_storage(&temp_dir); - let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\nversion: 2\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes"; + let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndate: 2026-06-15T12:00:00Z\nversion: 2\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes"; let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap(); assert!(fm.date.is_some()); assert!(fm.parent.is_some());