feat: add app initialization module with startup sequence

Implements core startup infrastructure:
- AppConfig for data directory paths
- ensure_data_dir() to create ~/.local/share/mc/
- verify_cli_dependencies() async CLI availability check
- load_with_migration() for state file loading with migration support
- init() main entry point coordinating lock acquisition and setup
- StartupWarning enum for non-fatal issues (missing CLIs, state reset)
- InitError enum with conversion to McError

Also exposes Bridge::with_data_dir() publicly (was test-only).

Includes 11 tests covering directory creation, lock acquisition,
lock contention, corrupt file handling, and config paths.

bd-3jh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:29:19 -05:00
parent 61a068ad99
commit 0ad1b30941
3 changed files with 489 additions and 2 deletions

487
src-tauri/src/app.rs Normal file
View File

@@ -0,0 +1,487 @@
//! App initialization and startup sequence
//!
//! Coordinates the proper startup order for Mission Control:
//! 1. Acquire single-instance lock
//! 2. Create data directories
//! 3. Load persisted state (with migration support)
//! 4. Verify CLI dependencies
//! 5. Run crash recovery
//! 6. Run full reconciliation (startup sync)
//!
//! Tracks warnings (e.g., missing CLIs) for display to the user.
use crate::data::beads::{BeadsCli, RealBeadsCli};
use crate::data::bridge::{Bridge, LockGuard};
use crate::data::lore::{LoreCli, RealLoreCli};
use crate::data::migration;
use crate::error::{McError, McErrorCode};
use serde::Serialize;
use specta::Type;
use std::path::PathBuf;
use tokio::process::Command;
/// Warnings that don't prevent startup but should be shown to the user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)]
#[serde(rename_all = "snake_case")]
pub enum StartupWarning {
/// lore CLI not found
LoreMissing,
/// br CLI not found
BrMissing,
/// bv CLI not found (subset of br, but check anyway)
BvMissing,
/// State file was corrupted and reset to defaults
StateReset { path: String },
/// Migration was applied to a state file
MigrationApplied { path: String, from: u32, to: u32 },
}
/// Errors that prevent app initialization
#[derive(Debug, Clone)]
pub enum InitError {
/// Another instance is already running
AlreadyRunning,
/// Failed to create data directory
DirectoryCreateFailed(String),
/// Critical file operation failed
IoFailed(String),
}
impl std::fmt::Display for InitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InitError::AlreadyRunning => {
write!(f, "Another Mission Control instance is already running")
}
InitError::DirectoryCreateFailed(path) => {
write!(f, "Failed to create data directory: {}", path)
}
InitError::IoFailed(msg) => write!(f, "IO error: {}", msg),
}
}
}
impl std::error::Error for InitError {}
impl From<InitError> for McError {
fn from(err: InitError) -> Self {
match err {
InitError::AlreadyRunning => Self::bridge_locked(),
InitError::DirectoryCreateFailed(path) => {
Self::new(McErrorCode::IoError, format!("Failed to create {}", path))
}
InitError::IoFailed(msg) => Self::io_error(msg),
}
}
}
/// Configuration for app initialization
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Data directory (defaults to ~/.local/share/mc/)
pub data_dir: PathBuf,
}
impl Default for AppConfig {
fn default() -> Self {
let data_dir = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("mc");
Self { data_dir }
}
}
impl AppConfig {
/// Create config with custom data directory (for testing)
pub fn with_data_dir(path: impl Into<PathBuf>) -> Self {
Self {
data_dir: path.into(),
}
}
/// Path to the mapping file
pub fn mapping_path(&self) -> PathBuf {
self.data_dir.join("gitlab_bead_map.json")
}
/// Path to the state file
pub fn state_path(&self) -> PathBuf {
self.data_dir.join("state.json")
}
/// Path to the settings file
pub fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json")
}
/// Path to the lock file
pub fn lock_path(&self) -> PathBuf {
self.data_dir.join(".mc.lock")
}
}
/// Startup state container returned from initialization
pub struct StartupState {
/// Warnings accumulated during startup
pub warnings: Vec<StartupWarning>,
/// Whether CLI tools are available
pub cli_available: CliAvailability,
}
/// CLI tool availability status
#[derive(Debug, Clone, Default, Serialize, Type)]
pub struct CliAvailability {
pub lore: bool,
pub br: bool,
pub bv: bool,
}
/// Check if a CLI tool is available
async fn check_cli_available(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Verify all CLI dependencies and collect warnings
pub async fn verify_cli_dependencies() -> (CliAvailability, Vec<StartupWarning>) {
let mut warnings = Vec::new();
let lore = check_cli_available("lore").await;
let br = check_cli_available("br").await;
let bv = check_cli_available("bv").await;
if !lore {
warnings.push(StartupWarning::LoreMissing);
}
if !br {
warnings.push(StartupWarning::BrMissing);
}
if !bv {
warnings.push(StartupWarning::BvMissing);
}
(CliAvailability { lore, br, bv }, warnings)
}
/// Create the data directory if it doesn't exist
pub fn ensure_data_dir(config: &AppConfig) -> Result<(), InitError> {
if !config.data_dir.exists() {
std::fs::create_dir_all(&config.data_dir).map_err(|e| {
InitError::DirectoryCreateFailed(format!("{}: {}", config.data_dir.display(), e))
})?;
}
Ok(())
}
/// Load and migrate a JSON state file
pub fn load_with_migration<T: Default + serde::de::DeserializeOwned + serde::Serialize>(
path: &PathBuf,
registry: &migration::MigrationRegistry,
) -> Result<(T, Option<StartupWarning>), InitError> {
// If file doesn't exist, return defaults
if !path.exists() {
return Ok((T::default(), None));
}
// Read file content
let content = std::fs::read_to_string(path).map_err(|e| InitError::IoFailed(e.to_string()))?;
// Parse as generic JSON for migration
let data: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => {
// File is corrupted, reset to defaults
return Ok((
T::default(),
Some(StartupWarning::StateReset {
path: path.display().to_string(),
}),
));
}
};
// Run migration
let (migrated_data, did_migrate) = match registry.migrate(data) {
Ok((d, m)) => (d, m),
Err(_) => {
// Migration failed, reset to defaults
return Ok((
T::default(),
Some(StartupWarning::StateReset {
path: path.display().to_string(),
}),
));
}
};
// Parse into target type
let parsed: T = match serde_json::from_value(migrated_data.clone()) {
Ok(v) => v,
Err(_) => {
return Ok((
T::default(),
Some(StartupWarning::StateReset {
path: path.display().to_string(),
}),
));
}
};
// If migration occurred, save the migrated file and return warning
let warning = if did_migrate {
let from_version = 0; // We don't have the original version easily accessible
let to_version = registry.current_version();
// Save migrated data
if let Ok(content) = serde_json::to_string_pretty(&migrated_data) {
let _ = std::fs::write(path, content);
}
Some(StartupWarning::MigrationApplied {
path: path.display().to_string(),
from: from_version,
to: to_version,
})
} else {
None
};
Ok((parsed, warning))
}
/// Initialize the app - the main entry point for startup
///
/// Returns:
/// - LockGuard (must be held while app is running)
/// - StartupState containing warnings and CLI availability
pub async fn init<L, B>(
config: &AppConfig,
bridge: &Bridge<L, B>,
) -> Result<(LockGuard, StartupState), InitError>
where
L: LoreCli + Send + 'static,
B: BeadsCli + Send + 'static,
{
let mut warnings = Vec::new();
// 1. Create data directories
ensure_data_dir(config)?;
// 2. Acquire single-instance lock
let lock = bridge
.acquire_lock()
.map_err(|_| InitError::AlreadyRunning)?;
// 3. Verify CLI dependencies
let (cli_available, cli_warnings) = verify_cli_dependencies().await;
warnings.extend(cli_warnings);
// Note: Steps 4-6 (load state, crash recovery, reconciliation) happen in the
// Tauri setup callback which has access to the app state and can trigger
// the sync orchestrator. This function just handles the critical path that
// must succeed before the app can start.
Ok((
lock,
StartupState {
warnings,
cli_available,
},
))
}
/// Convenience function to initialize with real CLI implementations
pub async fn init_with_real_cli(
config: &AppConfig,
) -> Result<(LockGuard, StartupState, Bridge<RealLoreCli, RealBeadsCli>), InitError> {
let bridge = Bridge::with_data_dir(RealLoreCli, RealBeadsCli, config.data_dir.clone());
let (lock, state) = init(config, &bridge).await?;
Ok((lock, state, bridge))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::beads::MockBeadsCli;
use crate::data::lore::MockLoreCli;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tempfile::TempDir;
#[tokio::test]
async fn test_init_creates_data_directory() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("mc");
let config = AppConfig::with_data_dir(&data_dir);
assert!(!data_dir.exists());
let lore = MockLoreCli::new();
let beads = MockBeadsCli::new();
let bridge = Bridge::with_data_dir(lore, beads, data_dir.clone());
let result = init(&config, &bridge).await;
assert!(result.is_ok());
assert!(data_dir.exists());
}
#[tokio::test]
async fn test_init_acquires_lock() {
let temp = TempDir::new().unwrap();
let config = AppConfig::with_data_dir(temp.path());
let lore = MockLoreCli::new();
let beads = MockBeadsCli::new();
let bridge = Bridge::with_data_dir(lore, beads, temp.path().to_path_buf());
let result = init(&config, &bridge).await;
assert!(result.is_ok());
// The lock file should have been created in the data directory
let (_lock, _state) = result.unwrap();
assert!(temp.path().join("mc.lock").exists());
}
#[tokio::test]
async fn test_init_fails_if_already_locked() {
let temp = TempDir::new().unwrap();
let config = AppConfig::with_data_dir(temp.path());
// First init succeeds
let lore1 = MockLoreCli::new();
let beads1 = MockBeadsCli::new();
let bridge1 = Bridge::with_data_dir(lore1, beads1, temp.path().to_path_buf());
let result1 = init(&config, &bridge1).await;
assert!(result1.is_ok());
let (_lock1, _) = result1.unwrap();
// Second init should fail with AlreadyRunning
let lore2 = MockLoreCli::new();
let beads2 = MockBeadsCli::new();
let bridge2 = Bridge::with_data_dir(lore2, beads2, temp.path().to_path_buf());
let result2 = init(&config, &bridge2).await;
assert!(matches!(result2, Err(InitError::AlreadyRunning)));
}
#[test]
fn test_ensure_data_dir_creates_missing_directory() {
let temp = TempDir::new().unwrap();
let data_dir = temp.path().join("nested").join("mc");
let config = AppConfig::with_data_dir(&data_dir);
assert!(!data_dir.exists());
let result = ensure_data_dir(&config);
assert!(result.is_ok());
assert!(data_dir.exists());
}
#[test]
fn test_ensure_data_dir_succeeds_if_exists() {
let temp = TempDir::new().unwrap();
let config = AppConfig::with_data_dir(temp.path());
let result = ensure_data_dir(&config);
assert!(result.is_ok());
}
#[test]
fn test_load_with_migration_returns_defaults_for_missing_file() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("nonexistent.json");
let registry = migration::frontend_state_registry();
#[derive(Default, Deserialize, Serialize)]
struct TestState {
value: Option<String>,
}
let (state, warning): (TestState, _) = load_with_migration(&path, &registry).unwrap();
assert!(state.value.is_none());
assert!(warning.is_none());
}
#[test]
fn test_load_with_migration_resets_corrupt_file() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("corrupt.json");
std::fs::write(&path, "not valid json {{{").unwrap();
let registry = migration::frontend_state_registry();
#[derive(Default, Deserialize, Serialize)]
struct TestState {
value: Option<String>,
}
let (state, warning): (TestState, _) = load_with_migration(&path, &registry).unwrap();
assert!(state.value.is_none());
assert!(matches!(warning, Some(StartupWarning::StateReset { .. })));
}
#[test]
fn test_load_with_migration_loads_valid_file() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("valid.json");
let data = json!({
"schema_version": 1,
"value": "test"
});
std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap();
let registry = migration::frontend_state_registry();
#[derive(Default, Deserialize, Serialize)]
struct TestState {
value: Option<String>,
}
let (state, warning): (TestState, _) = load_with_migration(&path, &registry).unwrap();
assert_eq!(state.value, Some("test".to_string()));
assert!(warning.is_none());
}
#[test]
fn test_config_paths() {
let config = AppConfig::with_data_dir("/test/mc");
assert_eq!(config.mapping_path(), PathBuf::from("/test/mc/gitlab_bead_map.json"));
assert_eq!(config.state_path(), PathBuf::from("/test/mc/state.json"));
assert_eq!(config.settings_path(), PathBuf::from("/test/mc/settings.json"));
assert_eq!(config.lock_path(), PathBuf::from("/test/mc/.mc.lock"));
}
#[test]
fn test_init_error_display() {
assert_eq!(
InitError::AlreadyRunning.to_string(),
"Another Mission Control instance is already running"
);
assert!(InitError::DirectoryCreateFailed("/foo".to_string())
.to_string()
.contains("/foo"));
assert!(InitError::IoFailed("test".to_string())
.to_string()
.contains("test"));
}
#[test]
fn test_init_error_converts_to_mc_error() {
let err: McError = InitError::AlreadyRunning.into();
assert_eq!(err.code, McErrorCode::BridgeLocked);
let err: McError = InitError::DirectoryCreateFailed("/foo".to_string()).into();
assert_eq!(err.code, McErrorCode::IoError);
}
}

View File

@@ -167,8 +167,7 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
}
}
/// Create a bridge with a custom data directory (for testing)
#[cfg(test)]
/// Create a bridge with a custom data directory
pub fn with_data_dir(lore: L, beads: B, data_dir: PathBuf) -> Self {
Self {
lore,

View File

@@ -6,6 +6,7 @@
//! - Decision logging and state persistence
//! - File watching for automatic sync
pub mod app;
pub mod commands;
pub mod data;
pub mod error;