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:
@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user