nu_analytics/core/
config.rs

1//! Configuration module for `NuAnalytics`
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::fs;
6use std::path::PathBuf;
7
8/// Default CLI configuration loaded based on build profile.
9/// Uses release defaults in release mode, debug defaults in debug mode.
10#[cfg(not(debug_assertions))]
11const CONFIG_DEFAULTS: &str = include_str!("../assets/DefaultCLIConfigRelease.toml");
12
13#[cfg(debug_assertions)]
14const CONFIG_DEFAULTS: &str = include_str!("../assets/DefaultCLIConfigDebug.toml");
15
16#[cfg(not(debug_assertions))]
17const CONFIG_FILE_NAME: &str = "config.toml";
18
19#[cfg(debug_assertions)]
20const CONFIG_FILE_NAME: &str = "dconfig.toml";
21
22/// Logging configuration
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct LoggingConfig {
25    /// Log level (error, warn, info, debug)
26    #[serde(default)]
27    pub level: String,
28    /// Log file path
29    #[serde(default)]
30    pub file: String,
31    /// Enable verbose output
32    #[serde(default)]
33    pub verbose: bool,
34}
35
36/// Database configuration
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct DatabaseConfig {
39    /// Database token/connection string
40    #[serde(default)]
41    pub token: String,
42    /// Database endpoint
43    #[serde(default)]
44    pub endpoint: String,
45}
46
47/// Paths configuration
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct PathsConfig {
50    /// Directory for metrics CSV output files
51    #[serde(default)]
52    pub metrics_dir: String,
53    /// Directory for report output files
54    #[serde(default)]
55    pub reports_dir: String,
56}
57
58/// Main configuration structure
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct Config {
61    /// Logging settings
62    pub logging: LoggingConfig,
63    /// Database settings
64    #[serde(default)]
65    pub database: DatabaseConfig,
66    /// Path settings
67    #[serde(default)]
68    pub paths: PathsConfig,
69}
70
71/// Optional CLI overrides for configuration values
72#[derive(Debug, Clone, Default)]
73pub struct ConfigOverrides {
74    /// Override logging level
75    pub level: Option<String>,
76    /// Override log file path
77    pub file: Option<String>,
78    /// Override verbose flag
79    pub verbose: Option<bool>,
80    /// Override database token
81    pub db_token: Option<String>,
82    /// Override database endpoint
83    pub db_endpoint: Option<String>,
84    /// Override metrics output directory
85    pub metrics_dir: Option<String>,
86    /// Override reports output directory
87    pub reports_dir: Option<String>,
88}
89
90impl Config {
91    /// Get the `$NU_ANALYTICS` directory path
92    ///
93    /// Returns:
94    /// - Linux: `~/.config/nuanalytics`
95    /// - macOS: `~/Library/Application Support/nuanalytics`
96    /// - Windows: `%APPDATA%\nuanalytics`
97    #[must_use]
98    pub fn get_nuanalytics_dir() -> PathBuf {
99        dirs::config_dir()
100            .unwrap_or_else(|| PathBuf::from("."))
101            .join("nuanalytics")
102    }
103
104    /// Merge missing fields from defaults into this config
105    ///
106    /// This method is used when loading configuration to ensure that newly added
107    /// configuration fields are populated with their default values. Only fields
108    /// that are empty in the current config and non-empty in defaults are updated.
109    ///
110    /// # Returns
111    ///
112    /// `true` if any fields were added/changed, `false` otherwise
113    ///
114    /// # Examples
115    ///
116    /// ```ignore
117    /// let mut config = Config::from_toml(old_config_str)?;
118    /// let defaults = Config::from_defaults();
119    /// if config.merge_defaults(&defaults) {
120    ///     // Config was updated with new fields
121    ///     config.save()?;
122    /// }
123    /// ```
124    #[allow(clippy::useless_let_if_seq)]
125    pub fn merge_defaults(&mut self, defaults: &Self) -> bool {
126        let mut changed = false;
127
128        // Merge logging fields - only if they're empty (use defaults for empty values)
129        if self.logging.level.is_empty() && !defaults.logging.level.is_empty() {
130            self.logging.level.clone_from(&defaults.logging.level);
131            changed = true;
132        }
133        if self.logging.file.is_empty() && !defaults.logging.file.is_empty() {
134            self.logging.file.clone_from(&defaults.logging.file);
135            changed = true;
136        }
137
138        // Merge database fields - only add if default is non-empty
139        if self.database.token.is_empty() && !defaults.database.token.is_empty() {
140            self.database.token.clone_from(&defaults.database.token);
141            changed = true;
142        }
143        if self.database.endpoint.is_empty() && !defaults.database.endpoint.is_empty() {
144            self.database
145                .endpoint
146                .clone_from(&defaults.database.endpoint);
147            changed = true;
148        }
149
150        // Merge paths fields
151        if self.paths.metrics_dir.is_empty() && !defaults.paths.metrics_dir.is_empty() {
152            self.paths
153                .metrics_dir
154                .clone_from(&defaults.paths.metrics_dir);
155            changed = true;
156        }
157        if self.paths.reports_dir.is_empty() && !defaults.paths.reports_dir.is_empty() {
158            self.paths
159                .reports_dir
160                .clone_from(&defaults.paths.reports_dir);
161            changed = true;
162        }
163
164        changed
165    }
166
167    /// Apply CLI-provided overrides onto the loaded configuration
168    ///
169    /// This allows command-line arguments to override configuration file values
170    /// without modifying the persistent configuration file. Only non-`None` values
171    /// in the overrides struct will replace config values.
172    ///
173    /// # Arguments
174    ///
175    /// * `overrides` - A `ConfigOverrides` struct with optional override values
176    ///
177    /// # Examples
178    ///
179    /// ```ignore
180    /// let mut config = Config::load();
181    /// let overrides = ConfigOverrides {
182    ///     level: Some("debug".to_string()),
183    ///     ..Default::default()
184    /// };
185    /// config.apply_overrides(&overrides);
186    /// // config.logging.level is now "debug" for this run only
187    /// ```
188    pub fn apply_overrides(&mut self, overrides: &ConfigOverrides) {
189        if let Some(level) = &overrides.level {
190            self.logging.level.clone_from(level);
191        }
192        if let Some(file) = &overrides.file {
193            self.logging.file.clone_from(file);
194        }
195        if let Some(verbose) = overrides.verbose {
196            self.logging.verbose = verbose;
197        }
198
199        if let Some(token) = &overrides.db_token {
200            self.database.token.clone_from(token);
201        }
202        if let Some(endpoint) = &overrides.db_endpoint {
203            self.database.endpoint.clone_from(endpoint);
204        }
205
206        if let Some(metrics_dir) = &overrides.metrics_dir {
207            self.paths.metrics_dir.clone_from(metrics_dir);
208        }
209        if let Some(reports_dir) = &overrides.reports_dir {
210            self.paths.reports_dir.clone_from(reports_dir);
211        }
212    }
213
214    /// Get the user config file path
215    ///
216    /// Returns the full path to the configuration file:
217    /// - `config.toml` for release builds
218    /// - `dconfig.toml` for debug builds (allows separate debug config)
219    ///
220    /// The file is located in the directory returned by [`get_nuanalytics_dir`].
221    ///
222    /// [`get_nuanalytics_dir`]: Self::get_nuanalytics_dir
223    #[must_use]
224    pub fn get_config_file_path() -> PathBuf {
225        Self::get_nuanalytics_dir().join(CONFIG_FILE_NAME)
226    }
227
228    /// Expand `$NU_ANALYTICS` variable in a string
229    ///
230    /// Replaces occurrences of `$NU_ANALYTICS` with the actual nuanalytics
231    /// directory path. This allows configuration values to reference the
232    /// config directory dynamically.
233    ///
234    /// # Arguments
235    ///
236    /// * `value` - The string potentially containing `$NU_ANALYTICS`
237    ///
238    /// # Returns
239    ///
240    /// The string with `$NU_ANALYTICS` expanded to the actual path
241    ///
242    /// # Examples
243    ///
244    /// ```ignore
245    /// let expanded = Config::expand_variables("$NU_ANALYTICS/logs/app.log");
246    /// // Returns something like "/home/user/.config/nuanalytics/logs/app.log"
247    /// ```
248    #[must_use]
249    fn expand_variables(value: &str) -> String {
250        if value.contains("$NU_ANALYTICS") {
251            let nu_analytics_dir = Self::get_nuanalytics_dir();
252            value.replace("$NU_ANALYTICS", nu_analytics_dir.to_str().unwrap_or("."))
253        } else {
254            value.to_string()
255        }
256    }
257
258    /// Initialize config from a TOML string
259    ///
260    /// Parses a TOML configuration string and expands any `$NU_ANALYTICS` variables
261    /// in the values. Missing fields will use their serde defaults (typically empty
262    /// strings or false).
263    ///
264    /// # Arguments
265    ///
266    /// * `toml_str` - A TOML-formatted configuration string
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the TOML cannot be parsed or doesn't match the expected schema
271    ///
272    /// # Examples
273    ///
274    /// ```ignore
275    /// let config = Config::from_toml(r#"
276    /// [Logging]
277    /// level = "info"
278    /// file = "$NU_ANALYTICS/app.log"
279    /// "#)?;
280    /// ```
281    pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
282        let mut config: Self = toml::from_str(toml_str)?;
283
284        // Expand variables in config values
285        config.logging.file = Self::expand_variables(&config.logging.file);
286        config.database.token = Self::expand_variables(&config.database.token);
287        config.database.endpoint = Self::expand_variables(&config.database.endpoint);
288        config.paths.metrics_dir = Self::expand_variables(&config.paths.metrics_dir);
289        config.paths.reports_dir = Self::expand_variables(&config.paths.reports_dir);
290
291        Ok(config)
292    }
293
294    /// Load configuration from embedded defaults
295    ///
296    /// Loads the compiled-in default configuration that is bundled with the binary.
297    /// The defaults differ between debug and release builds:
298    /// - Debug: Uses `DefaultCLIConfigDebug.toml`
299    /// - Release: Uses `DefaultCLIConfigRelease.toml`
300    ///
301    /// # Returns
302    /// A `Config` instance with all values set to their defaults.
303    ///
304    /// # Panics
305    /// Panics if the embedded default configuration is invalid TOML or cannot be parsed.
306    /// This should never happen in practice since the defaults are compiled into the binary.
307    ///
308    /// # Examples
309    /// ```ignore
310    /// let config = Config::from_defaults();
311    /// assert_eq!(config.logging.level, "info");
312    /// ```
313    #[must_use]
314    pub fn from_defaults() -> Self {
315        Self::from_toml(CONFIG_DEFAULTS).expect("Failed to parse compiled-in default configuration")
316    }
317
318    /// Load configuration from file, or create from defaults if not found
319    ///
320    /// This is the primary way to load configuration. It handles several scenarios:
321    /// - If config file exists: Loads from file, merges missing fields from defaults, saves updated config
322    /// - If config file doesn't exist (first run): Creates config directory if needed, loads defaults, saves to file
323    ///
324    /// The merge behavior ensures that upgrading the application automatically adds new config
325    /// fields while preserving existing user settings.
326    ///
327    /// # Returns
328    /// A `Config` instance loaded from file or defaults. Falls back to defaults if any error occurs
329    /// during loading.
330    ///
331    /// # Examples
332    /// ```ignore
333    /// let config = Config::load();
334    /// // Config is now loaded from ~/.config/nuanalytics/config.toml (or defaults if first run)
335    /// ```
336    #[must_use]
337    pub fn load() -> Self {
338        let config_file = Self::get_config_file_path();
339        let defaults = Self::from_defaults();
340
341        if config_file.exists() {
342            if let Ok(content) = fs::read_to_string(&config_file) {
343                if let Ok(mut config) = Self::from_toml(&content) {
344                    // Merge any missing fields from defaults
345                    if config.merge_defaults(&defaults) {
346                        // Save the updated config with new fields
347                        let _ = config.save();
348                    }
349                    return config;
350                }
351            }
352        } else {
353            // First run: create directory and config file from defaults
354
355            // Create the directory if it doesn't exist
356            if let Some(parent) = config_file.parent() {
357                let _ = fs::create_dir_all(parent);
358            }
359
360            // Save the default config
361            let _ = defaults.save();
362
363            return defaults;
364        }
365
366        defaults
367    }
368
369    /// Save configuration to file
370    ///
371    /// Serializes the current configuration to TOML format and writes it to the
372    /// platform-specific config file. The config directory will be created if it
373    /// doesn't exist.
374    ///
375    /// The saved file will use the format:
376    /// ```toml
377    /// [Logging]
378    /// level = "info"
379    /// file = "$NU_ANALYTICS/logs/nuanalytics.log"
380    /// verbose = false
381    ///
382    /// [Database]
383    /// token = "your-token"
384    /// endpoint = "https://api.example.com"
385    ///
386    /// [Paths]
387    /// metrics_dir = "$NU_ANALYTICS/metrics"
388    /// reports_dir = "$NU_ANALYTICS/reports"
389    /// ```
390    ///
391    /// # Errors
392    /// Returns an error if:
393    /// - The config cannot be serialized to TOML (shouldn't happen)
394    /// - The config directory cannot be created
395    /// - The file cannot be written (permissions, disk full, etc.)
396    ///
397    /// # Examples
398    /// ```ignore
399    /// let mut config = Config::load()?;
400    /// config.logging.level = "debug".to_string();
401    /// config.save()?;
402    /// ```
403    pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
404        let config_file = Self::get_config_file_path();
405        if let Some(parent) = config_file.parent() {
406            fs::create_dir_all(parent)?;
407        }
408        let toml_str = toml::to_string_pretty(self)?;
409        fs::write(&config_file, toml_str)?;
410        Ok(())
411    }
412
413    /// Get a configuration value by key
414    ///
415    /// Retrieves a configuration value using a string key that maps to the config structure.
416    /// Supports all config fields in the format `section.field` or just `field` for top-level fields.
417    ///
418    /// Supported keys:
419    /// - `level`: Logging level ("debug", "info", "warn", "error")
420    /// - `file`: Log file path
421    /// - `verbose`: Verbose logging boolean
422    /// - `token`: Database authentication token
423    /// - `endpoint`: Database API endpoint
424    /// - `metrics_dir`: Metrics output directory path
425    /// - `reports_dir`: Reports output directory path
426    ///
427    /// # Arguments
428    /// - `key`: The configuration key to retrieve
429    ///
430    /// # Returns
431    /// - `Some(String)`: The configuration value as a string
432    /// - `None`: If the key is not recognized
433    ///
434    /// # Examples
435    /// ```ignore
436    /// let config = Config::load()?;
437    /// if let Some(level) = config.get("level") {
438    ///     println!("Current log level: {}", level);
439    /// }
440    /// ```
441    #[must_use]
442    pub fn get(&self, key: &str) -> Option<String> {
443        match key {
444            "level" => Some(self.logging.level.clone()),
445            "file" => Some(self.logging.file.clone()),
446            "verbose" => Some(self.logging.verbose.to_string()),
447            "token" => Some(self.database.token.clone()),
448            "endpoint" => Some(self.database.endpoint.clone()),
449            "metrics_dir" | "metrics-dir" => Some(self.paths.metrics_dir.clone()),
450            "reports_dir" | "reports-dir" => Some(self.paths.reports_dir.clone()),
451            _ => None,
452        }
453    }
454
455    /// Set a configuration value by key
456    ///
457    /// Updates a configuration value using a string key and value. The value will be
458    /// validated and converted to the appropriate type.
459    ///
460    /// Supported keys and their value formats:
461    /// - `level`: String ("debug", "info", "warn", "error", "trace", "off")
462    /// - `file`: String (file path, can include `$NU_ANALYTICS`)
463    /// - `verbose`: Boolean ("true" or "false")
464    /// - `token`: String (any value)
465    /// - `endpoint`: String (typically a URL)
466    /// - `metrics_dir`: String (directory path for metrics CSV files)
467    /// - `reports_dir`: String (directory path for report files)
468    ///
469    /// Note: This method updates the in-memory config. Call [`save()`](Config::save) to persist changes.
470    ///
471    /// # Arguments
472    /// - `key`: The configuration key to set
473    /// - `value`: The new value as a string
474    ///
475    /// # Errors
476    /// Returns an error if:
477    /// - The key is not recognized
478    /// - The value cannot be parsed (e.g., "maybe" for verbose boolean)
479    ///
480    /// # Examples
481    /// ```ignore
482    /// let mut config = Config::load()?;
483    /// config.set("level", "debug")?;
484    /// config.set("verbose", "true")?;
485    /// config.save()?;
486    /// ```
487    pub fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
488        match key {
489            "level" => self.logging.level = value.to_string(),
490            "file" => self.logging.file = value.to_string(),
491            "verbose" => {
492                self.logging.verbose = value
493                    .parse::<bool>()
494                    .map_err(|_| format!("Invalid boolean value for 'verbose': '{value}'"))?;
495            }
496            "token" => self.database.token = value.to_string(),
497            "endpoint" => self.database.endpoint = value.to_string(),
498            "metrics_dir" | "metrics-dir" => self.paths.metrics_dir = value.to_string(),
499            "reports_dir" | "reports-dir" => self.paths.reports_dir = value.to_string(),
500            _ => return Err(format!("Unknown config key: '{key}'")),
501        }
502        Ok(())
503    }
504
505    /// Unset a configuration value by key (reset to default)
506    ///
507    /// Resets a single configuration value to its default value. This is useful for
508    /// reverting individual settings without losing all customizations.
509    ///
510    /// The default value is taken from the provided defaults config (typically from
511    /// [`from_defaults()`](Config::from_defaults)).
512    ///
513    /// Note: This method updates the in-memory config. Call [`save()`](Config::save) to persist changes.
514    ///
515    /// # Arguments
516    /// - `key`: The configuration key to reset
517    /// - `defaults`: A config instance containing default values
518    ///
519    /// # Errors
520    /// Returns an error if the key is not recognized.
521    ///
522    /// # Examples
523    /// ```ignore
524    /// let mut config = Config::load()?;
525    /// let defaults = Config::from_defaults();
526    ///
527    /// config.set("level", "trace")?;
528    /// config.unset("level", &defaults)?;  // Resets to "info"
529    /// config.save()?;
530    /// ```
531    pub fn unset(&mut self, key: &str, defaults: &Self) -> Result<(), String> {
532        match key {
533            "level" => self.logging.level.clone_from(&defaults.logging.level),
534            "file" => self.logging.file.clone_from(&defaults.logging.file),
535            "verbose" => self.logging.verbose = defaults.logging.verbose,
536            "token" => self.database.token.clone_from(&defaults.database.token),
537            "endpoint" => self
538                .database
539                .endpoint
540                .clone_from(&defaults.database.endpoint),
541            "metrics_dir" | "metrics-dir" => self
542                .paths
543                .metrics_dir
544                .clone_from(&defaults.paths.metrics_dir),
545            "reports_dir" | "reports-dir" => self
546                .paths
547                .reports_dir
548                .clone_from(&defaults.paths.reports_dir),
549            _ => return Err(format!("Unknown config key: '{key}'")),
550        }
551        Ok(())
552    }
553
554    /// Reset all configuration to defaults
555    ///
556    /// Deletes the configuration file, causing the next [`load()`](Config::load) call to
557    /// recreate it from defaults. This is a destructive operation that removes all user
558    /// customizations.
559    ///
560    /// If the config file doesn't exist, this method succeeds without doing anything.
561    ///
562    /// # Safety
563    /// This is a destructive operation. The CLI typically requires user confirmation
564    /// before calling this method.
565    ///
566    /// # Errors
567    /// Returns an error if:
568    /// - The config file exists but cannot be deleted (permissions, file locked, etc.)
569    ///
570    /// # Examples
571    /// ```ignore
572    /// // Typically preceded by user confirmation
573    /// Config::reset()?;
574    /// println!("Configuration reset to defaults");
575    ///
576    /// // Next load will recreate from defaults
577    /// let config = Config::load()?;
578    /// ```
579    pub fn reset() -> Result<(), std::io::Error> {
580        let config_file = Self::get_config_file_path();
581        if config_file.exists() {
582            fs::remove_file(config_file)?;
583        }
584        Ok(())
585    }
586}
587
588impl fmt::Display for Config {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        writeln!(f, "[logging]")?;
591        writeln!(f, "  level = \"{}\"", self.logging.level)?;
592        writeln!(f, "  file = \"{}\"", self.logging.file)?;
593        writeln!(f, "  verbose = {}", self.logging.verbose)?;
594
595        writeln!(f, "\n[database]")?;
596        writeln!(f, "  token = \"{}\"", self.database.token)?;
597        writeln!(f, "  endpoint = \"{}\"", self.database.endpoint)?;
598
599        writeln!(f, "\n[paths]")?;
600        writeln!(f, "  metrics_dir = \"{}\"", self.paths.metrics_dir)?;
601        writeln!(f, "  reports_dir = \"{}\"", self.paths.reports_dir)?;
602
603        Ok(())
604    }
605}