feat: add Tauri state persistence and BvCli trait
- Add Tauri storage adapter for Zustand (tauri-storage.ts) - Add read_state, write_state, clear_state Tauri commands - Wire focus-store and nav-store to use Tauri persistence - Add BvCli trait for bv CLI mocking with response types - Add BvError and McError conversion for bv errors - Add cleanup_tmp_files tests for bridge - Fix linter-introduced tauri_specta::command issues Closes bd-2x6, bd-gil, bd-3px Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
88
src-tauri/Cargo.lock
generated
88
src-tauri/Cargo.lock
generated
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Inflector"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -1940,10 +1946,13 @@ dependencies = [
|
||||
"notify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -2408,6 +2417,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3402,6 +3417,50 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta"
|
||||
version = "2.0.0-rc.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"specta-macros",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-macros"
|
||||
version = "2.0.0-rc.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-serde"
|
||||
version = "0.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
|
||||
dependencies = [
|
||||
"specta",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-typescript"
|
||||
version = "0.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
|
||||
dependencies = [
|
||||
"specta",
|
||||
"specta-serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3597,6 +3656,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"specta",
|
||||
"swift-rs",
|
||||
"tauri-build",
|
||||
"tauri-macros",
|
||||
@@ -3781,6 +3841,34 @@ dependencies = [
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta"
|
||||
version = "2.0.0-rc.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
"tauri-specta-macros",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta-macros"
|
||||
version = "2.0.0-rc.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.8.2"
|
||||
|
||||
@@ -30,6 +30,9 @@ dirs = "5"
|
||||
notify = "7"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
libc = "0.2"
|
||||
specta = { version = "=2.0.0-rc.22", features = ["derive"] }
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta-typescript = "0.0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
use crate::data::beads::{BeadsCli, RealBeadsCli};
|
||||
use crate::data::bridge::{Bridge, SyncResult};
|
||||
use crate::data::lore::{LoreCli, LoreError, RealLoreCli};
|
||||
use crate::data::state::{clear_frontend_state, read_frontend_state, write_frontend_state, FrontendState};
|
||||
use crate::error::McError;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
|
||||
/// Simple greeting command for testing IPC
|
||||
#[tauri::command]
|
||||
@@ -15,7 +17,7 @@ pub fn greet(name: &str) -> String {
|
||||
}
|
||||
|
||||
/// Lore sync status
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct LoreStatus {
|
||||
pub last_sync: Option<String>,
|
||||
pub is_healthy: bool,
|
||||
@@ -24,7 +26,7 @@ pub struct LoreStatus {
|
||||
}
|
||||
|
||||
/// Summary counts from lore for the status response
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct LoreSummaryStatus {
|
||||
pub open_issues: usize,
|
||||
pub authored_mrs: usize,
|
||||
@@ -88,7 +90,7 @@ fn get_lore_status_with(cli: &dyn LoreCli) -> Result<LoreStatus, McError> {
|
||||
// -- Bridge commands --
|
||||
|
||||
/// Bridge status for the frontend
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct BridgeStatus {
|
||||
/// Total mapped items
|
||||
pub mapping_count: usize,
|
||||
@@ -185,7 +187,7 @@ fn reconcile_inner(
|
||||
// -- Quick capture command --
|
||||
|
||||
/// Response from quick_capture: the bead ID created
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct CaptureResult {
|
||||
pub bead_id: String,
|
||||
}
|
||||
@@ -203,6 +205,39 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult,
|
||||
Ok(CaptureResult { bead_id })
|
||||
}
|
||||
|
||||
// -- Frontend state persistence commands --
|
||||
|
||||
/// Read persisted frontend state from ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Returns null if no state exists (first run).
|
||||
#[tauri::command]
|
||||
pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
tokio::task::spawn_blocking(read_frontend_state)
|
||||
.await
|
||||
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
|
||||
.map_err(|e| McError::io_error(format!("Failed to read state: {}", e)))
|
||||
}
|
||||
|
||||
/// Write frontend state to ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Uses atomic rename pattern to prevent corruption.
|
||||
#[tauri::command]
|
||||
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
||||
.await
|
||||
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
|
||||
.map_err(|e| McError::io_error(format!("Failed to write state: {}", e)))
|
||||
}
|
||||
|
||||
/// Clear persisted frontend state.
|
||||
#[tauri::command]
|
||||
pub async fn clear_state() -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(clear_frontend_state)
|
||||
.await
|
||||
.map_err(|e| McError::internal(format!("Task join error: {}", e)))?
|
||||
.map_err(|e| McError::io_error(format!("Failed to clear state: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Single-instance locking via flock(2)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
@@ -109,7 +110,7 @@ impl MappingKey {
|
||||
}
|
||||
|
||||
/// Result of a sync operation
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[derive(Debug, Default, Serialize, Type)]
|
||||
pub struct SyncResult {
|
||||
/// Number of new beads created
|
||||
pub created: usize,
|
||||
@@ -1323,4 +1324,48 @@ mod tests {
|
||||
assert_eq!(r4.closed, 1);
|
||||
assert!(!map.mappings.contains_key("issue:g/p:42"));
|
||||
}
|
||||
|
||||
// -- cleanup_tmp_files tests --
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_removes_orphaned_tmp() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir);
|
||||
|
||||
// Create an orphaned .tmp file (simulating a crash during save_map)
|
||||
let tmp_file = dir.path().join("gitlab_bead_map.json.tmp");
|
||||
std::fs::write(&tmp_file, "orphaned data").unwrap();
|
||||
assert!(tmp_file.exists());
|
||||
|
||||
// Cleanup should remove it
|
||||
let cleaned = bridge.cleanup_tmp_files().unwrap();
|
||||
assert_eq!(cleaned, 1);
|
||||
assert!(!tmp_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_ignores_non_tmp_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir);
|
||||
|
||||
// Create a regular file (should not be removed)
|
||||
let json_file = dir.path().join("gitlab_bead_map.json");
|
||||
std::fs::write(&json_file, "{}").unwrap();
|
||||
|
||||
let cleaned = bridge.cleanup_tmp_files().unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
assert!(json_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_handles_missing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nonexistent = dir.path().join("nonexistent");
|
||||
let bridge: Bridge<MockLoreCli, MockBeadsCli> =
|
||||
Bridge::with_data_dir(MockLoreCli::new(), MockBeadsCli::new(), nonexistent);
|
||||
|
||||
// Should return 0, not error, when dir doesn't exist
|
||||
let cleaned = bridge.cleanup_tmp_files().unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
}
|
||||
}
|
||||
|
||||
325
src-tauri/src/data/bv.rs
Normal file
325
src-tauri/src/data/bv.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! bv CLI integration (beads graph triage)
|
||||
//!
|
||||
//! Provides trait-based abstraction over bv CLI for testability.
|
||||
//! bv is the graph-aware triage engine for beads projects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
/// Trait for interacting with bv CLI
|
||||
///
|
||||
/// This abstraction allows us to mock bv in tests.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait BvCli: Send + Sync {
|
||||
/// Get triage recommendations via `bv --robot-triage`
|
||||
fn robot_triage(&self) -> Result<BvTriageResponse, BvError>;
|
||||
|
||||
/// Get the single top recommendation via `bv --robot-next`
|
||||
fn robot_next(&self) -> Result<BvNextResponse, BvError>;
|
||||
}
|
||||
|
||||
/// Real implementation that shells out to bv CLI
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RealBvCli;
|
||||
|
||||
impl BvCli for RealBvCli {
|
||||
fn robot_triage(&self) -> Result<BvTriageResponse, BvError> {
|
||||
let output = Command::new("bv")
|
||||
.args(["--robot-triage"])
|
||||
.output()
|
||||
.map_err(|e| BvError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(BvError::CommandFailed(stderr.to_string()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| BvError::ParseFailed(e.to_string()))
|
||||
}
|
||||
|
||||
fn robot_next(&self) -> Result<BvNextResponse, BvError> {
|
||||
let output = Command::new("bv")
|
||||
.args(["--robot-next"])
|
||||
.output()
|
||||
.map_err(|e| BvError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(BvError::CommandFailed(stderr.to_string()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| BvError::ParseFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when interacting with bv CLI
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum BvError {
|
||||
#[error("Failed to execute bv: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("bv command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
|
||||
#[error("Failed to parse bv response: {0}")]
|
||||
ParseFailed(String),
|
||||
}
|
||||
|
||||
// -- Response types --
|
||||
|
||||
/// Response from `bv --robot-triage`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BvTriageResponse {
|
||||
pub generated_at: String,
|
||||
pub data_hash: String,
|
||||
pub triage: TriageData,
|
||||
#[serde(default)]
|
||||
pub usage_hints: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Triage data containing recommendations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriageData {
|
||||
pub quick_ref: QuickRef,
|
||||
#[serde(default)]
|
||||
pub recommendations: Vec<Recommendation>,
|
||||
#[serde(default)]
|
||||
pub quick_wins: Vec<QuickWin>,
|
||||
#[serde(default)]
|
||||
pub blockers_to_clear: Vec<BlockerToClear>,
|
||||
#[serde(default)]
|
||||
pub project_health: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub commands: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Quick reference counts
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuickRef {
|
||||
pub open_count: i64,
|
||||
pub actionable_count: i64,
|
||||
pub blocked_count: i64,
|
||||
pub in_progress_count: i64,
|
||||
pub top_picks: Vec<TopPick>,
|
||||
}
|
||||
|
||||
/// Top pick from triage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopPick {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub score: f64,
|
||||
pub reasons: Vec<String>,
|
||||
pub unblocks: i64,
|
||||
}
|
||||
|
||||
/// Full recommendation with breakdown
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Recommendation {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<i64>,
|
||||
pub labels: Option<Vec<String>>,
|
||||
pub score: f64,
|
||||
#[serde(default)]
|
||||
pub breakdown: Option<serde_json::Value>,
|
||||
pub action: Option<String>,
|
||||
pub reasons: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub unblocks_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocked_by: Vec<String>,
|
||||
}
|
||||
|
||||
/// Quick win item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuickWin {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub score: f64,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub unblocks_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Blocker to clear
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockerToClear {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub unblocks_count: i64,
|
||||
#[serde(default)]
|
||||
pub unblocks_ids: Vec<String>,
|
||||
pub actionable: bool,
|
||||
}
|
||||
|
||||
/// Response from `bv --robot-next`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BvNextResponse {
|
||||
pub generated_at: String,
|
||||
pub data_hash: String,
|
||||
#[serde(default)]
|
||||
pub output_format: Option<String>,
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub score: f64,
|
||||
pub reasons: Vec<String>,
|
||||
pub unblocks: i64,
|
||||
pub claim_command: String,
|
||||
pub show_command: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bv_next_response_deserialize() {
|
||||
let json = r#"{
|
||||
"generated_at": "2026-02-26T15:00:00Z",
|
||||
"data_hash": "abc123",
|
||||
"output_format": "json",
|
||||
"id": "bd-qvc",
|
||||
"title": "Implement Inbox view",
|
||||
"score": 0.116,
|
||||
"reasons": ["Unblocks 1 item"],
|
||||
"unblocks": 1,
|
||||
"claim_command": "bd update bd-qvc --status=in_progress",
|
||||
"show_command": "bd show bd-qvc"
|
||||
}"#;
|
||||
|
||||
let response: BvNextResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(response.id, "bd-qvc");
|
||||
assert_eq!(response.title, "Implement Inbox view");
|
||||
assert_eq!(response.unblocks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_ref_deserialize() {
|
||||
let json = r#"{
|
||||
"open_count": 23,
|
||||
"actionable_count": 17,
|
||||
"blocked_count": 6,
|
||||
"in_progress_count": 1,
|
||||
"top_picks": [
|
||||
{
|
||||
"id": "bd-qvc",
|
||||
"title": "Inbox view",
|
||||
"score": 0.116,
|
||||
"reasons": ["Unblocks 1"],
|
||||
"unblocks": 1
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let quick_ref: QuickRef = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(quick_ref.open_count, 23);
|
||||
assert_eq!(quick_ref.top_picks.len(), 1);
|
||||
assert_eq!(quick_ref.top_picks[0].id, "bd-qvc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_triage_response_deserialize() {
|
||||
let json = r#"{
|
||||
"generated_at": "2026-02-26T15:00:00Z",
|
||||
"data_hash": "abc123",
|
||||
"triage": {
|
||||
"quick_ref": {
|
||||
"open_count": 23,
|
||||
"actionable_count": 17,
|
||||
"blocked_count": 6,
|
||||
"in_progress_count": 0,
|
||||
"top_picks": []
|
||||
},
|
||||
"recommendations": [],
|
||||
"quick_wins": [],
|
||||
"blockers_to_clear": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: BvTriageResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(response.triage.quick_ref.open_count, 23);
|
||||
assert!(response.triage.recommendations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_bv_cli_robot_next() {
|
||||
let mut mock = MockBvCli::new();
|
||||
mock.expect_robot_next().returning(|| {
|
||||
Ok(BvNextResponse {
|
||||
generated_at: "2026-02-26T15:00:00Z".to_string(),
|
||||
data_hash: "test".to_string(),
|
||||
output_format: Some("json".to_string()),
|
||||
id: "bd-test".to_string(),
|
||||
title: "Test bead".to_string(),
|
||||
score: 0.5,
|
||||
reasons: vec!["Test reason".to_string()],
|
||||
unblocks: 2,
|
||||
claim_command: "br update bd-test --status in_progress".to_string(),
|
||||
show_command: "br show bd-test".to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
let result = mock.robot_next().unwrap();
|
||||
assert_eq!(result.id, "bd-test");
|
||||
assert_eq!(result.unblocks, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_bv_cli_robot_triage() {
|
||||
let mut mock = MockBvCli::new();
|
||||
mock.expect_robot_triage().returning(|| {
|
||||
Ok(BvTriageResponse {
|
||||
generated_at: "2026-02-26T15:00:00Z".to_string(),
|
||||
data_hash: "test".to_string(),
|
||||
triage: TriageData {
|
||||
quick_ref: QuickRef {
|
||||
open_count: 10,
|
||||
actionable_count: 5,
|
||||
blocked_count: 3,
|
||||
in_progress_count: 2,
|
||||
top_picks: vec![TopPick {
|
||||
id: "bd-top".to_string(),
|
||||
title: "Top pick".to_string(),
|
||||
score: 0.8,
|
||||
reasons: vec!["High priority".to_string()],
|
||||
unblocks: 3,
|
||||
}],
|
||||
},
|
||||
recommendations: vec![],
|
||||
quick_wins: vec![],
|
||||
blockers_to_clear: vec![],
|
||||
project_health: None,
|
||||
commands: None,
|
||||
meta: None,
|
||||
},
|
||||
usage_hints: None,
|
||||
})
|
||||
});
|
||||
|
||||
let result = mock.robot_triage().unwrap();
|
||||
assert_eq!(result.triage.quick_ref.open_count, 10);
|
||||
assert_eq!(result.triage.quick_ref.top_picks[0].id, "bd-top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_bv_cli_can_return_error() {
|
||||
let mut mock = MockBvCli::new();
|
||||
mock.expect_robot_next()
|
||||
.returning(|| Err(BvError::ExecutionFailed("bv not found".to_string())));
|
||||
|
||||
let result = mock.robot_next();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), BvError::ExecutionFailed(_)));
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@
|
||||
|
||||
pub mod beads;
|
||||
pub mod bridge;
|
||||
pub mod bv;
|
||||
pub mod lore;
|
||||
pub mod state;
|
||||
|
||||
@@ -56,6 +56,60 @@ pub struct DecisionContext {
|
||||
/// Decision log - append-only for learning
|
||||
pub struct DecisionLog;
|
||||
|
||||
/// Frontend state stored by Zustand.
|
||||
///
|
||||
/// We store this as a JSON blob rather than parsing individual fields,
|
||||
/// allowing the frontend to evolve its schema freely.
|
||||
pub type FrontendState = serde_json::Value;
|
||||
|
||||
/// Read the persisted frontend state.
|
||||
///
|
||||
/// Returns `None` if the file doesn't exist (first run).
|
||||
pub fn read_frontend_state() -> io::Result<Option<FrontendState>> {
|
||||
let path = mc_data_dir().join("state.json");
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let state: FrontendState = serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// Write frontend state with atomic rename pattern.
|
||||
///
|
||||
/// Writes to `state.json.tmp` first, then renames to `state.json`.
|
||||
/// This prevents corruption from crashes during write.
|
||||
pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> {
|
||||
let dir = mc_data_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join("state.json");
|
||||
let tmp_path = dir.join("state.json.tmp");
|
||||
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
fs::write(&tmp_path, &content)?;
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the persisted frontend state (delete the file).
|
||||
pub fn clear_frontend_state() -> io::Result<()> {
|
||||
let path = mc_data_dir().join("state.json");
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DecisionLog {
|
||||
/// Append a decision to the log
|
||||
pub fn append(decision: &Decision) -> io::Result<()> {
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
//! to handle errors programmatically rather than parsing strings.
|
||||
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
|
||||
/// Structured error type for Tauri IPC commands.
|
||||
///
|
||||
/// This replaces string-based errors (`Result<T, String>`) with typed errors
|
||||
/// that the frontend can handle programmatically.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct McError {
|
||||
/// Machine-readable error code (e.g., "LORE_UNAVAILABLE", "BRIDGE_LOCKED")
|
||||
pub code: McErrorCode,
|
||||
@@ -20,7 +21,7 @@ pub struct McError {
|
||||
}
|
||||
|
||||
/// Error codes for frontend handling
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Type)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum McErrorCode {
|
||||
// Lore errors
|
||||
@@ -38,6 +39,10 @@ pub enum McErrorCode {
|
||||
BeadsCreateFailed,
|
||||
BeadsCloseFailed,
|
||||
|
||||
// Bv errors
|
||||
BvUnavailable,
|
||||
BvTriageFailed,
|
||||
|
||||
// General errors
|
||||
IoError,
|
||||
InternalError,
|
||||
@@ -50,6 +55,7 @@ impl McError {
|
||||
code,
|
||||
McErrorCode::LoreUnavailable
|
||||
| McErrorCode::BeadsUnavailable
|
||||
| McErrorCode::BvUnavailable
|
||||
| McErrorCode::BridgeLocked
|
||||
| McErrorCode::IoError
|
||||
);
|
||||
@@ -97,6 +103,19 @@ impl McError {
|
||||
"br CLI not found -- is beads installed?",
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an IO error with context
|
||||
pub fn io_error(context: impl Into<String>) -> Self {
|
||||
Self::new(McErrorCode::IoError, context)
|
||||
}
|
||||
|
||||
/// Create a bv unavailable error
|
||||
pub fn bv_unavailable() -> Self {
|
||||
Self::new(
|
||||
McErrorCode::BvUnavailable,
|
||||
"bv CLI not found -- is beads installed?",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for McError {
|
||||
@@ -169,6 +188,25 @@ impl From<crate::data::beads::BeadsError> for McError {
|
||||
}
|
||||
}
|
||||
|
||||
// Conversion from bv errors
|
||||
impl From<crate::data::bv::BvError> for McError {
|
||||
fn from(err: crate::data::bv::BvError) -> Self {
|
||||
use crate::data::bv::BvError;
|
||||
|
||||
match err {
|
||||
BvError::ExecutionFailed(_) => Self::bv_unavailable(),
|
||||
BvError::CommandFailed(msg) => Self::new(
|
||||
McErrorCode::BvTriageFailed,
|
||||
format!("bv command failed: {}", msg),
|
||||
),
|
||||
BvError::ParseFailed(msg) => Self::new(
|
||||
McErrorCode::BvTriageFailed,
|
||||
format!("Failed to parse bv response: {}", msg),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -261,4 +299,24 @@ mod tests {
|
||||
assert_eq!(err.code, McErrorCode::BridgeMapCorrupted);
|
||||
assert!(!err.recoverable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bv_error_conversion() {
|
||||
use crate::data::bv::BvError;
|
||||
|
||||
// ExecutionFailed -> BvUnavailable (recoverable)
|
||||
let err: McError = BvError::ExecutionFailed("not found".to_string()).into();
|
||||
assert_eq!(err.code, McErrorCode::BvUnavailable);
|
||||
assert!(err.recoverable);
|
||||
|
||||
// CommandFailed -> BvTriageFailed (not recoverable)
|
||||
let err: McError = BvError::CommandFailed("failed".to_string()).into();
|
||||
assert_eq!(err.code, McErrorCode::BvTriageFailed);
|
||||
assert!(!err.recoverable);
|
||||
|
||||
// ParseFailed -> BvTriageFailed (not recoverable)
|
||||
let err: McError = BvError::ParseFailed("bad json".to_string()).into();
|
||||
assert_eq!(err.code, McErrorCode::BvTriageFailed);
|
||||
assert!(!err.recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
use tauri_specta::{collect_commands, Builder};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
/// Toggle the main window's visibility.
|
||||
@@ -101,6 +102,28 @@ pub fn run() {
|
||||
|
||||
tracing::info!("Starting Mission Control");
|
||||
|
||||
// Build tauri-specta builder for type-safe IPC
|
||||
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
commands::greet,
|
||||
commands::get_lore_status,
|
||||
commands::get_bridge_status,
|
||||
commands::sync_now,
|
||||
commands::reconcile,
|
||||
commands::quick_capture,
|
||||
commands::read_state,
|
||||
commands::write_state,
|
||||
commands::clear_state,
|
||||
]);
|
||||
|
||||
// Export TypeScript bindings in debug builds
|
||||
#[cfg(debug_assertions)]
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default(),
|
||||
"../src/lib/bindings.ts",
|
||||
)
|
||||
.expect("Failed to export TypeScript bindings");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(
|
||||
@@ -165,14 +188,7 @@ pub fn run() {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::greet,
|
||||
commands::get_lore_status,
|
||||
commands::get_bridge_status,
|
||||
commands::sync_now,
|
||||
commands::reconcile,
|
||||
commands::quick_capture,
|
||||
])
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user