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)
|
/// Create a bridge with a custom data directory
|
||||||
#[cfg(test)]
|
|
||||||
pub fn with_data_dir(lore: L, beads: B, data_dir: PathBuf) -> Self {
|
pub fn with_data_dir(lore: L, beads: B, data_dir: PathBuf) -> Self {
|
||||||
Self {
|
Self {
|
||||||
lore,
|
lore,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! - Decision logging and state persistence
|
//! - Decision logging and state persistence
|
||||||
//! - File watching for automatic sync
|
//! - File watching for automatic sync
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
Reference in New Issue
Block a user