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"); + } } }