Skip to main content
← OpenMECP Documentation

omecp/
settings.rs

1//! Configuration management for OpenMECP.
2//!
3//! This module provides a flexible configuration system that allows users to customize
4//! program behavior through INI-format configuration files. The system supports
5//! hierarchical configuration with the following precedence:
6//!
7//! 1. Local configuration (`./omecp_config.cfg`)
8//! 2. User configuration (`~/.config/omecp/omecp_config.cfg`)
9//! 3. System configuration (`/etc/omecp/omecp_config.cfg`)
10//! 4. Built-in defaults
11//!
12//! # Configuration File Format
13//!
14//! The configuration uses INI format with sections for different types of settings:
15//!
16//! ```ini
17//! [extensions]
18//! gaussian = log
19//! orca = out
20//! xtb = out
21//! bagel = json
22//! custom = log
23//!
24//! [general]
25//! max_memory = 4GB
26//! default_nprocs = 4
27//!
28//! [logging]
29//! level = info
30//! file_logging = false
31//! ```
32//!
33//! # Usage
34//!
35//! ```rust
36//! use omecp::settings::SettingsManager;
37//! use omecp::config::QMProgram;
38//!
39//! let settings = SettingsManager::load()?;
40//! let extension = settings.get_output_extension(QMProgram::Orca);
41//! println!("ORCA output extension: {}", extension);
42//! ```
43
44use crate::config::QMProgram;
45use configparser::ini::Ini;
46use log::{debug, info, warn};
47use serde::{Deserialize, Serialize};
48use std::fs;
49use std::path::{Path, PathBuf};
50use thiserror::Error;
51
52/// Errors that can occur during configuration loading and processing.
53#[derive(Error, Debug)]
54pub enum ConfigError {
55    /// I/O error when reading configuration files
56    #[error("IO error: {0}")]
57    Io(#[from] std::io::Error),
58    /// INI parsing error
59    #[error("INI parsing error: {0}")]
60    IniParse(String),
61    /// Invalid configuration value
62    #[error("Invalid configuration value: {0}")]
63    InvalidValue(String),
64    /// Missing required configuration section
65    #[error("Missing required section: {0}")]
66    MissingSection(String),
67}
68
69/// Main configuration structure containing all program settings.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct Settings {
72    /// File extension settings for different QM programs
73    pub extensions: ExtensionSettings,
74    /// General program settings
75    pub general: GeneralSettings,
76    /// Logging configuration
77    pub logging: LoggingSettings,
78    /// Cleanup configuration
79    pub cleanup: CleanupSettings,
80}
81
82/// File extension settings for different quantum chemistry programs.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ExtensionSettings {
85    /// Gaussian output file extension (default: "log")
86    pub gaussian: String,
87    /// ORCA output file extension (default: "out")
88    pub orca: String,
89    /// XTB output file extension (default: "out")
90    pub xtb: String,
91    /// BAGEL output file extension (default: "json")
92    pub bagel: String,
93    /// Custom QM program output file extension (default: "log")
94    pub custom: String,
95}
96
97impl Default for ExtensionSettings {
98    fn default() -> Self {
99        Self {
100            gaussian: "log".to_string(),
101            orca: "out".to_string(),
102            xtb: "out".to_string(),
103            bagel: "json".to_string(),
104            custom: "log".to_string(),
105        }
106    }
107}
108
109/// General program settings.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct GeneralSettings {
112    /// Maximum memory usage (default: "4GB")
113    pub max_memory: String,
114    /// Default number of processors (default: 4)
115    pub default_nprocs: u32,
116    /// Print level for file operations and verbose output (default: 0)
117    /// 0 = quiet (minimal output), 1 = normal, 2 = verbose (show all file operations)
118    pub print_level: u32,
119}
120
121impl Default for GeneralSettings {
122    fn default() -> Self {
123        Self {
124            max_memory: "4GB".to_string(),
125            default_nprocs: 4,
126            print_level: 1, // Default to normal verbosity (1)
127        }
128    }
129}
130
131/// Logging configuration settings.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct LoggingSettings {
134    /// Log level (default: "info")
135    pub level: String,
136    /// Enable file-based logging (default: false)
137    /// When enabled, creates omecp_debug_<input_basename>.log with detailed debug info
138    pub file_logging: bool,
139}
140
141impl Default for LoggingSettings {
142    fn default() -> Self {
143        Self {
144            level: "info".to_string(),
145            file_logging: false,
146        }
147    }
148}
149
150/// Cleanup configuration settings.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CleanupSettings {
153    /// Enable or disable automatic cleanup (default: true)
154    pub enabled: bool,
155    /// Additional file extensions to preserve (comma-separated, optional)
156    /// These are extensions beyond the program's default output extension
157    /// Example: "xyz,backup,tmp"
158    pub preserve_extensions: Vec<String>,
159    /// Verbosity level for cleanup operations (default: 1)
160    /// 0 = quiet, 1 = normal, 2 = verbose
161    pub verbose: u32,
162    /// Perform cleanup every N optimization steps (default: 5)
163    /// Set to 0 to disable periodic cleanup
164    /// Example: 5 means cleanup after steps 5, 10, 15, etc.
165    pub cleanup_frequency: u32,
166}
167
168impl Default for CleanupSettings {
169    fn default() -> Self {
170        Self {
171            enabled: true,
172            preserve_extensions: Vec::new(),
173            verbose: 1,
174            cleanup_frequency: 5,
175        }
176    }
177}
178
179/// Configuration manager that handles loading and accessing program settings.
180pub struct SettingsManager {
181    settings: Settings,
182    config_source: String,
183}
184
185impl SettingsManager {
186    /// Loads configuration from available configuration files.
187    ///
188    /// Searches for configuration files in the following order:
189    /// 1. `./omecp_config.cfg` (current working directory)
190    /// 2. `~/.config/omecp/omecp_config.cfg` (user configuration)
191    /// 3. `/etc/omecp/omecp_config.cfg` (system configuration)
192    /// 4. Built-in defaults (fallback)
193    ///
194    /// # Returns
195    ///
196    /// Returns a `Result` containing:
197    /// - `Ok(SettingsManager)` - Successfully loaded configuration
198    /// - `Err(ConfigError)` - Configuration loading failed
199    ///
200    /// # Examples
201    ///
202    /// ```rust
203    /// use omecp::settings::SettingsManager;
204    ///
205    /// let settings = SettingsManager::load()?;
206    /// println!("Configuration loaded from: {}", settings.config_source());
207    /// ```
208    pub fn load() -> Result<Self, ConfigError> {
209        let (settings, source) = Self::load_from_files()?;
210        info!("Configuration loaded from: {}", source);
211        Ok(Self {
212            settings,
213            config_source: source,
214        })
215    }
216
217    /// Returns the source of the loaded configuration.
218    pub fn config_source(&self) -> &str {
219        &self.config_source
220    }
221
222    /// Gets a reference to the settings.
223    ///
224    /// # Returns
225    ///
226    /// Returns a reference to the internal Settings struct
227    pub fn settings(&self) -> &Settings {
228        &self.settings
229    }
230
231    /// Gets the output file extension for the specified QM program.
232    ///
233    /// # Arguments
234    ///
235    /// * `program` - The quantum chemistry program
236    ///
237    /// # Returns
238    ///
239    /// The file extension (without the dot) as a string slice
240    ///
241    /// # Examples
242    ///
243    /// ```rust
244    /// use omecp::config::QMProgram;
245    /// use omecp::settings::SettingsManager;
246    ///
247    /// let settings = SettingsManager::load()?;
248    /// let extension = settings.get_output_extension(QMProgram::Orca);
249    /// assert_eq!(extension, "out");
250    /// ```
251    pub fn get_output_extension(&self, program: QMProgram) -> &str {
252        match program {
253            QMProgram::Gaussian => &self.settings.extensions.gaussian,
254            QMProgram::Orca => &self.settings.extensions.orca,
255            QMProgram::Xtb => &self.settings.extensions.xtb,
256            QMProgram::Bagel => &self.settings.extensions.bagel,
257            QMProgram::Custom => &self.settings.extensions.custom,
258        }
259    }
260
261    /// Gets the general settings.
262    pub fn general(&self) -> &GeneralSettings {
263        &self.settings.general
264    }
265
266    /// Gets mutable general settings.
267    pub fn general_mut(&mut self) -> &mut GeneralSettings {
268        &mut self.settings.general
269    }
270
271    /// Gets the logging settings.
272    pub fn logging(&self) -> &LoggingSettings {
273        &self.settings.logging
274    }
275
276    /// Gets the extension settings.
277    pub fn extensions(&self) -> &ExtensionSettings {
278        &self.settings.extensions
279    }
280
281    /// Gets the cleanup settings.
282    pub fn cleanup(&self) -> &CleanupSettings {
283        &self.settings.cleanup
284    }
285
286    /// Loads configuration from files with hierarchical precedence.
287    fn load_from_files() -> Result<(Settings, String), ConfigError> {
288        let mut settings = Settings::default();
289        let mut config_source = "built-in defaults (no configuration file found)".to_string();
290
291        // Try to load system configuration
292        if let Some(system_path) = Self::get_system_config_path() {
293            if system_path.exists() {
294                match Self::load_config(&system_path) {
295                    Ok(system_config) => {
296                        settings.merge(system_config);
297                        config_source = format!("system config ({})", system_path.display());
298                        debug!(
299                            "Loaded system configuration from: {}",
300                            system_path.display()
301                        );
302                    }
303                    Err(e) => {
304                        warn!(
305                            "Failed to load system config from {}: {}",
306                            system_path.display(),
307                            e
308                        );
309                    }
310                }
311            }
312        }
313
314        // Try to load user configuration (overrides system)
315        if let Some(user_path) = Self::get_user_config_path() {
316            if user_path.exists() {
317                match Self::load_config(&user_path) {
318                    Ok(user_config) => {
319                        settings.merge(user_config);
320                        config_source = format!("user config ({})", user_path.display());
321                        debug!("Loaded user configuration from: {}", user_path.display());
322                    }
323                    Err(e) => {
324                        warn!(
325                            "Failed to load user config from {}: {}",
326                            user_path.display(),
327                            e
328                        );
329                    }
330                }
331            }
332        }
333
334        // Try to load local configuration (overrides user)
335        let local_path = PathBuf::from("omecp_config.cfg");
336        if local_path.exists() {
337            match Self::load_config(&local_path) {
338                Ok(local_config) => {
339                    settings.merge(local_config);
340                    config_source = format!("local config ({})", local_path.display());
341                    debug!("Loaded local configuration from: {}", local_path.display());
342                }
343                Err(e) => {
344                    warn!(
345                        "Failed to load local config from {}: {}",
346                        local_path.display(),
347                        e
348                    );
349                }
350            }
351        }
352
353        Ok((settings, config_source))
354    }
355
356    /// Loads configuration from a single INI file.
357    fn load_config(path: &Path) -> Result<Settings, ConfigError> {
358        let content = fs::read_to_string(path)?;
359        let mut ini = Ini::new();
360        ini.read(content)
361            .map_err(|e| ConfigError::IniParse(format!("Failed to parse INI: {}", e)))?;
362
363        let mut settings = Settings::default();
364
365        // Load extensions section
366        if let Some(extensions_map) = ini.get_map_ref().get("extensions") {
367            settings.extensions = Self::parse_extensions(extensions_map)?;
368        }
369
370        // Load general section
371        if let Some(general_map) = ini.get_map_ref().get("general") {
372            settings.general = Self::parse_general(general_map)?;
373        }
374
375        // Load logging section
376        if let Some(logging_map) = ini.get_map_ref().get("logging") {
377            settings.logging = Self::parse_logging(logging_map)?;
378        }
379
380        // Load cleanup section
381        if let Some(cleanup_map) = ini.get_map_ref().get("cleanup") {
382            settings.cleanup = Self::parse_cleanup(cleanup_map)?;
383        }
384
385        Ok(settings)
386    }
387
388    /// Parses the extensions section from INI configuration.
389    fn parse_extensions(
390        section: &std::collections::HashMap<String, Option<String>>,
391    ) -> Result<ExtensionSettings, ConfigError> {
392        let mut extensions = ExtensionSettings::default();
393
394        if let Some(Some(gaussian)) = section.get("gaussian") {
395            extensions.gaussian = gaussian.clone();
396        }
397        if let Some(Some(orca)) = section.get("orca") {
398            extensions.orca = orca.clone();
399        }
400        if let Some(Some(xtb)) = section.get("xtb") {
401            extensions.xtb = xtb.clone();
402        }
403        if let Some(Some(bagel)) = section.get("bagel") {
404            extensions.bagel = bagel.clone();
405        }
406        if let Some(Some(custom)) = section.get("custom") {
407            extensions.custom = custom.clone();
408        }
409
410        Ok(extensions)
411    }
412
413    /// Parses the general section from INI configuration.
414    fn parse_general(
415        section: &std::collections::HashMap<String, Option<String>>,
416    ) -> Result<GeneralSettings, ConfigError> {
417        let mut general = GeneralSettings::default();
418
419        if let Some(Some(max_memory)) = section.get("max_memory") {
420            general.max_memory = max_memory.clone();
421        }
422        if let Some(Some(default_nprocs)) = section.get("default_nprocs") {
423            general.default_nprocs = default_nprocs.parse().map_err(|_| {
424                ConfigError::InvalidValue(format!("Invalid default_nprocs: {}", default_nprocs))
425            })?;
426        }
427        if let Some(Some(print_level)) = section.get("print_level") {
428            general.print_level = print_level.parse().map_err(|_| {
429                ConfigError::InvalidValue(format!("Invalid print_level: {}", print_level))
430            })?;
431        }
432
433        Ok(general)
434    }
435
436    /// Parses the logging section from INI configuration.
437    fn parse_logging(
438        section: &std::collections::HashMap<String, Option<String>>,
439    ) -> Result<LoggingSettings, ConfigError> {
440        let mut logging = LoggingSettings::default();
441
442        if let Some(Some(level)) = section.get("level") {
443            logging.level = level.clone();
444        }
445        if let Some(Some(file_logging)) = section.get("file_logging") {
446            logging.file_logging = file_logging.parse().map_err(|_| {
447                ConfigError::InvalidValue(format!("Invalid file_logging value: {}", file_logging))
448            })?;
449        }
450
451        Ok(logging)
452    }
453
454    /// Parses the cleanup section from INI configuration.
455    fn parse_cleanup(
456        section: &std::collections::HashMap<String, Option<String>>,
457    ) -> Result<CleanupSettings, ConfigError> {
458        let mut cleanup = CleanupSettings::default();
459
460        if let Some(Some(enabled)) = section.get("enabled") {
461            cleanup.enabled = enabled.parse().map_err(|_| {
462                ConfigError::InvalidValue(format!("Invalid enabled value: {}", enabled))
463            })?;
464        }
465
466        if let Some(Some(preserve_extensions)) = section.get("preserve_extensions") {
467            // Parse comma-separated extensions
468            cleanup.preserve_extensions = preserve_extensions
469                .split(',')
470                .map(|s| s.trim().to_string())
471                .filter(|s| !s.is_empty())
472                .collect();
473        }
474
475        if let Some(Some(verbose)) = section.get("verbose") {
476            cleanup.verbose = verbose.parse().map_err(|_| {
477                ConfigError::InvalidValue(format!("Invalid verbose value: {}", verbose))
478            })?;
479        }
480
481        if let Some(Some(cleanup_frequency)) = section.get("cleanup_frequency") {
482            cleanup.cleanup_frequency = cleanup_frequency.parse().map_err(|_| {
483                ConfigError::InvalidValue(format!(
484                    "Invalid cleanup_frequency value: {}",
485                    cleanup_frequency
486                ))
487            })?;
488        }
489
490        Ok(cleanup)
491    }
492
493    /// Gets the system configuration file path.
494    fn get_system_config_path() -> Option<PathBuf> {
495        #[cfg(unix)]
496        {
497            Some(PathBuf::from("/etc/omecp/omecp_config.cfg"))
498        }
499        #[cfg(windows)]
500        {
501            // On Windows, use ProgramData directory
502            std::env::var("PROGRAMDATA")
503                .ok()
504                .map(|pd| PathBuf::from(pd).join("omecp").join("omecp_config.cfg"))
505        }
506    }
507
508    /// Gets the user configuration file path.
509    fn get_user_config_path() -> Option<PathBuf> {
510        #[cfg(unix)]
511        {
512            std::env::var("HOME").ok().map(|home| {
513                PathBuf::from(home)
514                    .join(".config")
515                    .join("omecp")
516                    .join("omecp_config.cfg")
517            })
518        }
519        #[cfg(windows)]
520        {
521            std::env::var("APPDATA").ok().map(|appdata| {
522                PathBuf::from(appdata)
523                    .join("omecp")
524                    .join("omecp_config.cfg")
525            })
526        }
527    }
528}
529
530impl SettingsManager {
531    /// Creates a default omecp_config.cfg file with all available configuration options.
532    ///
533    /// This function generates a comprehensive configuration file template with:
534    /// - All available configuration sections and parameters
535    /// - Default values for each parameter
536    /// - Detailed comments explaining each option
537    /// - Examples of common customizations
538    ///
539    /// # Arguments
540    ///
541    /// * `path` - Path where the omecp_config.cfg file should be created
542    ///
543    /// # Returns
544    ///
545    /// Returns a `Result` indicating success or failure of file creation
546    ///
547    /// # Examples
548    ///
549    /// ```rust
550    /// use omecp::settings::SettingsManager;
551    /// use std::path::Path;
552    ///
553    /// SettingsManager::create_template(Path::new("omecp_config.cfg"))?;
554    /// ```
555    pub fn create_template(path: &Path) -> Result<(), ConfigError> {
556        let template_content = Self::generate_template_content();
557        fs::write(path, template_content)?;
558        info!("Created settings template at: {}", path.display());
559        Ok(())
560    }
561
562    /// Generates the content for a omecp_config.cfg template file.
563    fn generate_template_content() -> String {
564        format!(
565            r#"# OpenMECP Configuration File
566# 
567# This file allows you to customize OpenMECP behavior without modifying source code.
568# Configuration files are loaded in hierarchical order with local settings taking precedence:
569#
570# 1. Current working directory (./omecp_config.cfg) - highest priority
571# 2. User config directory (~/.config/omecp/omecp_config.cfg on Unix, %APPDATA%/omecp/omecp_config.cfg on Windows)
572# 3. System config directory (/etc/omecp/omecp_config.cfg on Unix, %PROGRAMDATA%/omecp/omecp_config.cfg on Windows)
573# 4. Built-in defaults (fallback)
574#
575# Any missing sections or values will use the built-in defaults shown below.
576
577[extensions]
578# Output file extensions for different quantum chemistry programs
579# These extensions are used when reading calculation output files
580
581# Gaussian output files (default: log)
582gaussian = {}
583
584# ORCA output files (default: out)  
585orca = {}
586
587# XTB output files (default: out)
588xtb = {}
589
590# BAGEL output files (default: json)
591bagel = {}
592
593# Custom QM program output files (default: log)
594custom = {}
595
596[general]
597# General program settings
598
599# Maximum memory usage (default: 4GB)
600# Examples: 1GB, 2GB, 4GB, 8GB, 16GB, 32GB
601max_memory = {}
602
603# Default number of processors (default: 4)
604# Should match your system's CPU core count for optimal performance
605default_nprocs = {}
606
607# Print level for file operations and verbose output (default: 0)
608# 0 = quiet (minimal output, recommended for clean logs)
609# 1 = normal (standard output with key information)
610# 2 = verbose (show all file operations and detailed progress)
611print_level = {}
612
613[logging]
614# Logging configuration
615
616# Log level: debug, info, warn, error (default: info)
617# - debug: Detailed debugging information
618# - info: General information about program execution
619# - warn: Warning messages about potential issues
620# - error: Only error messages
621level = {}
622
623# Enable file-based logging (default: false)
624# When enabled, creates omecp_debug_<input_basename>.log with detailed debug info
625# Log messages go to file only (not console) for cleaner output
626# Useful for debugging and keeping detailed records of calculations
627file_logging = {}
628
629[cleanup]
630# Automatic file cleanup configuration
631# The cleanup system automatically removes temporary files after QM calculations
632# while preserving important output files (.out, .gbw, .engrad, etc.)
633
634# Enable or disable automatic cleanup (default: true)
635# Set to false to disable all automatic cleanup
636enabled = {}
637
638# Additional file extensions to preserve (comma-separated, optional)
639# These are extensions beyond the program's default output extension
640# Example: preserve_extensions = xyz,backup,important
641# Leave empty to only preserve program default extensions
642preserve_extensions = {}
643
644# Verbose logging for cleanup operations (default: 1)
645# 0 = quiet (minimal output)
646# 1 = normal (show cleanup summary)
647# 2 = verbose (show each file that is cleaned or preserved)
648verbose = {}
649
650# Perform cleanup every N optimization steps (default: 5)
651# Set to 0 to disable periodic cleanup during optimization
652# Example: 5 means cleanup after steps 5, 10, 15, etc.
653# This helps prevent file accumulation during long MECP optimization runs
654cleanup_frequency = {}
655
656# Example custom configurations:
657#
658# For a system with non-standard ORCA setup:
659# [extensions]
660# orca = out
661#
662# For high-memory calculations:
663# [general]
664# max_memory = 32GB
665# default_nprocs = 16
666#
667# For debugging calculations with file logging:
668# [logging]
669# level = debug
670# file_logging = true
671#
672# For custom XTB output format:
673# [extensions]
674# xtb = xyz
675#
676# For BAGEL with different output format:
677# [extensions]
678# bagel = out
679#
680# To disable automatic cleanup:
681# [cleanup]
682# enabled = false
683#
684# To preserve additional file types:
685# [cleanup]
686# preserve_extensions = xyz,backup
687"#,
688            // Extension defaults
689            ExtensionSettings::default().gaussian,
690            ExtensionSettings::default().orca,
691            ExtensionSettings::default().xtb,
692            ExtensionSettings::default().bagel,
693            ExtensionSettings::default().custom,
694            // General defaults
695            GeneralSettings::default().max_memory,
696            GeneralSettings::default().default_nprocs,
697            GeneralSettings::default().print_level,
698            // Logging defaults
699            LoggingSettings::default().level,
700            LoggingSettings::default().file_logging,
701            // Cleanup defaults
702            CleanupSettings::default().enabled,
703            // preserve_extensions is a Vec, convert to comma-separated string
704            CleanupSettings::default().preserve_extensions.join(","),
705            CleanupSettings::default().verbose,
706            CleanupSettings::default().cleanup_frequency,
707        )
708    }
709}
710
711impl Settings {
712    /// Merges another Settings instance into this one, overriding existing values.
713    fn merge(&mut self, other: Settings) {
714        // Merge extensions
715        if !other.extensions.gaussian.is_empty() {
716            self.extensions.gaussian = other.extensions.gaussian;
717        }
718        if !other.extensions.orca.is_empty() {
719            self.extensions.orca = other.extensions.orca;
720        }
721        if !other.extensions.xtb.is_empty() {
722            self.extensions.xtb = other.extensions.xtb;
723        }
724        if !other.extensions.bagel.is_empty() {
725            self.extensions.bagel = other.extensions.bagel;
726        }
727        if !other.extensions.custom.is_empty() {
728            self.extensions.custom = other.extensions.custom;
729        }
730
731        // Merge general settings
732        if !other.general.max_memory.is_empty() {
733            self.general.max_memory = other.general.max_memory;
734        }
735        if other.general.default_nprocs > 0 {
736            self.general.default_nprocs = other.general.default_nprocs;
737        }
738        if other.general.print_level > 0 {
739            self.general.print_level = other.general.print_level;
740        }
741
742        // Merge logging settings
743        if !other.logging.level.is_empty() {
744            self.logging.level = other.logging.level;
745        }
746        self.logging.file_logging = other.logging.file_logging;
747
748        // Merge cleanup settings
749        self.cleanup.enabled = other.cleanup.enabled;
750        if !other.cleanup.preserve_extensions.is_empty() {
751            self.cleanup.preserve_extensions = other.cleanup.preserve_extensions.clone();
752        }
753        if other.cleanup.verbose > 0 {
754            self.cleanup.verbose = other.cleanup.verbose;
755        }
756    }
757}