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}