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:
teernisse
2026-02-26 10:05:53 -05:00
parent 443db24fb3
commit 087b588d71
14 changed files with 877 additions and 20 deletions

File diff suppressed because one or more lines are too long

88
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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::*;

View File

@@ -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
View 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(_)));
}
}

View File

@@ -7,5 +7,6 @@
pub mod beads;
pub mod bridge;
pub mod bv;
pub mod lore;
pub mod state;

View File

@@ -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<()> {

View File

@@ -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);
}
}

View File

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

109
src/lib/tauri-storage.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Tauri storage adapter for Zustand persist middleware.
*
* Stores Zustand state in `~/.local/share/mc/state.json` via Tauri backend
* instead of browser localStorage. Falls back to localStorage in browser context.
*/
import { invoke } from "@tauri-apps/api/core";
import type { StateStorage } from "zustand/middleware";
/**
* Create a storage adapter that persists to Tauri backend.
*
* Uses the `read_state`, `write_state`, and `clear_state` Tauri commands.
*/
export function createTauriStorage(): StateStorage {
return {
getItem: async (_name: string): Promise<string | null> => {
try {
const state = await invoke<Record<string, unknown> | null>("read_state");
if (state === null) {
return null;
}
return JSON.stringify(state);
} catch (error) {
console.warn("[tauri-storage] Failed to read state:", error);
return null;
}
},
setItem: async (_name: string, value: string): Promise<void> => {
try {
const state = JSON.parse(value) as Record<string, unknown>;
await invoke("write_state", { state });
} catch (error) {
console.warn("[tauri-storage] Failed to write state:", error);
}
},
removeItem: async (_name: string): Promise<void> => {
try {
await invoke("clear_state");
} catch (error) {
console.warn("[tauri-storage] Failed to clear state:", error);
}
},
};
}
/**
* Check if running in Tauri context.
*/
function isTauriContext(): boolean {
return typeof window !== "undefined" && "__TAURI__" in window;
}
/**
* Create a localStorage-based storage adapter for browser context.
*/
function createLocalStorageAdapter(): StateStorage {
return {
getItem: (name: string): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem(name);
},
setItem: (name: string, value: string): void => {
if (typeof window === "undefined") return;
localStorage.setItem(name, value);
},
removeItem: (name: string): void => {
if (typeof window === "undefined") return;
localStorage.removeItem(name);
},
};
}
/**
* Get the appropriate storage adapter for the current context.
*
* - In Tauri: Uses backend persistence to ~/.local/share/mc/state.json
* - In browser: Falls back to localStorage
*/
export async function initializeStorage(): Promise<StateStorage> {
if (isTauriContext()) {
return createTauriStorage();
}
return createLocalStorageAdapter();
}
/**
* Singleton storage instance.
* Use this in store definitions to avoid recreating the adapter.
*/
let _storage: StateStorage | null = null;
/**
* Get the storage adapter (lazy initialization).
*
* In Tauri context, returns Tauri storage.
* In browser context, returns localStorage wrapper.
*/
export function getStorage(): StateStorage {
if (_storage === null) {
_storage = isTauriContext() ? createTauriStorage() : createLocalStorageAdapter();
}
return _storage;
}

View File

@@ -6,7 +6,8 @@
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, createJSONStorage } from "zustand/middleware";
import { getStorage } from "@/lib/tauri-storage";
import type { FocusAction, FocusItem } from "@/lib/types";
export interface FocusState {
@@ -108,6 +109,7 @@ export const useFocusStore = create<FocusState>()(
}),
{
name: "mc-focus-store",
storage: createJSONStorage(() => getStorage()),
partialize: (state) => ({
current: state.current,
queue: state.queue,

View File

@@ -6,7 +6,8 @@
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, createJSONStorage } from "zustand/middleware";
import { getStorage } from "@/lib/tauri-storage";
export type ViewId = "focus" | "queue" | "inbox";
@@ -23,6 +24,7 @@ export const useNavStore = create<NavState>()(
}),
{
name: "mc-nav-store",
storage: createJSONStorage(() => getStorage()),
}
)
);

View File

@@ -0,0 +1,119 @@
/**
* Tests for Tauri storage adapter for Zustand.
*
* Verifies that the store persists state to Tauri backend
* instead of browser localStorage.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock Tauri's invoke
const mockInvoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// Import after mocking
import { createTauriStorage, initializeStorage } from "@/lib/tauri-storage";
describe("Tauri Storage Adapter", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getItem", () => {
it("calls read_state Tauri command", async () => {
const savedState = { focusId: "br-123", queueOrder: ["a", "b"] };
mockInvoke.mockResolvedValue(savedState);
const storage = createTauriStorage();
const result = await storage.getItem("mc-state");
expect(mockInvoke).toHaveBeenCalledWith("read_state");
expect(result).toBe(JSON.stringify(savedState));
});
it("returns null when no state exists", async () => {
mockInvoke.mockResolvedValue(null);
const storage = createTauriStorage();
const result = await storage.getItem("mc-state");
expect(result).toBeNull();
});
it("returns null on Tauri error (graceful fallback)", async () => {
mockInvoke.mockRejectedValue(new Error("Tauri not available"));
const storage = createTauriStorage();
const result = await storage.getItem("mc-state");
expect(result).toBeNull();
});
});
describe("setItem", () => {
it("calls write_state Tauri command with parsed JSON", async () => {
mockInvoke.mockResolvedValue(undefined);
const storage = createTauriStorage();
const state = { focusId: "br-456", activeView: "queue" };
await storage.setItem("mc-state", JSON.stringify(state));
expect(mockInvoke).toHaveBeenCalledWith("write_state", { state });
});
it("handles Tauri error gracefully (does not throw)", async () => {
mockInvoke.mockRejectedValue(new Error("Write failed"));
const storage = createTauriStorage();
// Should not throw
await expect(
storage.setItem("mc-state", JSON.stringify({ focusId: null }))
).resolves.not.toThrow();
});
});
describe("removeItem", () => {
it("calls clear_state Tauri command", async () => {
mockInvoke.mockResolvedValue(undefined);
const storage = createTauriStorage();
await storage.removeItem("mc-state");
expect(mockInvoke).toHaveBeenCalledWith("clear_state");
});
});
});
describe("initializeStorage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns Tauri storage when in Tauri context", async () => {
// Tauri context detection: window.__TAURI__ exists
vi.stubGlobal("__TAURI__", {});
const storage = await initializeStorage();
// Should be our custom storage, not localStorage
expect(storage.getItem).toBeDefined();
expect(storage.setItem).toBeDefined();
vi.unstubAllGlobals();
});
it("falls back to localStorage in browser context", async () => {
// Not in Tauri
vi.stubGlobal("__TAURI__", undefined);
const storage = await initializeStorage();
// Should fall back to localStorage wrapper
expect(storage).toBeDefined();
vi.unstubAllGlobals();
});
});