xvc_config/
lib.rs

1//! Provides a general solution to maintain configuration spanned across different sources.
2//!
3//!
4//! - Default Values
5//! - System configuration
6//! - User configuration
7//! - Public project configuration (tracked by Git)
8//! - Private (local) project configuration (not tracked by Git)
9//! - Environment variables
10//! - Command line options
11//!
12//!
13//! The configuration keys are string.
14//! Configuration values can be:
15//! - string
16//! - bool
17//! - int
18//! - float
19//!
20//! Configuration files are in TOML.
21//!
22//! Options can be nested like `group.name = value`.
23//!
24//! Each option can be tracked to its source via [XvcConfigOption].
25//!
26#![warn(missing_docs)]
27#![forbid(unsafe_code)]
28pub mod config_params;
29pub mod configuration;
30pub mod error;
31
32pub use config_params::XvcLoadParams;
33pub use configuration::blank_optional_config;
34pub use configuration::default_config;
35pub use configuration::initial_xvc_configuration_file;
36pub use configuration::XvcConfiguration;
37pub use configuration::XvcOptionalConfiguration;
38
39use directories_next::{BaseDirs, ProjectDirs, UserDirs};
40use lazy_static::lazy_static;
41use serde::{Deserialize, Serialize};
42use std::{
43    collections::HashMap,
44    fmt,
45    path::{Path, PathBuf},
46    str::FromStr,
47};
48use xvc_walker::AbsolutePath;
49
50use strum_macros::{Display as EnumDisplay, EnumString, IntoStaticStr};
51
52use crate::error::{Error, Result};
53use toml::Value as TomlValue;
54
55lazy_static! {
56    /// System specific configuration directory.
57    /// see [directories_next::ProjectDirs].
58    pub static ref SYSTEM_CONFIG_DIRS: Option<ProjectDirs> =
59        ProjectDirs::from("com", "emresult", "xvc");
60
61    /// User configuration directories.
62    /// See [directories_next::BaseDirs].
63    pub static ref USER_CONFIG_DIRS: Option<BaseDirs> = BaseDirs::new();
64
65    /// User directories.
66    /// see [directories_next::UserDirs].
67    pub static ref USER_DIRS: Option<UserDirs> = UserDirs::new();
68}
69
70/// Define the source where an option is obtained
71#[derive(
72    Debug, Copy, Clone, EnumString, EnumDisplay, IntoStaticStr, Serialize, Deserialize, PartialEq,
73)]
74#[strum(serialize_all = "lowercase")]
75pub enum XvcConfigOptionSource {
76    /// Default value defined in source code
77    Default,
78    /// System-wide configuration value from [SYSTEM_CONFIG_DIRS]
79    System,
80    /// User's configuration value from [USER_CONFIG_DIRS]
81    Global,
82    /// Project specific configuration that can be shared
83    Project,
84    /// Project specific configuration that's not meant to be shared (personal/local)
85    Local,
86    /// Options obtained from the command line
87    CommandLine,
88    /// Options from environment variables
89    Environment,
90    /// Options set while running the software, automatically.
91    Runtime,
92}
93
94/// The option and its source.
95#[derive(Debug, Copy, Clone)]
96pub struct XvcConfigOption<T> {
97    /// Where did we get this option?
98    pub source: XvcConfigOptionSource,
99    /// The key and value
100    pub option: T,
101}
102
103/// Verbosity levels for Xvc CLI
104#[derive(Debug, Copy, Clone, EnumString, EnumDisplay, IntoStaticStr)]
105pub enum XvcVerbosity {
106    /// Do not print anything
107    #[strum(serialize = "quiet", serialize = "0")]
108    Quiet,
109    /// Print default output and errors
110    #[strum(serialize = "default", serialize = "error", serialize = "1")]
111    Default,
112    /// Print default output, warnings and errors
113    #[strum(serialize = "warn", serialize = "2")]
114    Warn,
115    /// Print default output, info, warnings and errors
116    #[strum(serialize = "info", serialize = "3")]
117    Info,
118    /// Print default output, errors, warnings, info and debug output
119    #[strum(serialize = "debug", serialize = "4")]
120    Debug,
121    /// Print default output, errors, warnings, info, debug and tracing output
122    #[strum(serialize = "trace", serialize = "5")]
123    Trace,
124}
125
126impl From<u8> for XvcVerbosity {
127    fn from(v: u8) -> Self {
128        match v {
129            0 => Self::Quiet,
130            1 => Self::Default,
131            2 => Self::Warn,
132            3 => Self::Info,
133            4 => Self::Debug,
134            _ => Self::Trace,
135        }
136    }
137}
138
139/// A configuration value with its source
140#[derive(Debug, Clone)]
141pub struct XvcConfigValue {
142    /// Where did we get this value?
143    pub source: XvcConfigOptionSource,
144    /// The value itself
145    pub value: TomlValue,
146}
147
148impl XvcConfigValue {
149    /// Create a new XvcConfigValue
150    pub fn new(source: XvcConfigOptionSource, value: TomlValue) -> Self {
151        Self { source, value }
152    }
153}
154
155/// A set of options defined as a TOML document from a single [XvcConfigOptionSource]
156#[derive(Debug, Clone)]
157pub struct XvcConfigMap {
158    /// Where does these option come from?
159    pub source: XvcConfigOptionSource,
160    /// The key-value map for the options
161    pub map: HashMap<String, TomlValue>,
162}
163
164/// Keeps track of all Xvc configuration.
165///
166/// It's created by [XvcRoot] using the options from [XvcConfigInitParams].
167/// Keeps the current directory, that can also be set manually from the command line.
168/// It loads several config maps (one for each [XvcConfigOptionSource]) and cascadingly merges them to get an actual configuration.
169#[derive(Debug, Clone)]
170pub struct XvcConfig {
171    /// Current directory. It can be set with xvc -C option
172    pub current_dir: AbsolutePath,
173    /// System configuration from the system directories
174    system_config: XvcOptionalConfiguration,
175    /// User's configuration value from [USER_CONFIG_DIRS]
176    user_config: XvcOptionalConfiguration,
177    /// Project specific configuration that can be shared
178    project_config: XvcOptionalConfiguration,
179    /// Project specific configuration that's not meant to be shared (personal/local)
180    local_config: XvcOptionalConfiguration,
181    /// Options obtained from the command line
182    command_line_config: XvcOptionalConfiguration,
183    /// Options from environment variables
184    environment_config: XvcOptionalConfiguration,
185    /// Options set while running the software, automatically.
186    runtime_config: XvcOptionalConfiguration,
187    /// The current configuration map, updated cascadingly
188    the_config: XvcConfiguration,
189}
190
191impl fmt::Display for XvcConfig {
192    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
193        writeln!(f, "\nCurrent Configuration")?;
194        writeln!(f, "current_dir: {}", self.current_dir)?;
195        writeln!(f, "{}", &self.the_config)?;
196        writeln!(f)
197    }
198}
199
200impl XvcConfig {
201    /// Loads the default configuration from `p`.
202    ///
203    /// The configuration must be a valid TOML document.
204    pub fn new_v2(config_init_params: &XvcLoadParams) -> Result<Self> {
205        let default_conf = default_config();
206
207        let system_config = if config_init_params.include_system_config {
208            Self::system_config_file()
209                .and_then(|path| Self::load_optional_config_from_file(&path))
210                .unwrap_or(blank_optional_config())
211        } else {
212            blank_optional_config()
213        };
214
215        let user_config = if config_init_params.include_user_config {
216            Self::user_config_file()
217                .and_then(|path| Self::load_optional_config_from_file(&path))
218                .unwrap_or(blank_optional_config())
219        } else {
220            blank_optional_config()
221        };
222
223        let mut project_config = if config_init_params.include_project_config {
224            if let Some(ref config_path) = config_init_params.project_config_path {
225                Self::load_optional_config_from_file(config_path)?
226            } else {
227                blank_optional_config()
228            }
229        } else {
230            blank_optional_config()
231        };
232
233        if let Some(ref config_path) = config_init_params.project_config_path {
234            if let Some(ref mut core) = project_config.core {
235                if let Some(guid) = core.guid.take() {
236                    Self::migrate_config_to_07(config_path, guid)?;
237                }
238            }
239        }
240
241        let local_config = if config_init_params.include_local_config {
242            if let Some(ref config_path) = config_init_params.local_config_path {
243                Self::load_optional_config_from_file(config_path)?
244            } else {
245                blank_optional_config()
246            }
247        } else {
248            blank_optional_config()
249        };
250
251        let environment_config = if config_init_params.include_environment_config {
252            XvcOptionalConfiguration::from_env()
253        } else {
254            blank_optional_config()
255        };
256
257        let command_line_config =
258            Self::load_command_line_config(&config_init_params.command_line_config)?;
259
260        let runtime_config = blank_optional_config();
261
262        let the_config = default_conf
263            .merge_with_optional(&system_config)
264            .merge_with_optional(&user_config)
265            .merge_with_optional(&project_config)
266            .merge_with_optional(&local_config)
267            .merge_with_optional(&environment_config)
268            .merge_with_optional(&command_line_config)
269            .merge_with_optional(&runtime_config);
270
271        Ok(XvcConfig {
272            current_dir: config_init_params.current_dir.clone(),
273            system_config,
274            user_config,
275            project_config,
276            local_config,
277            command_line_config,
278            environment_config,
279            runtime_config,
280            the_config,
281        })
282    }
283
284    /// Return the current configuration
285    pub fn config(&self) -> &XvcConfiguration {
286        &self.the_config
287    }
288
289    /// Return the system configuration file path for Xvc
290    /// FIXME: Return Absolute Path
291    pub fn system_config_file() -> Result<PathBuf> {
292        Ok(SYSTEM_CONFIG_DIRS
293            .to_owned()
294            .ok_or(Error::CannotDetermineSystemConfigurationPath)?
295            .config_dir()
296            .to_path_buf())
297    }
298
299    /// Return the user configuration file path for Xvc
300    pub fn user_config_file() -> Result<PathBuf> {
301        Ok(USER_CONFIG_DIRS
302            .to_owned()
303            .ok_or(Error::CannotDetermineUserConfigurationPath)?
304            .config_dir()
305            .join("xvc"))
306    }
307
308    /// Load an [XvcOptionalConfiguration] from a file or returns a blank config if the file is not found
309    pub fn load_optional_config_from_file(path: &Path) -> Result<XvcOptionalConfiguration> {
310        if path.exists() {
311            let opt_config = XvcOptionalConfiguration::from_file(path)?;
312            Ok(opt_config)
313        } else {
314            Ok(blank_optional_config())
315        }
316    }
317
318    fn migrate_config_to_07(config_path: &Path, guid: String) -> Result<()> {
319        if let Some(xvc_dir) = config_path.parent() {
320            let guid_path = xvc_dir.join("guid");
321            if !guid_path.exists() {
322                std::fs::write(&guid_path, &guid).map_err(|e| Error::IoError { source: e })?;
323            }
324
325            if let Some(project_root) = xvc_dir.parent() {
326                let gitignore_path = project_root.join(".gitignore");
327                if gitignore_path.exists() {
328                    let content = std::fs::read_to_string(&gitignore_path)
329                        .map_err(|e| Error::IoError { source: e })?;
330                    let mut new_content = content.clone();
331                    let mut changed = false;
332
333                    if !content.contains("!.xvc/guid") {
334                        new_content.push_str("\n!.xvc/guid");
335                        changed = true;
336                    }
337
338                    if !content.contains("!.xvc/pipelines/") {
339                        new_content.push_str("\n!.xvc/pipelines/");
340                        changed = true;
341                    }
342
343                    if changed {
344                        std::fs::write(&gitignore_path, new_content)
345                            .map_err(|e| Error::IoError { source: e })?;
346                    }
347                }
348            }
349        }
350
351        let content =
352            std::fs::read_to_string(config_path).map_err(|e| Error::IoError { source: e })?;
353        let mut new_lines = Vec::new();
354        for line in content.lines() {
355            let trimmed = line.trim();
356            if !trimmed.starts_with("guid =") && !trimmed.starts_with("guid=") {
357                new_lines.push(line);
358            }
359        }
360        let mut new_content = new_lines.join("\n");
361        if content.ends_with('\n') {
362            new_content.push('\n');
363        }
364        std::fs::write(config_path, new_content).map_err(|e| Error::IoError { source: e })?;
365
366        Ok(())
367    }
368
369    /// Loads configuration from command line arguments.
370    /// Parses a vector of key-value strings into an [XvcOptionalConfiguration].
371    pub fn load_command_line_config(
372        cli_opt_vector: &Option<Vec<String>>,
373    ) -> Result<XvcOptionalConfiguration> {
374        if let Some(cli_opts) = cli_opt_vector {
375            let cli_opts_hm = Self::parse_key_value_vector(cli_opts);
376            Ok(XvcOptionalConfiguration::from_hash_map("", &cli_opts_hm))
377        } else {
378            Ok(XvcOptionalConfiguration::default())
379        }
380    }
381
382    /// Parses a vector of strings, and returns a `HashMap<String, String>`.
383    fn parse_key_value_vector(cli_opts: &Vec<String>) -> HashMap<String, String> {
384        cli_opts
385            .into_iter()
386            .map(|str| {
387                let elements: Vec<&str> = str.split('=').collect();
388                let key = elements[0].trim().to_owned();
389                let value = elements[1].trim().to_owned();
390                (key, value)
391            })
392            .collect()
393    }
394
395    /// Where do we run the command?
396    ///
397    /// This can be modified by options in the command line, so it's not always equal to [std::env::current_dir()]
398    pub fn current_dir(&self) -> Result<&AbsolutePath> {
399        let pb = &self.current_dir;
400        Ok(pb)
401    }
402
403    /// The current verbosity level.
404    /// Set with `core.verbosity` option.
405    pub fn verbosity(&self) -> XvcVerbosity {
406        let verbosity_str = &self.the_config.core.verbosity;
407        match XvcVerbosity::from_str(verbosity_str) {
408            Ok(v) => v,
409            Err(source) => {
410                Error::StrumError { source }.warn();
411                XvcVerbosity::Default
412            }
413        }
414    }
415
416    /// Find where a configuration value is defined, by checking configuration layers from highest priority to lowest.
417    pub fn find_value_source(&self, key: &str) -> Option<XvcConfigOptionSource> {
418        let layers = [
419            (XvcConfigOptionSource::Runtime, &self.runtime_config),
420            (
421                XvcConfigOptionSource::CommandLine,
422                &self.command_line_config,
423            ),
424            (XvcConfigOptionSource::Environment, &self.environment_config),
425            (XvcConfigOptionSource::Local, &self.local_config),
426            (XvcConfigOptionSource::Project, &self.project_config),
427            (XvcConfigOptionSource::Global, &self.user_config), // enum variant is Global
428            (XvcConfigOptionSource::System, &self.system_config),
429        ];
430
431        for (source, config) in &layers {
432            if self.key_exists_in_optional_config(config, key) {
433                return Some(*source);
434            }
435        }
436
437        if self.is_valid_key(key) {
438            Some(XvcConfigOptionSource::Default)
439        } else {
440            None
441        }
442    }
443
444    /// Helper function to check if a specific key exists and has a `Some` value within an [XvcOptionalConfiguration] instance.
445    /// It traverses the optional configuration structure based on the `key`'s dot-separated parts.
446    fn key_exists_in_optional_config(&self, config: &XvcOptionalConfiguration, key: &str) -> bool {
447        let parts: Vec<&str> = key.split('.').collect();
448        match parts.as_slice() {
449            // core
450            ["core", "xvc_repo_version"] => config
451                .core
452                .as_ref()
453                .is_some_and(|c| c.xvc_repo_version.is_some()),
454            ["core", "verbosity"] => config.core.as_ref().is_some_and(|c| c.verbosity.is_some()),
455            // git
456            ["git", "use_git"] => config.git.as_ref().is_some_and(|c| c.use_git.is_some()),
457            ["git", "command"] => config.git.as_ref().is_some_and(|c| c.command.is_some()),
458            ["git", "auto_commit"] => config.git.as_ref().is_some_and(|c| c.auto_commit.is_some()),
459            ["git", "auto_stage"] => config.git.as_ref().is_some_and(|c| c.auto_stage.is_some()),
460            // cache
461            ["cache", "algorithm"] => config.cache.as_ref().is_some_and(|c| c.algorithm.is_some()),
462            // file.track
463            ["file", "track", "no_commit"] => config
464                .file
465                .as_ref()
466                .and_then(|f| f.track.as_ref())
467                .is_some_and(|t| t.no_commit.is_some()),
468            ["file", "track", "force"] => config
469                .file
470                .as_ref()
471                .and_then(|f| f.track.as_ref())
472                .is_some_and(|t| t.force.is_some()),
473            ["file", "track", "text_or_binary"] => config
474                .file
475                .as_ref()
476                .and_then(|f| f.track.as_ref())
477                .is_some_and(|t| t.text_or_binary.is_some()),
478            ["file", "track", "no_parallel"] => config
479                .file
480                .as_ref()
481                .and_then(|f| f.track.as_ref())
482                .is_some_and(|t| t.no_parallel.is_some()),
483            ["file", "track", "include_git_files"] => config
484                .file
485                .as_ref()
486                .and_then(|f| f.track.as_ref())
487                .is_some_and(|t| t.include_git_files.is_some()),
488            // file.list
489            ["file", "list", "format"] => config
490                .file
491                .as_ref()
492                .and_then(|f| f.list.as_ref())
493                .is_some_and(|l| l.format.is_some()),
494            ["file", "list", "sort"] => config
495                .file
496                .as_ref()
497                .and_then(|f| f.list.as_ref())
498                .is_some_and(|l| l.sort.is_some()),
499            ["file", "list", "show_dot_files"] => config
500                .file
501                .as_ref()
502                .and_then(|f| f.list.as_ref())
503                .is_some_and(|l| l.show_dot_files.is_some()),
504            ["file", "list", "no_summary"] => config
505                .file
506                .as_ref()
507                .and_then(|f| f.list.as_ref())
508                .is_some_and(|l| l.no_summary.is_some()),
509            ["file", "list", "recursive"] => config
510                .file
511                .as_ref()
512                .and_then(|f| f.list.as_ref())
513                .is_some_and(|l| l.recursive.is_some()),
514            ["file", "list", "include_git_files"] => config
515                .file
516                .as_ref()
517                .and_then(|f| f.list.as_ref())
518                .is_some_and(|l| l.include_git_files.is_some()),
519            // file.carry-in
520            ["file", "carry-in", "force"] => config
521                .file
522                .as_ref()
523                .and_then(|f| f.carry_in.as_ref())
524                .is_some_and(|c| c.force.is_some()),
525            ["file", "carry-in", "no_parallel"] => config
526                .file
527                .as_ref()
528                .and_then(|f| f.carry_in.as_ref())
529                .is_some_and(|c| c.no_parallel.is_some()),
530            // file.recheck
531            ["file", "recheck", "method"] => config
532                .file
533                .as_ref()
534                .and_then(|f| f.recheck.as_ref())
535                .is_some_and(|r| r.method.is_some()),
536            // pipeline
537            ["pipeline", "current_pipeline"] => config
538                .pipeline
539                .as_ref()
540                .is_some_and(|p| p.current_pipeline.is_some()),
541            ["pipeline", "default"] => config
542                .pipeline
543                .as_ref()
544                .is_some_and(|p| p.default.is_some()),
545            ["pipeline", "default_params_file"] => config
546                .pipeline
547                .as_ref()
548                .is_some_and(|p| p.default_params_file.is_some()),
549            ["pipeline", "process_pool_size"] => config
550                .pipeline
551                .as_ref()
552                .is_some_and(|p| p.process_pool_size.is_some()),
553            // check-ignore
554            ["check-ignore", "details"] => config
555                .check_ignore
556                .as_ref()
557                .is_some_and(|c| c.details.is_some()),
558            _ => false,
559        }
560    }
561
562    /// Checks if a given key string (e.g., "core.verbosity") corresponds to a valid, known configuration path.
563    /// This ensures that only recognized keys are processed or reported as having a default source.
564    fn is_valid_key(&self, key: &str) -> bool {
565        let parts: Vec<&str> = key.split('.').collect();
566        matches!(
567            parts.as_slice(),
568            // core
569            ["core", "xvc_repo_version"] |
570            ["core", "verbosity"] |
571            // git
572            ["git", "use_git"] |
573            ["git", "command"] |
574            ["git", "auto_commit"] |
575            ["git", "auto_stage"] |
576            // cache
577            ["cache", "algorithm"] |
578            // file.track
579            ["file", "track", "no_commit"] |
580            ["file", "track", "force"] |
581            ["file", "track", "text_or_binary"] |
582            ["file", "track", "no_parallel"] |
583            ["file", "track", "include_git_files"] |
584            // file.list
585            ["file", "list", "format"] |
586            ["file", "list", "sort"] |
587            ["file", "list", "show_dot_files"] |
588            ["file", "list", "no_summary"] |
589            ["file", "list", "recursive"] |
590            ["file", "list", "include_git_files"] |
591            // file.carry-in
592            ["file", "carry-in", "force"] |
593            ["file", "carry-in", "no_parallel"] |
594            // file.recheck
595            ["file", "recheck", "method"] |
596            // pipeline
597            ["pipeline", "current_pipeline"] |
598            ["pipeline", "default"] |
599            ["pipeline", "default_params_file"] |
600            ["pipeline", "process_pool_size"] |
601            // check-ignore
602            ["check-ignore", "details"]
603        )
604    }
605}
606
607/// Trait to update CLI options with defaults from configuration.
608///
609/// When a CLI struct like [xvc_pipeline::PipelineCLI] implements this trait, it reads the configuration and updates values not set in the command line accordingly.
610pub trait FromConfig {
611    /// Update the implementing struct from the configuration.
612    /// Reading the relevant keys and values of the config is in implementor's responsibility.
613    ///
614    /// This is used to abstract away CLI structs and crate options.
615    fn from_config(conf: &XvcConfiguration) -> Result<Box<Self>>;
616}
617
618/// A trait for updating an existing configuration struct with values from a complete `XvcConfiguration`.
619///
620/// This allows for merging configuration values into an already instantiated struct,
621/// often used for CLI options where some fields might already be set by the user.
622pub trait UpdateFromConfig {
623    /// Updates the implementing struct with values from the provided `XvcConfiguration`.
624    ///
625    /// The implementation is responsible for reading the relevant keys and values
626    /// from `conf` and applying them to `self`.
627    ///
628    /// # Arguments
629    ///
630    /// * `self` - The instance of the struct to be updated.
631    /// * `conf` - A reference to the `XvcConfiguration` containing the values to apply.
632    ///
633    /// # Returns
634    ///
635    /// A `Result` containing a `Box`ed instance of the updated struct if successful, or an error.
636    fn update_from_config(self, conf: &XvcConfiguration) -> Result<Box<Self>>;
637}