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}