From 0ad1b30941fb5650f0362007ea11730bdc223417 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 10:29:19 -0500 Subject: [PATCH] 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 --- src-tauri/src/app.rs | 487 +++++++++++++++++++++++++++++++++++ src-tauri/src/data/bridge.rs | 3 +- src-tauri/src/lib.rs | 1 + 3 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/app.rs diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs new file mode 100644 index 0000000..bd17b11 --- /dev/null +++ b/src-tauri/src/app.rs @@ -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 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) -> 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, + /// 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) { + 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( + path: &PathBuf, + registry: &migration::MigrationRegistry, +) -> Result<(T, Option), 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( + config: &AppConfig, + bridge: &Bridge, +) -> 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), 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, + } + + let (state, warning): (TestState, _) = load_with_migration(&path, ®istry).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, + } + + let (state, warning): (TestState, _) = load_with_migration(&path, ®istry).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, + } + + let (state, warning): (TestState, _) = load_with_migration(&path, ®istry).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); + } +} diff --git a/src-tauri/src/data/bridge.rs b/src-tauri/src/data/bridge.rs index 055799e..7d8a0f5 100644 --- a/src-tauri/src/data/bridge.rs +++ b/src-tauri/src/data/bridge.rs @@ -167,8 +167,7 @@ impl Bridge { } } - /// 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ecec932..68a8046 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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;