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

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