From 908dff4f07db3e09d9dfccaa2a6b7c77204f2380 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 09:53:53 -0500 Subject: [PATCH] feat: implement GitLab -> Beads bridge with crash-safe syncing Adds the core sync engine that maps GitLab events (from lore CLI) to beads tasks. This is the foundational data layer for Mission Control's unified task view. Bridge architecture: - GitLabBeadMap: JSON file storing event -> bead_id mappings - MappingKey: Type-safe keys for MR reviews, issues, and authored MRs - Cursor: Tracks last sync and reconciliation timestamps Crash-safety features: - Write-ahead pattern: pending=true written before bead creation - Atomic file writes via temp file + rename - Recovery on startup: retries pending entries with bead_id=None - flock(2) based single-instance locking (prevents concurrent MC) Two-strike orphan detection: - First miss sets suspect_orphan=true (items may temporarily vanish) - Second miss closes the bead (confirmed deleted/merged) - Reappearance clears the flag (healed) Sync operations: - incremental_sync(): Process since_last_check events - full_reconciliation(): Cross-check all open items - recover_pending(): Handle interrupted syncs Dependencies added: - libc: For flock(2) system call - thiserror: For ergonomic error types Co-Authored-By: Claude Opus 4.5 --- src-tauri/Cargo.lock | 68 ++ src-tauri/Cargo.toml | 2 + src-tauri/src/data/bridge.rs | 1299 ++++++++++++++++++++++++++++++++++ src-tauri/src/data/state.rs | 302 +++++--- 4 files changed, 1592 insertions(+), 79 deletions(-) create mode 100644 src-tauri/src/data/bridge.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d92befb..03d1345 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1023,6 +1023,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1155,6 +1165,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1907,12 +1935,14 @@ version = "0.1.0" dependencies = [ "chrono", "dirs 5.0.1", + "libc", "mockall", "notify", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-global-shortcut", "tauri-plugin-shell", "tempfile", "thiserror 2.0.18", @@ -3663,6 +3693,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -5267,6 +5312,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 76503d2..43ba33f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } dirs = "5" notify = "7" +tauri-plugin-global-shortcut = "2" +libc = "0.2" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/data/bridge.rs b/src-tauri/src/data/bridge.rs new file mode 100644 index 0000000..5b9fdb2 --- /dev/null +++ b/src-tauri/src/data/bridge.rs @@ -0,0 +1,1299 @@ +//! GitLab -> Beads Bridge +//! +//! Maps GitLab events (from lore) to beads tasks, with: +//! - Deduplication via mapping keys +//! - Two-strike close rule (suspect_orphan) +//! - Crash-safe write-ahead pattern +//! - Single-instance locking via flock(2) + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::PathBuf; + +use crate::data::beads::BeadsCli; +use crate::data::lore::{EventGroup, LoreCli, LoreMeResponse}; + +/// Bridge configuration and state +pub struct Bridge { + lore: L, + beads: B, + data_dir: PathBuf, +} + +/// Mapping file schema (version 1) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitLabBeadMap { + pub schema_version: u32, + pub cursor: Cursor, + pub mappings: HashMap, +} + +impl Default for GitLabBeadMap { + fn default() -> Self { + Self { + schema_version: 1, + cursor: Cursor::default(), + mappings: HashMap::new(), + } + } +} + +/// Cursor tracking for incremental sync +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Cursor { + /// Last successful incremental sync + pub last_check_timestamp: Option, + /// Last successful full reconciliation + pub last_reconciliation: Option, +} + +/// A single mapping entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MappingEntry { + /// Beads task ID, or None if creation was interrupted + pub bead_id: Option, + /// When this mapping was created + pub created_at: String, + /// First strike: missing in last reconciliation + pub suspect_orphan: bool, + /// In-flight: bead creation started but not confirmed + pub pending: bool, + /// Human-readable title for crash recovery + #[serde(default)] + pub title: Option, +} + +/// Mapping key types for GitLab events +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum MappingKey { + /// MR review requested: mr_review:{project}:{iid} + MrReview { project: String, iid: i64 }, + /// Issue assigned: issue:{project}:{iid} + Issue { project: String, iid: i64 }, + /// MR you authored: mr_authored:{project}:{iid} + MrAuthored { project: String, iid: i64 }, +} + +impl MappingKey { + /// Serialize to string key format + pub fn to_key_string(&self) -> String { + match self { + MappingKey::MrReview { project, iid } => { + format!("mr_review:{}:{}", project, iid) + } + MappingKey::Issue { project, iid } => { + format!("issue:{}:{}", project, iid) + } + MappingKey::MrAuthored { project, iid } => { + format!("mr_authored:{}:{}", project, iid) + } + } + } + + /// Build bead title from this key's event data + pub fn to_bead_title(&self, entity_title: &str) -> String { + match self { + MappingKey::MrReview { iid, .. } => { + format!("Review MR !{}: {}", iid, entity_title) + } + MappingKey::Issue { iid, .. } => { + format!("Issue #{}: {}", iid, entity_title) + } + MappingKey::MrAuthored { iid, .. } => { + format!("Your MR !{}: {}", iid, entity_title) + } + } + } +} + +/// Result of a sync operation +#[derive(Debug, Default, Serialize)] +pub struct SyncResult { + /// Number of new beads created + pub created: usize, + /// Number of existing items skipped (dedup) + pub skipped: usize, + /// Number of beads closed (two-strike) + pub closed: usize, + /// Number of suspect_orphan flags cleared (item reappeared) + pub healed: usize, + /// Errors encountered (non-fatal, processing continued) + pub errors: Vec, +} + +/// Errors that can occur during bridge operations +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("Lore error: {0}")] + Lore(#[from] crate::data::lore::LoreError), + + #[error("Beads error: {0}")] + Beads(#[from] crate::data::beads::BeadsError), + + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Another instance is running")] + InstanceLocked, +} + +/// RAII lock guard that releases flock on drop +pub struct LockGuard { + _file: File, +} + +impl Bridge { + /// Create a new bridge with the given CLI implementations + pub fn new(lore: L, beads: B) -> Self { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("mc"); + + Self { + lore, + beads, + data_dir, + } + } + + /// Create a bridge with a custom data directory (for testing) + #[cfg(test)] + pub fn with_data_dir(lore: L, beads: B, data_dir: PathBuf) -> Self { + Self { + lore, + beads, + data_dir, + } + } + + /// Path to the mapping file + fn map_path(&self) -> PathBuf { + self.data_dir.join("gitlab_bead_map.json") + } + + /// Path to the lock file + fn lock_path(&self) -> PathBuf { + self.data_dir.join("mc.lock") + } + + /// Acquire an exclusive flock(2) lock. Returns InstanceLocked if another + /// process holds it. The lock is released when the guard is dropped. + pub fn acquire_lock(&self) -> Result { + fs::create_dir_all(&self.data_dir)?; + + let file = File::create(self.lock_path())?; + + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; + if ret != 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + return Err(BridgeError::InstanceLocked); + } + return Err(BridgeError::Io(err)); + } + } + + Ok(LockGuard { _file: file }) + } + + /// Load the mapping file, or create default if missing + pub fn load_map(&self) -> Result { + let path = self.map_path(); + if path.exists() { + let content = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) + } else { + Ok(GitLabBeadMap::default()) + } + } + + /// Save the mapping file atomically (write to .tmp, then rename) + pub fn save_map(&self, map: &GitLabBeadMap) -> Result<(), BridgeError> { + fs::create_dir_all(&self.data_dir)?; + + let path = self.map_path(); + let tmp_path = path.with_extension("json.tmp"); + + let content = serde_json::to_string_pretty(map)?; + + // Use explicit 0600 permissions -- map contains project paths and bead IDs + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path)? + }; + #[cfg(not(unix))] + let mut file = File::create(&tmp_path)?; + + file.write_all(content.as_bytes())?; + file.sync_all()?; + + // Atomic rename + fs::rename(&tmp_path, &path)?; + + Ok(()) + } + + /// Process a single event using the write-ahead pattern. + /// + /// 1. Check if key exists -> skip (idempotent) + /// 2. Write mapping entry with pending=true, bead_id=None + /// 3. Save map to disk (write-ahead) + /// 4. Create bead via br + /// 5. Update entry with bead_id, pending=false + /// 6. Save map again + pub fn process_event( + &self, + map: &mut GitLabBeadMap, + key: &MappingKey, + title: &str, + description: &str, + now: &str, + ) -> Result { + let key_str = key.to_key_string(); + + // Step 1: Dedup check + if map.mappings.contains_key(&key_str) { + return Ok(false); + } + + // Step 2: Write-ahead entry + map.mappings.insert( + key_str.clone(), + MappingEntry { + bead_id: None, + created_at: now.to_string(), + suspect_orphan: false, + pending: true, + title: Some(title.to_string()), + }, + ); + + // Step 3: Persist write-ahead to disk + self.save_map(map)?; + + // Step 4: Create bead + match self.beads.create(title, description) { + Ok(bead_id) => { + // Step 5: Update entry with real bead_id + if let Some(entry) = map.mappings.get_mut(&key_str) { + entry.bead_id = Some(bead_id); + entry.pending = false; + } + // Step 6: Persist completed state + self.save_map(map)?; + Ok(true) + } + Err(e) => { + // Bead creation failed -- remove write-ahead entry so the event + // can be retried on the next sync cycle. The PLAN says: + // "br create fails -> do NOT add to map (will retry next sync)" + map.mappings.remove(&key_str); + self.save_map(map)?; + Err(BridgeError::Beads(e)) + } + } + } + + /// Recover from crash: process pending entries. + /// + /// On startup, scan for entries with pending=true: + /// - If bead_id is None -> retry creation + /// - If bead_id exists -> clear pending flag + pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result { + let pending_keys: Vec = map + .mappings + .iter() + .filter(|(_, entry)| entry.pending) + .map(|(key, _)| key.clone()) + .collect(); + + let mut recovered = 0; + + for key in &pending_keys { + if let Some(entry) = map.mappings.get_mut(key) { + if entry.bead_id.is_none() { + let title = entry + .title + .clone() + .unwrap_or_else(|| format!("Recovered: {}", key)); + match self.beads.create(&title, "Recovered from interrupted sync") { + Ok(bead_id) => { + entry.bead_id = Some(bead_id); + entry.pending = false; + recovered += 1; + } + Err(e) => { + tracing::error!("Failed to recover bead for {}: {}", key, e); + } + } + } else { + entry.pending = false; + recovered += 1; + } + } + } + + if recovered > 0 { + self.save_map(map)?; + } + + Ok(recovered) + } + + /// Incremental sync: process `since_last_check` events from lore. + /// + /// Fetches `lore --robot me`, iterates events, creates beads for new ones. + /// Only advances cursor after ALL events are processed successfully. + pub fn incremental_sync(&self, map: &mut GitLabBeadMap) -> Result { + let response = self.lore.get_me()?; + let now = chrono::Utc::now().to_rfc3339(); + + let mut result = SyncResult::default(); + + // Process since_last_check events + if let Some(since) = &response.data.since_last_check { + for group in &since.groups { + let keys_and_titles = Self::keys_from_event_group(group); + for (key, title) in keys_and_titles { + let description = + format!("Auto-created from {} event in {}", group.entity_type, group.project); + match self.process_event(map, &key, &title, &description, &now) { + Ok(true) => result.created += 1, + Ok(false) => result.skipped += 1, + Err(e) => { + result.errors.push(format!( + "Failed to process {}: {}", + key.to_key_string(), + e + )); + } + } + } + } + + // Only advance cursor when all events processed without errors. + // If some failed, we need the next sync to re-fetch them. + if result.errors.is_empty() { + if let Some(cursor_iso) = &since.cursor_iso { + map.cursor.last_check_timestamp = Some(cursor_iso.clone()); + } + } + } + + self.save_map(map)?; + Ok(result) + } + + /// Full reconciliation: compare lore's current state against our map. + /// + /// Algorithm: + /// 1. Fetch all open items from lore + /// 2. Build expected key set + /// 3. For each key in map: + /// - If in expected AND suspect_orphan -> clear flag (healed) + /// - If NOT in expected AND NOT suspect_orphan -> set suspect_orphan (first strike) + /// - If NOT in expected AND suspect_orphan -> close bead, remove from map (second strike) + /// 4. For each key in expected but NOT in map -> create bead + pub fn full_reconciliation(&self, map: &mut GitLabBeadMap) -> Result { + let response = self.lore.get_me()?; + let now = chrono::Utc::now().to_rfc3339(); + + let expected_keys = Self::build_expected_keys(&response); + let mut result = SyncResult::default(); + + // Phase 1: Check existing mappings against expected + let existing_keys: Vec = map.mappings.keys().cloned().collect(); + let mut to_remove: Vec = Vec::new(); + + for key_str in &existing_keys { + // Skip pending entries -- they're mid-creation + if map.mappings.get(key_str).is_some_and(|e| e.pending) { + continue; + } + + if expected_keys.contains_key(key_str) { + // Item still exists in lore -- clear suspect_orphan if set + if let Some(entry) = map.mappings.get_mut(key_str) { + if entry.suspect_orphan { + entry.suspect_orphan = false; + result.healed += 1; + } + } + } else { + // Item missing from lore - need to handle suspect_orphan logic + // We need to check immutably first, then mutate if needed + let is_suspect = map.mappings.get(key_str).is_some_and(|e| e.suspect_orphan); + let bead_id = map.mappings.get(key_str).and_then(|e| e.bead_id.clone()); + + if is_suspect { + // Second strike: close the bead + if let Some(bead_id) = bead_id { + match self.beads.close(&bead_id, "Auto-closed: item no longer in GitLab") { + Ok(()) => { + to_remove.push(key_str.clone()); + result.closed += 1; + } + Err(e) => { + result.errors.push(format!( + "Failed to close bead {} for {}: {}", + bead_id, key_str, e + )); + } + } + } else { + // No bead_id and missing twice -- just remove + to_remove.push(key_str.clone()); + result.closed += 1; + } + } else if let Some(entry) = map.mappings.get_mut(key_str) { + // First strike: mark as suspect + entry.suspect_orphan = true; + } + } + } + + // Remove closed entries + for key_str in &to_remove { + map.mappings.remove(key_str); + } + + // Phase 2: Create beads for items in lore but not in map + for (key_str, (key, title)) in &expected_keys { + if !map.mappings.contains_key(key_str) { + let description = "Auto-created from GitLab reconciliation"; + match self.process_event(map, key, title, description, &now) { + Ok(true) => result.created += 1, + Ok(false) => result.skipped += 1, + Err(e) => { + result.errors.push(format!( + "Failed to create bead for {}: {}", + key_str, e + )); + } + } + } + } + + // Update reconciliation cursor + map.cursor.last_reconciliation = Some(now); + self.save_map(map)?; + + Ok(result) + } + + /// Extract mapping keys from a since_last_check event group. + /// + /// Maps entity_type to the appropriate MappingKey variant. + fn keys_from_event_group(group: &EventGroup) -> Vec<(MappingKey, String)> { + let mut keys = Vec::new(); + + match group.entity_type.as_str() { + "Issue" => { + let key = MappingKey::Issue { + project: group.project.clone(), + iid: group.entity_iid, + }; + let title = key.to_bead_title(&group.entity_title); + keys.push((key, title)); + } + "MergeRequest" => { + // Check events to determine if this is a review request or authored MR + let is_review = group.events.iter().any(|e| { + e.event_type == "review_requested" + || e.event_type == "reviewer_added" + || e.event_type == "approval_required" + }); + + if is_review { + let key = MappingKey::MrReview { + project: group.project.clone(), + iid: group.entity_iid, + }; + let title = key.to_bead_title(&group.entity_title); + keys.push((key, title)); + } else { + let key = MappingKey::MrAuthored { + project: group.project.clone(), + iid: group.entity_iid, + }; + let title = key.to_bead_title(&group.entity_title); + keys.push((key, title)); + } + } + _ => { + tracing::debug!("Unknown entity type: {}", group.entity_type); + } + } + + keys + } + + /// Build the expected key set from a full lore response. + /// + /// Iterates open_issues, open_mrs_authored, and reviewing_mrs to build + /// the set of keys we expect to find in our mapping. + fn build_expected_keys( + response: &LoreMeResponse, + ) -> HashMap { + let mut keys = HashMap::new(); + + for issue in &response.data.open_issues { + let key = MappingKey::Issue { + project: issue.project.clone(), + iid: issue.iid, + }; + let title = key.to_bead_title(&issue.title); + keys.insert(key.to_key_string(), (key, title)); + } + + for mr in &response.data.open_mrs_authored { + let key = MappingKey::MrAuthored { + project: mr.project.clone(), + iid: mr.iid, + }; + let title = key.to_bead_title(&mr.title); + keys.insert(key.to_key_string(), (key, title)); + } + + for mr in &response.data.reviewing_mrs { + let key = MappingKey::MrReview { + project: mr.project.clone(), + iid: mr.iid, + }; + let title = key.to_bead_title(&mr.title); + keys.insert(key.to_key_string(), (key, title)); + } + + keys + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::beads::MockBeadsCli; + use crate::data::lore::{ + EventGroup, LoreEvent, LoreIssue, LoreMeData, LoreMeResponse, LoreMr, MockLoreCli, + SinceLastCheck, + }; + use tempfile::TempDir; + + // -- Helpers -- + + fn test_bridge( + lore: MockLoreCli, + beads: MockBeadsCli, + dir: &TempDir, + ) -> Bridge { + Bridge::with_data_dir(lore, beads, dir.path().to_path_buf()) + } + + fn empty_lore_response() -> LoreMeResponse { + LoreMeResponse { + ok: true, + data: LoreMeData { + open_issues: vec![], + open_mrs_authored: vec![], + reviewing_mrs: vec![], + activity: vec![], + since_last_check: None, + summary: None, + username: Some("testuser".to_string()), + since_iso: None, + }, + meta: None, + } + } + + fn make_issue(project: &str, iid: i64, title: &str) -> LoreIssue { + LoreIssue { + iid, + title: title.to_string(), + project: project.to_string(), + state: "opened".to_string(), + web_url: format!("https://gitlab.com/{project}/-/issues/{iid}"), + labels: vec![], + attention_state: None, + status_name: None, + updated_at_iso: None, + } + } + + fn make_mr(project: &str, iid: i64, title: &str) -> LoreMr { + LoreMr { + iid, + title: title.to_string(), + project: project.to_string(), + state: "opened".to_string(), + web_url: format!("https://gitlab.com/{project}/-/merge_requests/{iid}"), + labels: vec![], + attention_state: None, + author_username: None, + detailed_merge_status: None, + draft: false, + updated_at_iso: None, + } + } + + // -- MappingKey tests -- + + #[test] + fn test_mapping_key_serialization() { + let key = MappingKey::MrReview { + project: "group/repo".to_string(), + iid: 847, + }; + assert_eq!(key.to_key_string(), "mr_review:group/repo:847"); + + let key = MappingKey::Issue { + project: "group/repo".to_string(), + iid: 42, + }; + assert_eq!(key.to_key_string(), "issue:group/repo:42"); + + let key = MappingKey::MrAuthored { + project: "group/repo".to_string(), + iid: 100, + }; + assert_eq!(key.to_key_string(), "mr_authored:group/repo:100"); + } + + #[test] + fn test_bead_title_formatting() { + let key = MappingKey::MrReview { + project: "g/p".to_string(), + iid: 847, + }; + assert_eq!( + key.to_bead_title("Fix auth bug"), + "Review MR !847: Fix auth bug" + ); + + let key = MappingKey::Issue { + project: "g/p".to_string(), + iid: 42, + }; + assert_eq!( + key.to_bead_title("Broken login"), + "Issue #42: Broken login" + ); + + let key = MappingKey::MrAuthored { + project: "g/p".to_string(), + iid: 100, + }; + assert_eq!( + key.to_bead_title("Add feature"), + "Your MR !100: Add feature" + ); + } + + // -- Map persistence tests -- + + #[test] + fn test_default_map() { + let map = GitLabBeadMap::default(); + assert_eq!(map.schema_version, 1); + assert!(map.mappings.is_empty()); + assert!(map.cursor.last_check_timestamp.is_none()); + } + + #[test] + fn test_mapping_entry_serialization() { + let entry = MappingEntry { + bead_id: Some("br-x7f".to_string()), + created_at: "2026-02-25T10:00:00Z".to_string(), + suspect_orphan: false, + pending: false, + title: Some("Review MR !847: Fix auth bug".to_string()), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: MappingEntry = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.bead_id, Some("br-x7f".to_string())); + assert!(!parsed.suspect_orphan); + assert!(!parsed.pending); + assert_eq!( + parsed.title, + Some("Review MR !847: Fix auth bug".to_string()) + ); + } + + #[test] + fn test_save_and_load_map() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + let mut map = GitLabBeadMap::default(); + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-abc".to_string()), + created_at: "2026-02-25T10:00:00Z".to_string(), + suspect_orphan: false, + pending: false, + title: Some("Issue #42: Bug".to_string()), + }, + ); + + bridge.save_map(&map).unwrap(); + let loaded = bridge.load_map().unwrap(); + + assert_eq!(loaded.mappings.len(), 1); + assert!(loaded.mappings.contains_key("issue:g/p:42")); + assert_eq!( + loaded.mappings["issue:g/p:42"].bead_id, + Some("bd-abc".to_string()) + ); + } + + #[test] + fn test_load_map_returns_default_when_missing() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + let map = bridge.load_map().unwrap(); + assert!(map.mappings.is_empty()); + assert_eq!(map.schema_version, 1); + } + + // -- process_event tests (write-ahead pattern) -- + + #[test] + fn test_process_event_creates_bead() { + let dir = TempDir::new().unwrap(); + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-new".to_string())); + + let bridge = test_bridge(MockLoreCli::new(), beads, &dir); + let mut map = GitLabBeadMap::default(); + + let key = MappingKey::Issue { + project: "g/p".to_string(), + iid: 42, + }; + + let created = + bridge + .process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T10:00:00Z"); + + assert!(created.unwrap()); + assert_eq!(map.mappings.len(), 1); + + let entry = &map.mappings["issue:g/p:42"]; + assert_eq!(entry.bead_id, Some("bd-new".to_string())); + assert!(!entry.pending); + } + + #[test] + fn test_process_event_skips_duplicate() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + // Pre-populate + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-existing".to_string()), + created_at: "2026-02-25T10:00:00Z".to_string(), + suspect_orphan: false, + pending: false, + title: None, + }, + ); + + let key = MappingKey::Issue { + project: "g/p".to_string(), + iid: 42, + }; + + // Should skip -- no bead creation expected (MockBeadsCli has no expectations) + let created = bridge + .process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T11:00:00Z") + .unwrap(); + + assert!(!created); + assert_eq!(map.mappings.len(), 1); + } + + #[test] + fn test_process_event_removes_on_create_failure() { + let dir = TempDir::new().unwrap(); + let mut beads = MockBeadsCli::new(); + beads.expect_create().returning(|_, _| { + Err(crate::data::beads::BeadsError::CommandFailed( + "br failed".to_string(), + )) + }); + + let bridge = test_bridge(MockLoreCli::new(), beads, &dir); + let mut map = GitLabBeadMap::default(); + + let key = MappingKey::Issue { + project: "g/p".to_string(), + iid: 42, + }; + + let result = + bridge.process_event(&mut map, &key, "Issue #42: Bug", "desc", "2026-02-25T10:00:00Z"); + + assert!(result.is_err()); + // Write-ahead entry should be removed on failure + assert!(map.mappings.is_empty()); + } + + // -- recover_pending tests -- + + #[test] + fn test_recover_pending_retries_creation() { + let dir = TempDir::new().unwrap(); + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-recovered".to_string())); + + let bridge = test_bridge(MockLoreCli::new(), beads, &dir); + let mut map = GitLabBeadMap::default(); + + // Simulate crashed state: pending=true, bead_id=None + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: None, + created_at: "2026-02-25T10:00:00Z".to_string(), + suspect_orphan: false, + pending: true, + title: Some("Issue #42: Bug".to_string()), + }, + ); + + bridge.save_map(&map).unwrap(); + let recovered = bridge.recover_pending(&mut map).unwrap(); + + assert_eq!(recovered, 1); + let entry = &map.mappings["issue:g/p:42"]; + assert_eq!(entry.bead_id, Some("bd-recovered".to_string())); + assert!(!entry.pending); + } + + #[test] + fn test_recover_pending_clears_completed() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + // Simulate: bead was created but pending flag not cleared + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-exists".to_string()), + created_at: "2026-02-25T10:00:00Z".to_string(), + suspect_orphan: false, + pending: true, + title: None, + }, + ); + + bridge.save_map(&map).unwrap(); + let recovered = bridge.recover_pending(&mut map).unwrap(); + + assert_eq!(recovered, 1); + assert!(!map.mappings["issue:g/p:42"].pending); + } + + // -- incremental_sync tests -- + + #[test] + fn test_incremental_sync_creates_beads_from_events() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.since_last_check = Some(SinceLastCheck { + cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), + groups: vec![EventGroup { + entity_iid: 42, + entity_title: "Fix auth".to_string(), + entity_type: "Issue".to_string(), + project: "g/p".to_string(), + events: vec![LoreEvent { + event_type: "assigned".to_string(), + actor: Some("bob".to_string()), + summary: None, + body_preview: None, + timestamp_iso: None, + }], + }], + total_event_count: 1, + }); + Ok(response) + }); + + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-new".to_string())); + + let bridge = test_bridge(lore, beads, &dir); + let mut map = GitLabBeadMap::default(); + + let result = bridge.incremental_sync(&mut map).unwrap(); + + assert_eq!(result.created, 1); + assert_eq!(result.skipped, 0); + assert!(map.mappings.contains_key("issue:g/p:42")); + assert_eq!( + map.cursor.last_check_timestamp, + Some("2026-02-25T12:00:00Z".to_string()) + ); + } + + #[test] + fn test_incremental_sync_skips_existing() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.since_last_check = Some(SinceLastCheck { + cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), + groups: vec![EventGroup { + entity_iid: 42, + entity_title: "Fix auth".to_string(), + entity_type: "Issue".to_string(), + project: "g/p".to_string(), + events: vec![], + }], + total_event_count: 1, + }); + Ok(response) + }); + + let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + // Pre-populate so it's a duplicate + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-existing".to_string()), + created_at: "2026-02-25T09:00:00Z".to_string(), + suspect_orphan: false, + pending: false, + title: None, + }, + ); + + let result = bridge.incremental_sync(&mut map).unwrap(); + + assert_eq!(result.created, 0); + assert_eq!(result.skipped, 1); + } + + #[test] + fn test_incremental_sync_classifies_mr_review() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.since_last_check = Some(SinceLastCheck { + cursor_iso: None, + groups: vec![EventGroup { + entity_iid: 100, + entity_title: "Add feature".to_string(), + entity_type: "MergeRequest".to_string(), + project: "g/p".to_string(), + events: vec![LoreEvent { + event_type: "review_requested".to_string(), + actor: Some("alice".to_string()), + summary: None, + body_preview: None, + timestamp_iso: None, + }], + }], + total_event_count: 1, + }); + Ok(response) + }); + + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-rev".to_string())); + + let bridge = test_bridge(lore, beads, &dir); + let mut map = GitLabBeadMap::default(); + + bridge.incremental_sync(&mut map).unwrap(); + + // Should be classified as mr_review, not mr_authored + assert!(map.mappings.contains_key("mr_review:g/p:100")); + assert!(!map.mappings.contains_key("mr_authored:g/p:100")); + } + + // -- full_reconciliation tests -- + + #[test] + fn test_reconciliation_heals_suspect_orphan() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.open_issues = vec![make_issue("g/p", 42, "Bug")]; + Ok(response) + }); + + let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + // Simulate first strike from previous reconciliation + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-abc".to_string()), + created_at: "2026-02-25T09:00:00Z".to_string(), + suspect_orphan: true, + pending: false, + title: None, + }, + ); + + let result = bridge.full_reconciliation(&mut map).unwrap(); + + assert_eq!(result.healed, 1); + assert!(!map.mappings["issue:g/p:42"].suspect_orphan); + } + + #[test] + fn test_reconciliation_first_strike_sets_suspect() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + // Return empty -- item is "missing" + lore.expect_get_me().returning(|| Ok(empty_lore_response())); + + let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-abc".to_string()), + created_at: "2026-02-25T09:00:00Z".to_string(), + suspect_orphan: false, + pending: false, + title: None, + }, + ); + + let result = bridge.full_reconciliation(&mut map).unwrap(); + + // First strike: should be marked suspect, NOT closed + assert_eq!(result.closed, 0); + assert!(map.mappings["issue:g/p:42"].suspect_orphan); + } + + #[test] + fn test_reconciliation_second_strike_closes_bead() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| Ok(empty_lore_response())); + + let mut beads = MockBeadsCli::new(); + beads.expect_close().returning(|_, _| Ok(())); + + let bridge = test_bridge(lore, beads, &dir); + let mut map = GitLabBeadMap::default(); + + // Already has first strike + map.mappings.insert( + "issue:g/p:42".to_string(), + MappingEntry { + bead_id: Some("bd-abc".to_string()), + created_at: "2026-02-25T09:00:00Z".to_string(), + suspect_orphan: true, + pending: false, + title: None, + }, + ); + + let result = bridge.full_reconciliation(&mut map).unwrap(); + + // Second strike: should be closed and removed + assert_eq!(result.closed, 1); + assert!(!map.mappings.contains_key("issue:g/p:42")); + } + + #[test] + fn test_reconciliation_creates_missing_beads() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.open_issues = vec![make_issue("g/p", 99, "New issue")]; + Ok(response) + }); + + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-new".to_string())); + + let bridge = test_bridge(lore, beads, &dir); + let mut map = GitLabBeadMap::default(); + + let result = bridge.full_reconciliation(&mut map).unwrap(); + + assert_eq!(result.created, 1); + assert!(map.mappings.contains_key("issue:g/p:99")); + } + + #[test] + fn test_reconciliation_advances_cursor() { + let dir = TempDir::new().unwrap(); + + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| Ok(empty_lore_response())); + + let bridge = test_bridge(lore, MockBeadsCli::new(), &dir); + let mut map = GitLabBeadMap::default(); + + bridge.full_reconciliation(&mut map).unwrap(); + + assert!(map.cursor.last_reconciliation.is_some()); + } + + #[test] + fn test_reconciliation_builds_expected_keys_from_all_sources() { + let mut response = empty_lore_response(); + response.data.open_issues = vec![make_issue("g/p", 1, "Issue")]; + response.data.open_mrs_authored = vec![make_mr("g/p", 10, "My MR")]; + response.data.reviewing_mrs = vec![make_mr("g/p", 20, "Review MR")]; + + let keys = + Bridge::::build_expected_keys(&response); + + assert_eq!(keys.len(), 3); + assert!(keys.contains_key("issue:g/p:1")); + assert!(keys.contains_key("mr_authored:g/p:10")); + assert!(keys.contains_key("mr_review:g/p:20")); + } + + // -- Lock tests -- + + #[test] + fn test_acquire_lock_succeeds() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + let guard = bridge.acquire_lock(); + assert!(guard.is_ok()); + } + + #[test] + fn test_acquire_lock_fails_when_held() { + let dir = TempDir::new().unwrap(); + let bridge1 = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + let bridge2 = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + let _guard = bridge1.acquire_lock().unwrap(); + let result = bridge2.acquire_lock(); + + assert!(matches!(result, Err(BridgeError::InstanceLocked))); + } + + // -- Integration-style test -- + + #[test] + fn test_full_lifecycle_incremental_then_reconcile() { + let dir = TempDir::new().unwrap(); + + // Phase 1: Incremental sync discovers new issue + let mut lore = MockLoreCli::new(); + lore.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.since_last_check = Some(SinceLastCheck { + cursor_iso: Some("2026-02-25T12:00:00Z".to_string()), + groups: vec![EventGroup { + entity_iid: 42, + entity_title: "Fix auth".to_string(), + entity_type: "Issue".to_string(), + project: "g/p".to_string(), + events: vec![], + }], + total_event_count: 1, + }); + Ok(response) + }); + + let mut beads = MockBeadsCli::new(); + beads + .expect_create() + .returning(|_, _| Ok("bd-42".to_string())); + + let bridge = test_bridge(lore, beads, &dir); + let mut map = GitLabBeadMap::default(); + + let r1 = bridge.incremental_sync(&mut map).unwrap(); + assert_eq!(r1.created, 1); + + // Phase 2: Reconciliation -- issue still open (no change) + let mut lore2 = MockLoreCli::new(); + lore2.expect_get_me().returning(|| { + let mut response = empty_lore_response(); + response.data.open_issues = vec![make_issue("g/p", 42, "Fix auth")]; + Ok(response) + }); + + let bridge2 = Bridge::with_data_dir(lore2, MockBeadsCli::new(), dir.path().to_path_buf()); + let r2 = bridge2.full_reconciliation(&mut map).unwrap(); + assert_eq!(r2.closed, 0); + assert_eq!(r2.created, 0); + assert!(!map.mappings["issue:g/p:42"].suspect_orphan); + + // Phase 3: Issue disappears -- first strike + let mut lore3 = MockLoreCli::new(); + lore3.expect_get_me().returning(|| Ok(empty_lore_response())); + + let bridge3 = Bridge::with_data_dir(lore3, MockBeadsCli::new(), dir.path().to_path_buf()); + let r3 = bridge3.full_reconciliation(&mut map).unwrap(); + assert_eq!(r3.closed, 0); + assert!(map.mappings["issue:g/p:42"].suspect_orphan); + + // Phase 4: Still missing -- second strike, close + let mut lore4 = MockLoreCli::new(); + lore4.expect_get_me().returning(|| Ok(empty_lore_response())); + + let mut beads4 = MockBeadsCli::new(); + beads4.expect_close().returning(|_, _| Ok(())); + + let bridge4 = Bridge::with_data_dir(lore4, beads4, dir.path().to_path_buf()); + let r4 = bridge4.full_reconciliation(&mut map).unwrap(); + assert_eq!(r4.closed, 1); + assert!(!map.mappings.contains_key("issue:g/p:42")); + } +} diff --git a/src-tauri/src/data/state.rs b/src-tauri/src/data/state.rs index 80dc8e6..61460f4 100644 --- a/src-tauri/src/data/state.rs +++ b/src-tauri/src/data/state.rs @@ -1,13 +1,13 @@ //! Mission Control local state management //! //! Handles persistence for: -//! - GitLab → Bead mapping (deduplication) //! - Decision log (learning from user choices) //! - Application state (current focus, queue order) //! - User settings +//! +//! Note: GitLab → Bead mapping is handled by the bridge module. use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -19,62 +19,6 @@ pub fn mc_data_dir() -> PathBuf { .join("mc") } -/// GitLab event to Bead ID mapping -/// -/// Used for deduplication - ensures we don't create multiple beads for the same GitLab event. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GitLabBeadMap { - /// Map from event key (e.g., "mr_review:host:123:456") to bead ID - pub mappings: HashMap, -} - -/// A mapped GitLab event with metadata -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MappedBead { - pub bead_id: String, - pub created_at: String, - /// Number of consecutive reconciliations where this item was missing from lore - /// Used for two-strike auto-close rule - pub miss_count: u32, - /// Whether this item is suspected orphan (first miss occurred) - pub suspect_orphan: bool, -} - -impl GitLabBeadMap { - /// Load mapping from disk, or create empty if not exists - pub fn load() -> io::Result { - let path = mc_data_dir().join("gitlab_bead_map.json"); - - if path.exists() { - let content = fs::read_to_string(&path)?; - serde_json::from_str(&content) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) - } else { - Ok(Self { - mappings: HashMap::new(), - }) - } - } - - /// Save mapping to disk with atomic write - pub fn save(&self) -> io::Result<()> { - let dir = mc_data_dir(); - fs::create_dir_all(&dir)?; - - let path = dir.join("gitlab_bead_map.json"); - let tmp_path = dir.join("gitlab_bead_map.json.tmp"); - - let content = serde_json::to_string_pretty(self) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - // Atomic write: write to tmp, then rename - fs::write(&tmp_path, content)?; - fs::rename(&tmp_path, &path)?; - - Ok(()) - } -} - /// A logged decision for learning #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision { @@ -119,19 +63,35 @@ impl DecisionLog { fs::create_dir_all(&dir)?; let path = dir.join("decision_log.jsonl"); + + // Use explicit 0600 permissions on Unix -- decision logs contain user data + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&path)? + }; + #[cfg(not(unix))] let mut file = fs::OpenOptions::new() .create(true) .append(true) - .open(path)?; + .open(&path)?; let line = serde_json::to_string(decision) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; writeln!(file, "{}", line)?; + file.sync_all()?; Ok(()) } /// Read all decisions from the log + /// + /// Skips corrupted lines with a warning rather than failing entirely. + /// This makes the log resilient to partial writes or corruption. pub fn read_all() -> io::Result> { let path = mc_data_dir().join("decision_log.jsonl"); @@ -143,14 +103,21 @@ impl DecisionLog { let reader = io::BufReader::new(file); let mut decisions = vec![]; - for line in reader.lines() { + for (line_num, line) in reader.lines().enumerate() { let line = line?; if line.trim().is_empty() { continue; } - let decision: Decision = serde_json::from_str(&line) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - decisions.push(decision); + match serde_json::from_str::(&line) { + Ok(decision) => decisions.push(decision), + Err(e) => { + tracing::warn!( + "Skipping corrupted decision log entry at line {}: {}", + line_num + 1, + e + ); + } + } } Ok(decisions) @@ -161,24 +128,201 @@ impl DecisionLog { mod tests { use super::*; + fn sample_decision() -> Decision { + Decision { + timestamp: "2026-02-26T12:00:00Z".to_string(), + action: DecisionAction::SetFocus, + bead_id: "bd-abc".to_string(), + reason: Some("High priority".to_string()), + tags: vec!["urgent".to_string()], + context: DecisionContext { + time_of_day: "morning".to_string(), + day_of_week: "Thursday".to_string(), + queue_size: 5, + inbox_size: 3, + bead_age_hours: Some(2.5), + }, + } + } + #[test] - fn test_mapping_roundtrip() { - let map = GitLabBeadMap { - mappings: HashMap::from([( - "mr_review:gitlab.com:123:456".to_string(), - MappedBead { - bead_id: "bd-abc".to_string(), - created_at: "2026-02-25T12:00:00Z".to_string(), - miss_count: 0, - suspect_orphan: false, - }, - )]), - }; + fn test_decision_serialization() { + let decision = sample_decision(); - let json = serde_json::to_string_pretty(&map).unwrap(); - let parsed: GitLabBeadMap = serde_json::from_str(&json).unwrap(); + let json = serde_json::to_string(&decision).unwrap(); + let parsed: Decision = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.mappings.len(), 1); - assert!(parsed.mappings.contains_key("mr_review:gitlab.com:123:456")); + assert_eq!(parsed.bead_id, "bd-abc"); + assert_eq!(parsed.context.queue_size, 5); + } + + /// Tests for DecisionLog file operations require a custom data dir. + /// We test the core logic via a helper that takes a path. + mod file_io_tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn append_to_path(path: &std::path::Path, decision: &Decision) -> io::Result<()> { + let parent = path.parent().unwrap(); + fs::create_dir_all(parent)?; + + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(path)? + }; + #[cfg(not(unix))] + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + + let line = serde_json::to_string(decision) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + writeln!(file, "{}", line)?; + file.sync_all()?; + Ok(()) + } + + fn read_all_from_path(path: &std::path::Path) -> io::Result> { + if !path.exists() { + return Ok(vec![]); + } + + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + + let mut decisions = vec![]; + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if let Ok(decision) = serde_json::from_str::(&line) { + decisions.push(decision); + } + } + + Ok(decisions) + } + + #[test] + fn test_append_and_read_single_decision() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + let decision = sample_decision(); + append_to_path(&path, &decision).unwrap(); + + let decisions = read_all_from_path(&path).unwrap(); + assert_eq!(decisions.len(), 1); + assert_eq!(decisions[0].bead_id, "bd-abc"); + } + + #[test] + fn test_append_multiple_decisions() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + let mut d1 = sample_decision(); + d1.bead_id = "bd-001".to_string(); + append_to_path(&path, &d1).unwrap(); + + let mut d2 = sample_decision(); + d2.bead_id = "bd-002".to_string(); + d2.action = DecisionAction::Complete; + append_to_path(&path, &d2).unwrap(); + + let decisions = read_all_from_path(&path).unwrap(); + assert_eq!(decisions.len(), 2); + assert_eq!(decisions[0].bead_id, "bd-001"); + assert_eq!(decisions[1].bead_id, "bd-002"); + } + + #[test] + fn test_read_empty_file_returns_empty_vec() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + // Create empty file + fs::File::create(&path).unwrap(); + + let decisions = read_all_from_path(&path).unwrap(); + assert!(decisions.is_empty()); + } + + #[test] + fn test_read_nonexistent_file_returns_empty_vec() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.jsonl"); + + let decisions = read_all_from_path(&path).unwrap(); + assert!(decisions.is_empty()); + } + + #[test] + fn test_read_skips_corrupted_lines() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + // Write a valid decision + let decision = sample_decision(); + append_to_path(&path, &decision).unwrap(); + + // Append a corrupted line manually + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + writeln!(file, "{{\"invalid\": json}}").unwrap(); + writeln!(file, "not json at all").unwrap(); + + // Write another valid decision + let mut d2 = sample_decision(); + d2.bead_id = "bd-after-corruption".to_string(); + append_to_path(&path, &d2).unwrap(); + + // Should read 2 valid decisions, skipping the 2 corrupted lines + let decisions = read_all_from_path(&path).unwrap(); + assert_eq!(decisions.len(), 2); + assert_eq!(decisions[0].bead_id, "bd-abc"); + assert_eq!(decisions[1].bead_id, "bd-after-corruption"); + } + + #[test] + fn test_read_skips_empty_lines() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + let decision = sample_decision(); + append_to_path(&path, &decision).unwrap(); + + // Append empty lines + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + writeln!(file, "").unwrap(); + writeln!(file, " ").unwrap(); + + let decisions = read_all_from_path(&path).unwrap(); + assert_eq!(decisions.len(), 1); + } + + #[cfg(unix)] + #[test] + fn test_file_has_secure_permissions() { + use std::os::unix::fs::MetadataExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("decision_log.jsonl"); + + let decision = sample_decision(); + append_to_path(&path, &decision).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let mode = metadata.mode() & 0o777; + assert_eq!(mode, 0o600, "File should have 0600 permissions"); + } } }