|
| 1 | +//! Configuration for the HTTP server |
| 2 | +//! |
| 3 | +//! Loads configuration from `~/.lean-spec/config.json` |
| 4 | +
|
| 5 | +use crate::error::ServerError; |
| 6 | +use serde::{Deserialize, Serialize}; |
| 7 | +use std::fs; |
| 8 | +use std::path::PathBuf; |
| 9 | + |
| 10 | +/// Server configuration |
| 11 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 12 | +#[serde(rename_all = "camelCase")] |
| 13 | +pub struct ServerConfig { |
| 14 | + /// Server-specific configuration |
| 15 | + #[serde(default)] |
| 16 | + pub server: ServerSettings, |
| 17 | + |
| 18 | + /// UI preferences |
| 19 | + #[serde(default)] |
| 20 | + pub ui: UiSettings, |
| 21 | + |
| 22 | + /// Project management settings |
| 23 | + #[serde(default)] |
| 24 | + pub projects: ProjectSettings, |
| 25 | +} |
| 26 | + |
| 27 | +impl Default for ServerConfig { |
| 28 | + fn default() -> Self { |
| 29 | + Self { |
| 30 | + server: ServerSettings::default(), |
| 31 | + ui: UiSettings::default(), |
| 32 | + projects: ProjectSettings::default(), |
| 33 | + } |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +/// Server-specific settings |
| 38 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 39 | +#[serde(rename_all = "camelCase")] |
| 40 | +pub struct ServerSettings { |
| 41 | + /// Host to bind to (default: 127.0.0.1) |
| 42 | + #[serde(default = "default_host")] |
| 43 | + pub host: String, |
| 44 | + |
| 45 | + /// Port to listen on (default: 3333) |
| 46 | + #[serde(default = "default_port")] |
| 47 | + pub port: u16, |
| 48 | + |
| 49 | + /// CORS configuration |
| 50 | + #[serde(default)] |
| 51 | + pub cors: CorsSettings, |
| 52 | +} |
| 53 | + |
| 54 | +impl Default for ServerSettings { |
| 55 | + fn default() -> Self { |
| 56 | + Self { |
| 57 | + host: default_host(), |
| 58 | + port: default_port(), |
| 59 | + cors: CorsSettings::default(), |
| 60 | + } |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +fn default_host() -> String { |
| 65 | + "127.0.0.1".to_string() |
| 66 | +} |
| 67 | + |
| 68 | +fn default_port() -> u16 { |
| 69 | + 3333 |
| 70 | +} |
| 71 | + |
| 72 | +/// CORS settings |
| 73 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 74 | +#[serde(rename_all = "camelCase")] |
| 75 | +pub struct CorsSettings { |
| 76 | + /// Enable CORS (default: true) |
| 77 | + #[serde(default = "default_cors_enabled")] |
| 78 | + pub enabled: bool, |
| 79 | + |
| 80 | + /// Allowed origins (default: localhost development ports) |
| 81 | + #[serde(default = "default_cors_origins")] |
| 82 | + pub origins: Vec<String>, |
| 83 | +} |
| 84 | + |
| 85 | +impl Default for CorsSettings { |
| 86 | + fn default() -> Self { |
| 87 | + Self { |
| 88 | + enabled: default_cors_enabled(), |
| 89 | + origins: default_cors_origins(), |
| 90 | + } |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +fn default_cors_enabled() -> bool { |
| 95 | + true |
| 96 | +} |
| 97 | + |
| 98 | +fn default_cors_origins() -> Vec<String> { |
| 99 | + vec![ |
| 100 | + "http://localhost:5173".to_string(), // Vite dev server |
| 101 | + "http://localhost:3000".to_string(), // Next.js |
| 102 | + "http://localhost:3333".to_string(), // Self |
| 103 | + "http://127.0.0.1:5173".to_string(), |
| 104 | + "http://127.0.0.1:3000".to_string(), |
| 105 | + "http://127.0.0.1:3333".to_string(), |
| 106 | + "tauri://localhost".to_string(), // Tauri |
| 107 | + ] |
| 108 | +} |
| 109 | + |
| 110 | +/// UI preferences |
| 111 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 112 | +#[serde(rename_all = "camelCase")] |
| 113 | +pub struct UiSettings { |
| 114 | + /// Theme (auto, light, dark) |
| 115 | + #[serde(default = "default_theme")] |
| 116 | + pub theme: String, |
| 117 | + |
| 118 | + /// Locale (en, zh-CN) |
| 119 | + #[serde(default = "default_locale")] |
| 120 | + pub locale: String, |
| 121 | + |
| 122 | + /// Compact mode |
| 123 | + #[serde(default)] |
| 124 | + pub compact_mode: bool, |
| 125 | +} |
| 126 | + |
| 127 | +impl Default for UiSettings { |
| 128 | + fn default() -> Self { |
| 129 | + Self { |
| 130 | + theme: default_theme(), |
| 131 | + locale: default_locale(), |
| 132 | + compact_mode: false, |
| 133 | + } |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +fn default_theme() -> String { |
| 138 | + "auto".to_string() |
| 139 | +} |
| 140 | + |
| 141 | +fn default_locale() -> String { |
| 142 | + "en".to_string() |
| 143 | +} |
| 144 | + |
| 145 | +/// Project management settings |
| 146 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 147 | +#[serde(rename_all = "camelCase")] |
| 148 | +pub struct ProjectSettings { |
| 149 | + /// Auto-discover projects in common locations |
| 150 | + #[serde(default = "default_auto_discover")] |
| 151 | + pub auto_discover: bool, |
| 152 | + |
| 153 | + /// Maximum number of recent projects to track |
| 154 | + #[serde(default = "default_max_recent")] |
| 155 | + pub max_recent: usize, |
| 156 | +} |
| 157 | + |
| 158 | +impl Default for ProjectSettings { |
| 159 | + fn default() -> Self { |
| 160 | + Self { |
| 161 | + auto_discover: default_auto_discover(), |
| 162 | + max_recent: default_max_recent(), |
| 163 | + } |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +fn default_auto_discover() -> bool { |
| 168 | + true |
| 169 | +} |
| 170 | + |
| 171 | +fn default_max_recent() -> usize { |
| 172 | + 10 |
| 173 | +} |
| 174 | + |
| 175 | +/// Get the LeanSpec config directory path |
| 176 | +pub fn config_dir() -> PathBuf { |
| 177 | + dirs::home_dir() |
| 178 | + .map(|h| h.join(".lean-spec")) |
| 179 | + .unwrap_or_else(|| PathBuf::from(".lean-spec")) |
| 180 | +} |
| 181 | + |
| 182 | +/// Get the path to the config file |
| 183 | +pub fn config_path() -> PathBuf { |
| 184 | + config_dir().join("config.json") |
| 185 | +} |
| 186 | + |
| 187 | +/// Get the path to the projects registry file |
| 188 | +pub fn projects_path() -> PathBuf { |
| 189 | + config_dir().join("projects.json") |
| 190 | +} |
| 191 | + |
| 192 | +/// Load configuration from disk or return defaults |
| 193 | +pub fn load_config() -> Result<ServerConfig, ServerError> { |
| 194 | + let path = config_path(); |
| 195 | + |
| 196 | + if !path.exists() { |
| 197 | + // Try to migrate from YAML config |
| 198 | + let yaml_path = config_dir().join("config.yaml"); |
| 199 | + if yaml_path.exists() { |
| 200 | + return migrate_yaml_config(&yaml_path); |
| 201 | + } |
| 202 | + |
| 203 | + // Return defaults |
| 204 | + return Ok(ServerConfig::default()); |
| 205 | + } |
| 206 | + |
| 207 | + let content = fs::read_to_string(&path) |
| 208 | + .map_err(|e| ServerError::ConfigError(format!("Failed to read config: {}", e)))?; |
| 209 | + |
| 210 | + serde_json::from_str(&content) |
| 211 | + .map_err(|e| ServerError::ConfigError(format!("Failed to parse config: {}", e))) |
| 212 | +} |
| 213 | + |
| 214 | +/// Migrate from YAML config to JSON |
| 215 | +/// Note: This performs best-effort migration. Unknown YAML fields are ignored |
| 216 | +/// and defaults are used. The primary goal is to create a valid JSON config file. |
| 217 | +fn migrate_yaml_config(yaml_path: &PathBuf) -> Result<ServerConfig, ServerError> { |
| 218 | + let content = fs::read_to_string(yaml_path) |
| 219 | + .map_err(|e| ServerError::ConfigError(format!("Failed to read YAML config: {}", e)))?; |
| 220 | + |
| 221 | + // Try to parse YAML directly into our config struct |
| 222 | + // This handles fields that match between YAML and JSON formats |
| 223 | + let config = serde_yaml::from_str::<ServerConfig>(&content).unwrap_or_else(|e| { |
| 224 | + tracing::warn!( |
| 225 | + "Could not fully parse YAML config, using defaults: {}", |
| 226 | + e |
| 227 | + ); |
| 228 | + ServerConfig::default() |
| 229 | + }); |
| 230 | + |
| 231 | + // Save as JSON for future use |
| 232 | + if let Err(e) = save_config(&config) { |
| 233 | + tracing::warn!("Failed to save migrated config: {}", e); |
| 234 | + } |
| 235 | + |
| 236 | + Ok(config) |
| 237 | +} |
| 238 | + |
| 239 | +/// Save configuration to disk |
| 240 | +pub fn save_config(config: &ServerConfig) -> Result<(), ServerError> { |
| 241 | + let path = config_path(); |
| 242 | + |
| 243 | + // Ensure directory exists |
| 244 | + if let Some(parent) = path.parent() { |
| 245 | + fs::create_dir_all(parent) |
| 246 | + .map_err(|e| ServerError::ConfigError(format!("Failed to create config dir: {}", e)))?; |
| 247 | + } |
| 248 | + |
| 249 | + let content = serde_json::to_string_pretty(config) |
| 250 | + .map_err(|e| ServerError::ConfigError(format!("Failed to serialize config: {}", e)))?; |
| 251 | + |
| 252 | + fs::write(&path, content) |
| 253 | + .map_err(|e| ServerError::ConfigError(format!("Failed to write config: {}", e)))?; |
| 254 | + |
| 255 | + Ok(()) |
| 256 | +} |
| 257 | + |
| 258 | +#[cfg(test)] |
| 259 | +mod tests { |
| 260 | + use super::*; |
| 261 | + |
| 262 | + #[test] |
| 263 | + fn test_default_config() { |
| 264 | + let config = ServerConfig::default(); |
| 265 | + assert_eq!(config.server.host, "127.0.0.1"); |
| 266 | + assert_eq!(config.server.port, 3333); |
| 267 | + assert!(config.server.cors.enabled); |
| 268 | + assert_eq!(config.ui.theme, "auto"); |
| 269 | + assert_eq!(config.projects.max_recent, 10); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn test_config_serialization() { |
| 274 | + let config = ServerConfig::default(); |
| 275 | + let json = serde_json::to_string(&config).unwrap(); |
| 276 | + let parsed: ServerConfig = serde_json::from_str(&json).unwrap(); |
| 277 | + assert_eq!(parsed.server.port, config.server.port); |
| 278 | + } |
| 279 | +} |
0 commit comments