aimx/aim/config.rs
1//! Application configuration management for AIM
2//!
3//! This module provides functionality for managing application configuration
4//! including workspace paths, AI provider settings, and user preferences.
5//! Configuration is stored in platform-specific locations and loaded lazily
6//! on first access.
7//!
8//! # Configuration Storage
9//!
10//! Configuration is stored in TOML format in platform-specific directories:
11//!
12//! - **Linux**: `~/.config/{app_name}/{app_name}.toml`
13//! - **macOS**: `~/Library/Application Support/net.imogen.{app_name}/{app_name}.toml`
14//! - **Windows**: `C:\Users\{username}\AppData\Roaming\imogen\{app_name}\config\{app_name}.toml`
15//!
16//! # Features
17//!
18//! - Thread-safe global singleton access
19//! - Runtime application name override
20//! - Dual provider configuration (local and external)
21//! - Automatic loading and saving
22//!
23//! # Examples
24//!
25//! ```no_run
26//! use aimx::{AppName, get_config, get_config_mut};
27//!
28//! // Set custom application name (must be done before any config access)
29//! AppName::set("my_app");
30//!
31//! // Access configuration for reading
32//! let config = get_config();
33//! let provider = config.read().unwrap().get_provider();
34//!
35//! // Access configuration for writing
36//! let mut config_guard = get_config_mut().expect("Failed to get config");
37//! config_guard.workspace_path = "/path/to/workspace".to_string();
38//! // Changes are automatically saved when guard is dropped
39//! ```
40
41use directories::ProjectDirs;
42use serde::{Deserialize, Serialize};
43use std::fs;
44use std::path::PathBuf;
45use std::sync::{OnceLock, RwLock};
46use crate::inference::{Api, Model, Capability, Provider};
47
48/// Application name configuration
49///
50/// This provides a flexible way to override the application name used for
51/// configuration file paths. The priority order is:
52/// 1. Runtime override via `AppName::set()` (highest priority)
53/// 2. Default "aimx" fallback
54///
55/// # Examples
56///
57/// ## Using runtime override (recommended for applications)
58/// ```rust
59/// use aimx::AppName;
60///
61/// // Set custom app name early in application startup
62/// AppName::set("myapp");
63/// ```
64pub struct AppName;
65
66impl AppName {
67 /// Set a custom application name at runtime
68 ///
69 /// This allows applications to override the app name programmatically.
70 /// Note: This must be called before any configuration operations.
71 ///
72 /// # Panics
73 ///
74 /// Panics if the app name has already been set.
75 ///
76 /// # Examples
77 ///
78 /// ```rust
79 /// use aimx::AppName;
80 ///
81 /// // Set custom app name early in application startup
82 /// AppName::set("my_custom_app");
83 /// ```
84 pub fn set(name: &'static str) {
85 APP_NAME.set(name).unwrap_or_else(|_| {
86 panic!("App name can only be set once");
87 });
88 }
89
90 /// Get the application name with runtime override support
91 ///
92 /// Returns the runtime override if set, otherwise returns the default "aimx".
93 pub fn get() -> &'static str {
94 // Check for runtime override first
95 if let Some(name) = APP_NAME.get() {
96 return name;
97 }
98
99 // Fall back to default
100 "aimx"
101 }
102}
103
104/// Shared static storage for the application name
105static APP_NAME: OnceLock<&'static str> = OnceLock::new();
106
107/// Application configuration structure
108///
109/// This struct contains all configuration settings for the AIM application,
110/// including session state and provider configurations for both local and external
111/// AI services.
112#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
113pub struct Config {
114 /// Path to the workspace directory
115 ///
116 /// This field persists the user's workspace location to restore it
117 /// on application startup. An empty string indicates no previous session.
118 pub workspace_path: String,
119
120 /// Path to the last opened file or directory
121 ///
122 /// This field persists the user's last working location to restore it
123 /// on application startup. An empty string indicates no previous session.
124 pub last_path: String,
125
126 /// Currently selected provider identifier
127 ///
128 /// Determines which provider configuration to use for AI requests.
129 /// Valid values are "local" or "external".
130 pub provider: String,
131
132 /// Configuration for local AI provider
133 ///
134 /// Contains settings for connecting to local AI services like Ollama,
135 /// running on the user's machine or local network.
136 pub local: Provider,
137
138 /// Configuration for external AI provider
139 ///
140 /// Contains settings for connecting to external AI services like OpenAI
141 /// or compatible APIs that require network access.
142 pub external: Provider,
143}
144
145impl Default for Config {
146 /// Creates a default configuration with sensible defaults
147 ///
148 /// The default configuration sets up two providers:
149 /// - Local provider using Ollama with mistral:latest models
150 /// - External provider using OpenAI-compatible API with GPT models
151 ///
152 /// # Returns
153 ///
154 /// Returns a `Config` instance with default settings:
155 /// - No last opened path
156 /// - Local provider selected by default
157 /// - Standard capability level for both providers
158 /// - 30 second connection timeout
159 /// - 2 minute request timeout
160 fn default() -> Self {
161 Self {
162 workspace_path: String::new(),
163 last_path: String::new(),
164 provider: "local".to_owned(),
165 local: Provider {
166 api: Api::Ollama,
167 url: "http://localhost:11434".to_owned(),
168 key: String::new(),
169 model: Model::Standard,
170 capability: Capability::Standard,
171 fast: "mistral:latest".to_owned(),
172 standard: "mistral:latest".to_owned(),
173 planning: "mistral:latest".to_owned(),
174 temperature: 0.7,
175 max_tokens: 2048,
176 connection_timeout_ms: 30000,
177 request_timeout_ms: 120000,
178 },
179 external: Provider {
180 api: Api::Openai,
181 url: "https://openrouter.ai/api/v1".to_owned(),
182 key: String::new(),
183 model: Model::Standard,
184 capability: Capability::Standard,
185 fast: "openai/gpt-oss-120b".to_owned(),
186 standard: "openai/gpt-5-mini".to_owned(),
187 planning: "openai/gpt-5".to_owned(),
188 temperature: 0.7,
189 max_tokens: 2048,
190 connection_timeout_ms: 30000,
191 request_timeout_ms: 120000,
192 },
193 }
194 }
195}
196
197impl Config {
198 /// Get the global singleton instance of the Config.
199 ///
200 /// This method uses `OnceLock` to ensure thread-safe one-time initialization
201 /// of the configuration. The configuration is loaded from the config file
202 /// or created with defaults if no existing config exists.
203 /// Uses RwLock to allow multiple concurrent readers while ensuring
204 /// exclusive access during writes.
205 ///
206 /// # Returns
207 ///
208 /// Returns a reference to the RwLock-protected singleton Config instance.
209 pub fn get_instance() -> &'static RwLock<Self> {
210 static INSTANCE: OnceLock<RwLock<Config>> = OnceLock::new();
211
212 INSTANCE.get_or_init(|| {
213 let config = Self::new().unwrap_or_else(|_| Self::default());
214 RwLock::new(config)
215 })
216 }
217
218 /// Get a read lock on the Config for reading configuration values.
219 ///
220 /// This is a convenience method that returns a read guard, allowing
221 /// multiple concurrent readers without blocking each other.
222 ///
223 /// # Returns
224 ///
225 /// Returns a `Result` containing the read guard or an error if the lock is poisoned.
226 pub fn read_lock() -> Result<std::sync::RwLockReadGuard<'static, Self>, Box<dyn std::error::Error>> {
227 Self::get_instance().read().map_err(|_| {
228 Box::<dyn std::error::Error>::from("Config lock is poisoned") as Box<dyn std::error::Error>
229 })
230 }
231
232 /// Get a write lock on the Config for writing configuration changes.
233 ///
234 /// This is a convenience method that returns a write guard, providing
235 /// exclusive access for modifying configuration values.
236 ///
237 /// # Returns
238 ///
239 /// Returns a `Result` containing the write guard or an error if the lock is poisoned.
240 pub fn write_lock() -> Result<std::sync::RwLockWriteGuard<'static, Self>, Box<dyn std::error::Error>> {
241 Self::get_instance().write().map_err(|_| {
242 Box::<dyn std::error::Error>::from("Config lock is poisoned") as Box<dyn std::error::Error>
243 })
244 }
245
246 /// Creates a new Config instance
247 ///
248 /// This function attempts to load an existing configuration from the standard
249 /// config directory. If no configuration exists or loading fails, it creates
250 /// a new default configuration and saves it to disk.
251 ///
252 /// # Returns
253 ///
254 /// * `Ok(Config)` - Successfully loaded or created configuration
255 /// * `Err(Box<dyn std::error::Error>)` - Failed to load or save configuration
256 ///
257 /// # Examples
258 ///
259 /// ```rust
260 /// use aimx::{AppName, aim::Config};
261 /// AppName::set("app_test");
262 ///
263 /// let config = Config::new().expect("Failed to create config");
264 /// ```
265 pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
266 match Self::load() {
267 Ok(config) => Ok(config),
268 Err(_) => {
269 let default_config = Self::default();
270 default_config.save()?;
271 Ok(default_config)
272 }
273 }
274 }
275
276 /// Loads configuration from the standard config directory
277 ///
278 /// This function reads the TOML configuration file from the platform-specific
279 /// configuration directory and deserializes it into an Config struct.
280 ///
281 /// # Returns
282 ///
283 /// * `Ok(Config)` - Successfully loaded configuration
284 /// * `Err(Box<dyn std::error::Error>)` - Failed to load or parse configuration
285 ///
286 /// # Errors
287 ///
288 /// This function will return an error if:
289 /// * The configuration file cannot be read from disk
290 /// * The configuration file is not valid TOML
291 /// * The configuration file structure doesn't match Config
292 pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
293 let path = Self::get_config_path()?;
294 let content = fs::read_to_string(path)?;
295 let config = toml::from_str(&content)?;
296 Ok(config)
297 }
298
299 /// Saves the configuration to the standard config directory
300 ///
301 /// Serializes the configuration to TOML format and writes it to the platform-specific
302 /// configuration directory. Creates any necessary parent directories.
303 ///
304 /// # Returns
305 ///
306 /// * `Ok(())` - Successfully saved configuration
307 /// * `Err(Box<dyn std::error::Error>)` - Failed to save configuration
308 ///
309 /// # Errors
310 ///
311 /// This function will return an error if:
312 /// * The configuration directory cannot be created
313 /// * The configuration file cannot be written to disk
314 /// * Serialization to TOML fails
315 ///
316 /// # Examples
317 ///
318 /// ```rust
319 /// use aimx::{AppName, aim::Config};
320 /// AppName::set("app_test");
321 ///
322 /// let mut config = Config::default();
323 /// config.last_path = "/path/to/my/project".to_string();
324 /// config.save().expect("Failed to save config");
325 /// ```
326 pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
327 let path = Self::get_config_path()?;
328 // Create parent directories
329 if let Some(parent) = path.parent() {
330 fs::create_dir_all(parent)?;
331 }
332
333 let content = toml::to_string_pretty(self)?;
334 fs::write(path, content)?;
335 Ok(())
336 }
337
338 /// Gets the platform-specific configuration file path
339 ///
340 /// Determines the appropriate configuration directory for the current platform
341 /// and returns the full path to the config.toml file.
342 ///
343 /// # Returns
344 ///
345 /// * `Ok(PathBuf)` - Path to the configuration file
346 /// * `Err(Box<dyn std::error::Error>)` - Failed to determine config directory
347 ///
348 /// # Platform-specific locations
349 ///
350 /// * Linux: `~/.config/aimx/config.toml`
351 /// * macOS: `~/Library/Application Support/net.imogen.aimx/config.toml`
352 /// * Windows: `C:\Users\{username}\AppData\Roaming\imogen\aimx\config\config.toml`
353 ///
354 /// # Examples
355 ///
356 /// ```rust
357 /// use aimx::{AppName, aim::Config};
358 /// AppName::set("app_test");
359 ///
360 /// let config_path = Config::get_config_path().expect("Failed to get config path");
361 /// println!("Config file location: {:?}", config_path);
362 /// ```
363 pub fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
364 let app_name: &str = AppName::get();
365 let proj_dirs = ProjectDirs::from("net", "imogen", app_name)
366 .ok_or("Failed to determine project directories")?;
367 Ok(proj_dirs.config_dir().join(format!("{}.toml", app_name)))
368 }
369
370 /// Gets the currently active provider configuration
371 ///
372 /// Returns a reference to either the local or external provider based on
373 /// the `provider` field value.
374 ///
375 /// # Returns
376 ///
377 /// * `&Provider` - Reference to the active provider configuration
378 ///
379 /// # Selection logic
380 ///
381 /// * If `provider` is "external" → returns `&self.external`
382 /// * Any other value → returns `&self.local` (default behavior)
383 ///
384 /// # Examples
385 ///
386 /// ```rust
387 /// use aimx::{AppName, aim::Config};
388 /// AppName::set("app_test");
389 ///
390 /// let config = Config::new().expect("Failed to create config");
391 /// let provider = config.get_provider();
392 /// println!("Using API: {:?}", provider.api);
393 /// println!("Fast model: {}", provider.fast);
394 /// ```
395 pub fn get_provider(&self) -> &Provider {
396 match self.provider.as_str() {
397 "external" => &self.external,
398 _ => &self.local,
399 }
400 }
401}
402
403/// Returns the global singleton instance of the Config.
404///
405/// This function provides a simpler interface for accessing the Config singleton
406/// without needing to manage the RwLock directly.
407///
408/// # Returns
409///
410/// Returns a reference to the singleton Config instance.
411///
412/// # Examples
413///
414/// ```rust
415/// use aimx::get_config;
416///
417/// let config = get_config();
418/// println!("Last path: {}", config.read().unwrap().last_path);
419/// ```
420pub fn get_config() -> &'static RwLock<Config> {
421 Config::get_instance()
422}
423
424/// Returns a mutable reference to the global singleton instance of the Config.
425///
426/// This function provides a simpler interface for accessing the Config singleton
427/// with write access for modifying configuration settings.
428///
429/// # Returns
430///
431/// Returns a `Result` containing the write guard or an error if the lock is poisoned.
432///
433/// # Examples
434///
435/// ```rust
436/// use aimx::get_config_mut;
437///
438/// let mut config_guard = get_config_mut().expect("Failed to get config mutex");
439/// config_guard.last_path = "/new/path".to_string();
440/// // Lock is automatically released when guard goes out of scope
441/// ```
442pub fn get_config_mut() -> Result<std::sync::RwLockWriteGuard<'static, Config>, Box<dyn std::error::Error>> {
443 Config::write_lock()
444}