From bb1b608fbb13c900908cc8c513ff5b6328b3ec88 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 17:01:25 -0500 Subject: [PATCH] feat: add trait-based CLI integration for lore and beads Implement the data layer with mockable CLI wrappers for testability: CLI Traits (data/*.rs): - LoreCli: Trait for lore --robot commands (get_me, health_check) - BeadsCli: Trait for br commands (create, close, list) - Both use #[automock] for unit testing without real CLI Real Implementations: - RealLoreCli: Shells to 'lore --robot me', parses JSON response - RealBeadsCli: Shells to 'br create/close/list --json' Type Definitions: - LoreMeResponse: Full response from 'lore --robot me' - open_issues, open_mrs_authored, reviewing_mrs, activity - since_last_check with EventGroup for inbox functionality - All fields use #[serde(default)] for forward compatibility - Bead: Task from br list (id, title, status, priority, issue_type) Local State Management (data/state.rs): - GitLabBeadMap: Deduplication mapping (GitLab event -> bead ID) - MappedBead: Tracks miss_count for two-strike orphan detection - DecisionLog: Append-only JSONL for learning from user choices - Atomic writes via .tmp files + rename pattern Tauri Commands (commands/mod.rs): - greet: Placeholder for IPC testing - get_lore_status: Exposes lore health to frontend This establishes the CLI-over-library pattern from PLAN.md: clean boundaries, no schema coupling, full testability via mocks. --- src-tauri/src/commands/mod.rs | 30 ++++ src-tauri/src/data/beads.rs | 203 +++++++++++++++++++++ src-tauri/src/data/lore.rs | 328 ++++++++++++++++++++++++++++++++++ src-tauri/src/data/mod.rs | 10 ++ src-tauri/src/data/state.rs | 184 +++++++++++++++++++ 5 files changed, 755 insertions(+) create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/data/beads.rs create mode 100644 src-tauri/src/data/lore.rs create mode 100644 src-tauri/src/data/mod.rs create mode 100644 src-tauri/src/data/state.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..27d1bf1 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,30 @@ +//! Tauri command handlers +//! +//! These functions are exposed to the frontend via Tauri's IPC system. + +use serde::Serialize; + +/// Simple greeting command for testing IPC +#[tauri::command] +pub fn greet(name: &str) -> String { + format!("Hello, {}! Welcome to Mission Control.", name) +} + +/// Lore sync status +#[derive(Debug, Clone, Serialize)] +pub struct LoreStatus { + pub last_sync: Option, + pub is_healthy: bool, + pub message: String, +} + +/// Get the current status of lore integration +#[tauri::command] +pub async fn get_lore_status() -> Result { + // TODO: Implement actual lore status check + Ok(LoreStatus { + last_sync: None, + is_healthy: true, + message: "lore integration not yet implemented".to_string(), + }) +} diff --git a/src-tauri/src/data/beads.rs b/src-tauri/src/data/beads.rs new file mode 100644 index 0000000..8553c22 --- /dev/null +++ b/src-tauri/src/data/beads.rs @@ -0,0 +1,203 @@ +//! beads CLI integration (br, bv) +//! +//! Provides trait-based abstraction over beads CLI tools for testability. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[cfg(test)] +use mockall::automock; + +/// Trait for interacting with beads CLI (br) +/// +/// This abstraction allows us to mock br in tests. +#[cfg_attr(test, automock)] +pub trait BeadsCli: Send + Sync { + /// Create a new bead via `br create` + fn create(&self, title: &str, description: &str) -> Result; + + /// Close a bead via `br close` + fn close(&self, bead_id: &str, reason: &str) -> Result<(), BeadsError>; + + /// List all beads via `br list --json` + fn list(&self) -> Result, BeadsError>; +} + +/// Real implementation that shells out to br CLI +#[derive(Debug, Default)] +pub struct RealBeadsCli; + +impl BeadsCli for RealBeadsCli { + fn create(&self, title: &str, description: &str) -> Result { + let output = Command::new("br") + .args(["create", "--title", title, "--description", description, "--json"]) + .output() + .map_err(|e| BeadsError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BeadsError::CommandFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let response: CreateResponse = serde_json::from_str(&stdout) + .map_err(|e| BeadsError::ParseFailed(e.to_string()))?; + + Ok(response.id) + } + + fn close(&self, bead_id: &str, reason: &str) -> Result<(), BeadsError> { + let output = Command::new("br") + .args(["close", bead_id, "--reason", reason]) + .output() + .map_err(|e| BeadsError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BeadsError::CommandFailed(stderr.to_string())); + } + + Ok(()) + } + + fn list(&self) -> Result, BeadsError> { + let output = Command::new("br") + .args(["list", "--json"]) + .output() + .map_err(|e| BeadsError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BeadsError::CommandFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| BeadsError::ParseFailed(e.to_string())) + } +} + +/// Errors that can occur when interacting with beads CLI +#[derive(Debug, Clone, thiserror::Error)] +pub enum BeadsError { + #[error("Failed to execute br command: {0}")] + ExecutionFailed(String), + + #[error("br command failed: {0}")] + CommandFailed(String), + + #[error("Failed to parse br output: {0}")] + ParseFailed(String), +} + +/// Response from br create --json +#[derive(Debug, Deserialize)] +struct CreateResponse { + id: String, +} + +/// A bead from br list +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bead { + pub id: String, + pub title: String, + pub description: String, + pub status: String, + pub priority: i32, + pub issue_type: String, + pub created_at: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_bead() -> Bead { + Bead { + id: "bd-abc".to_string(), + title: "Review MR !100".to_string(), + description: "Review requested by johndoe".to_string(), + status: "open".to_string(), + priority: 2, + issue_type: "task".to_string(), + created_at: "2026-02-25T12:00:00Z".to_string(), + } + } + + #[test] + fn test_mock_beads_cli_create() { + let mut mock = MockBeadsCli::new(); + + mock.expect_create() + .with( + mockall::predicate::eq("Review MR !100"), + mockall::predicate::eq("Review requested by johndoe"), + ) + .times(1) + .returning(|_, _| Ok("bd-xyz".to_string())); + + let result = mock + .create("Review MR !100", "Review requested by johndoe") + .unwrap(); + assert_eq!(result, "bd-xyz"); + } + + #[test] + fn test_mock_beads_cli_close() { + let mut mock = MockBeadsCli::new(); + + mock.expect_close() + .with( + mockall::predicate::eq("bd-abc"), + mockall::predicate::eq("MR merged"), + ) + .times(1) + .returning(|_, _| Ok(())); + + let result = mock.close("bd-abc", "MR merged"); + assert!(result.is_ok()); + } + + #[test] + fn test_mock_beads_cli_list() { + let mut mock = MockBeadsCli::new(); + let beads = vec![sample_bead()]; + let beads_clone = beads.clone(); + + mock.expect_list() + .times(1) + .returning(move || Ok(beads_clone.clone())); + + let result = mock.list().unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, "bd-abc"); + } + + #[test] + fn test_mock_beads_cli_can_return_error() { + let mut mock = MockBeadsCli::new(); + + mock.expect_create() + .times(1) + .returning(|_, _| Err(BeadsError::ExecutionFailed("br not found".to_string()))); + + let result = mock.create("test", "test"); + assert!(result.is_err()); + } + + #[test] + fn test_bead_deserialize() { + let json = r#"{ + "id": "bd-123", + "title": "Test bead", + "description": "Test description", + "status": "open", + "priority": 2, + "issue_type": "task", + "created_at": "2026-02-25T12:00:00Z" + }"#; + + let bead: Bead = serde_json::from_str(json).unwrap(); + assert_eq!(bead.id, "bd-123"); + assert_eq!(bead.priority, 2); + } +} diff --git a/src-tauri/src/data/lore.rs b/src-tauri/src/data/lore.rs new file mode 100644 index 0000000..f66c9c1 --- /dev/null +++ b/src-tauri/src/data/lore.rs @@ -0,0 +1,328 @@ +//! lore CLI integration +//! +//! Provides trait-based abstraction over the lore CLI for testability. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[cfg(test)] +use mockall::automock; + +/// Trait for interacting with lore CLI +/// +/// This abstraction allows us to mock lore in tests. +#[cfg_attr(test, automock)] +pub trait LoreCli: Send + Sync { + /// Execute `lore --robot me` and return the parsed result + fn get_me(&self) -> Result; + + /// Execute `lore --robot health` and check if lore is healthy + fn health_check(&self) -> Result; +} + +/// Real implementation that shells out to lore CLI +#[derive(Debug, Default)] +pub struct RealLoreCli; + +impl LoreCli for RealLoreCli { + fn get_me(&self) -> Result { + let output = Command::new("lore") + .args(["--robot", "me"]) + .output() + .map_err(|e| LoreError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(LoreError::CommandFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| LoreError::ParseFailed(e.to_string())) + } + + fn health_check(&self) -> Result { + let output = Command::new("lore") + .args(["health", "--json"]) + .output() + .map_err(|e| LoreError::ExecutionFailed(e.to_string()))?; + + Ok(output.status.success()) + } +} + +/// Errors that can occur when interacting with lore +#[derive(Debug, Clone, thiserror::Error)] +pub enum LoreError { + #[error("Failed to execute lore command: {0}")] + ExecutionFailed(String), + + #[error("lore command failed: {0}")] + CommandFailed(String), + + #[error("Failed to parse lore output: {0}")] + ParseFailed(String), +} + +/// Response from `lore --robot me` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreMeResponse { + pub ok: bool, + pub data: LoreMeData, + #[serde(default)] + pub meta: Option, +} + +/// Metadata from lore response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreMeta { + pub elapsed_ms: Option, +} + +/// Data section of `lore --robot me` +/// +/// Note: Field names match actual lore CLI output format +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreMeData { + /// Issues assigned to you + #[serde(default)] + pub open_issues: Vec, + + /// MRs you authored that are open + #[serde(default)] + pub open_mrs_authored: Vec, + + /// MRs where you're a reviewer + #[serde(default)] + pub reviewing_mrs: Vec, + + /// Recent activity across GitLab + #[serde(default)] + pub activity: Vec, + + /// Events since last cursor check + #[serde(default)] + pub since_last_check: Option, + + /// Summary counts + #[serde(default)] + pub summary: Option, + + /// Your username + #[serde(default)] + pub username: Option, + + /// ISO timestamp since when activity is shown + #[serde(default)] + pub since_iso: Option, +} + +/// Summary statistics from lore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreSummary { + pub authored_mr_count: i64, + pub needs_attention_count: i64, + pub open_issue_count: i64, + pub project_count: i64, + pub reviewing_mr_count: i64, +} + +/// A GitLab issue from lore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreIssue { + pub iid: i64, + pub title: String, + #[serde(default)] + pub project: String, + pub state: String, + pub web_url: String, + #[serde(default)] + pub labels: Vec, + #[serde(default)] + pub attention_state: Option, + #[serde(default)] + pub status_name: Option, + #[serde(default)] + pub updated_at_iso: Option, +} + +/// A GitLab merge request from lore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreMr { + pub iid: i64, + pub title: String, + #[serde(default)] + pub project: String, + pub state: String, + pub web_url: String, + #[serde(default)] + pub labels: Vec, + #[serde(default)] + pub attention_state: Option, + #[serde(default)] + pub author_username: Option, + #[serde(default)] + pub detailed_merge_status: Option, + #[serde(default)] + pub draft: bool, + #[serde(default)] + pub updated_at_iso: Option, +} + +/// Recent activity item from lore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreActivity { + pub actor: String, + pub event_type: String, + pub entity_iid: i64, + pub entity_type: String, + pub project: String, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub body_preview: Option, + #[serde(default)] + pub is_own: bool, + #[serde(default)] + pub timestamp_iso: Option, +} + +/// Events since last lore cursor check +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SinceLastCheck { + #[serde(default)] + pub cursor_iso: Option, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub total_event_count: i64, +} + +/// A group of related events (e.g., all events on one MR) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventGroup { + pub entity_iid: i64, + pub entity_title: String, + pub entity_type: String, + pub project: String, + #[serde(default)] + pub events: Vec, +} + +/// A GitLab event from the since_last_check section +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoreEvent { + pub event_type: String, + #[serde(default)] + pub actor: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub body_preview: Option, + #[serde(default)] + pub timestamp_iso: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_lore_me_response() -> LoreMeResponse { + LoreMeResponse { + ok: true, + data: LoreMeData { + open_issues: vec![LoreIssue { + iid: 42, + title: "Fix authentication bug".to_string(), + project: "mygroup/myproject".to_string(), + state: "opened".to_string(), + web_url: "https://gitlab.com/mygroup/myproject/-/issues/42".to_string(), + labels: vec![], + attention_state: None, + status_name: None, + updated_at_iso: None, + }], + open_mrs_authored: vec![], + reviewing_mrs: vec![LoreMr { + iid: 100, + title: "Add new feature".to_string(), + project: "mygroup/myproject".to_string(), + state: "opened".to_string(), + web_url: "https://gitlab.com/mygroup/myproject/-/merge_requests/100" + .to_string(), + labels: vec![], + attention_state: Some("needs_attention".to_string()), + author_username: Some("johndoe".to_string()), + detailed_merge_status: None, + draft: false, + updated_at_iso: None, + }], + activity: vec![], + since_last_check: Some(SinceLastCheck { + cursor_iso: None, + groups: vec![], + total_event_count: 0, + }), + summary: None, + username: Some("testuser".to_string()), + since_iso: None, + }, + meta: None, + } + } + + #[test] + fn test_mock_lore_cli_returns_expected_data() { + let mut mock = MockLoreCli::new(); + let expected = sample_lore_me_response(); + let expected_clone = expected.clone(); + + mock.expect_get_me() + .times(1) + .returning(move || Ok(expected_clone.clone())); + + let result = mock.get_me().unwrap(); + assert!(result.ok); + assert_eq!(result.data.open_issues.len(), 1); + assert_eq!(result.data.reviewing_mrs.len(), 1); + assert_eq!(result.data.open_issues[0].iid, 42); + } + + #[test] + fn test_mock_lore_cli_health_check() { + let mut mock = MockLoreCli::new(); + + mock.expect_health_check().times(1).returning(|| Ok(true)); + + assert!(mock.health_check().unwrap()); + } + + #[test] + fn test_mock_lore_cli_can_return_error() { + let mut mock = MockLoreCli::new(); + + mock.expect_get_me() + .times(1) + .returning(|| Err(LoreError::ExecutionFailed("lore not found".to_string()))); + + let result = mock.get_me(); + assert!(result.is_err()); + } + + #[test] + fn test_lore_response_deserialize_empty() { + let json = r#"{ + "ok": true, + "data": { + "open_issues": [], + "open_mrs_authored": [], + "reviewing_mrs": [], + "activity": [], + "since_last_check": null + } + }"#; + + let response: LoreMeResponse = serde_json::from_str(json).unwrap(); + assert!(response.ok); + assert!(response.data.open_issues.is_empty()); + } +} diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs new file mode 100644 index 0000000..1ba9baa --- /dev/null +++ b/src-tauri/src/data/mod.rs @@ -0,0 +1,10 @@ +//! Data layer for Mission Control +//! +//! This module provides abstractions over: +//! - lore CLI (GitLab data) +//! - br CLI (beads task management) +//! - MC local state (mapping, decisions, settings) + +pub mod lore; +pub mod beads; +pub mod state; diff --git a/src-tauri/src/data/state.rs b/src-tauri/src/data/state.rs new file mode 100644 index 0000000..80dc8e6 --- /dev/null +++ b/src-tauri/src/data/state.rs @@ -0,0 +1,184 @@ +//! 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 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +/// Get the Mission Control data directory +pub fn mc_data_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .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 { + pub timestamp: String, + pub action: DecisionAction, + pub bead_id: String, + pub reason: Option, + pub tags: Vec, + pub context: DecisionContext, +} + +/// Types of decisions users make +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DecisionAction { + SetFocus, + Reorder, + Defer, + Skip, + Complete, + StartBatch, + EndBatch, +} + +/// Context snapshot at decision time +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecisionContext { + pub time_of_day: String, + pub day_of_week: String, + pub queue_size: usize, + pub inbox_size: usize, + pub bead_age_hours: Option, +} + +/// Decision log - append-only for learning +pub struct DecisionLog; + +impl DecisionLog { + /// Append a decision to the log + pub fn append(decision: &Decision) -> io::Result<()> { + let dir = mc_data_dir(); + fs::create_dir_all(&dir)?; + + let path = dir.join("decision_log.jsonl"); + 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)?; + Ok(()) + } + + /// Read all decisions from the log + pub fn read_all() -> io::Result> { + let path = mc_data_dir().join("decision_log.jsonl"); + + 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; + } + let decision: Decision = serde_json::from_str(&line) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + decisions.push(decision); + } + + Ok(decisions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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, + }, + )]), + }; + + let json = serde_json::to_string_pretty(&map).unwrap(); + let parsed: GitLabBeadMap = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.mappings.len(), 1); + assert!(parsed.mappings.contains_key("mr_review:gitlab.com:123:456")); + } +}