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:
487
src-tauri/src/app.rs
Normal file
487
src-tauri/src/app.rs
Normal 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, ®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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user