composition_cli/context/config/
mod.rs

1mod default;
2
3use dirs;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::{collections::HashSet, path::Path};
7
8#[derive(Debug, Serialize, Deserialize)]
9pub struct Config {
10    #[serde(default = "default::use_color")]
11    pub use_color: bool,
12
13    #[serde(default = "default::respect_gitignore")]
14    pub respect_gitignore: bool,
15
16    #[serde(default = "default::ignore_dotfolders")]
17    pub ignore_dotfolders: bool,
18
19    #[serde(default = "default::ignored_directories")]
20    pub ignored_directories: Vec<String>,
21
22    #[serde(default = "default::ignore_dotfiles")]
23    pub ignore_dotfiles: bool,
24
25    #[serde(default = "default::ignored_files")]
26    pub ignored_files: Vec<String>,
27
28    #[serde(default = "default::ignore_empty_lines")]
29    pub ignore_empty_lines: bool,
30
31    #[serde(default = "default::excluded_patterns")]
32    pub excluded_patterns: Vec<String>,
33
34    #[serde(default = "default::tracked")]
35    pub tracked: Vec<Tracked>,
36
37    #[serde(skip)]
38    pub compiled_excluded_patterns: Vec<Regex>,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct Tracked {
43    pub display: String,
44    pub extensions: Vec<String>,
45    pub color: Option<String>,
46
47    #[serde(default)]
48    pub excluded_patterns: Vec<String>,
49
50    #[serde(skip)]
51    pub compiled_excluded_patterns: Vec<Regex>,
52}
53
54impl Config {
55    pub fn from_config() -> (Self, bool) {
56        let config_path = dirs::config_dir()
57            .unwrap_or_else(|| std::path::PathBuf::from("."))
58            .join("composition")
59            .join("config.toml");
60
61        match Self::load_from_path(&config_path) {
62            Ok(config) => (config, true),
63            Err(_) => (Self::default(), false),
64        }
65    }
66
67    fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigLoadError> {
68        let path = path.as_ref();
69        let content = std::fs::read_to_string(path).map_err(|_| ConfigLoadError::FileReadFailed)?;
70
71        let mut config: Config =
72            toml::from_str(&content).map_err(|_| ConfigLoadError::TomlParseFailed)?;
73
74        // compile all exclude regex patterns
75        config.compiled_excluded_patterns = compile_regexes(&config.excluded_patterns)?;
76
77        let mut seen_displays = HashSet::new();
78        let color_regex = Regex::new(r"^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
79            .expect("hardcoded regex should compile");
80
81        for tracked in &mut config.tracked {
82            // ensure all display values are unique
83            if !seen_displays.insert(tracked.display.clone()) {
84                return Err(ConfigLoadError::DuplicateTrackedDisplay(
85                    tracked.display.clone(),
86                ));
87            }
88
89            if let Some(color) = &tracked.color {
90                if !color_regex.is_match(color) {
91                    return Err(ConfigLoadError::InvalidColorValue(color.clone()));
92                }
93            }
94
95            // compile all language specific regex patterns
96            tracked.compiled_excluded_patterns = compile_regexes(&tracked.excluded_patterns)?
97        }
98
99        Ok(config)
100    }
101}
102
103impl Default for Config {
104    fn default() -> Self {
105        let mut config = Self {
106            use_color: default::use_color(),
107            respect_gitignore: default::respect_gitignore(),
108            ignore_dotfolders: default::ignore_dotfolders(),
109            ignored_directories: default::ignored_directories(),
110            ignore_dotfiles: default::ignore_dotfiles(),
111            ignored_files: default::ignored_files(),
112            ignore_empty_lines: default::ignore_empty_lines(),
113            excluded_patterns: default::excluded_patterns(),
114            tracked: default::tracked(),
115            compiled_excluded_patterns: Vec::new(),
116        };
117
118        // compile global regex patterns (ignore errors here, assume defaults are valid)
119        config.compiled_excluded_patterns =
120            compile_regexes(&config.excluded_patterns).unwrap_or_default();
121
122        // compile tracked regex patterns individually
123        for tracked in &mut config.tracked {
124            tracked.compiled_excluded_patterns =
125                compile_regexes(&tracked.excluded_patterns).unwrap_or_default();
126        }
127
128        config
129    }
130}
131
132fn compile_regexes(excluded_patterns: &Vec<String>) -> Result<Vec<Regex>, ConfigLoadError> {
133    let mut compiled_patterns = Vec::with_capacity(excluded_patterns.len());
134    for pat in excluded_patterns {
135        let regex =
136            Regex::new(pat).map_err(|_| ConfigLoadError::RegexCompileFailed(pat.clone()))?;
137        compiled_patterns.push(regex);
138    }
139
140    Ok(compiled_patterns)
141}
142
143#[derive(Debug)]
144pub enum ConfigLoadError {
145    FileReadFailed,
146    TomlParseFailed,
147    InvalidColorValue(String),
148    RegexCompileFailed(String),
149    DuplicateTrackedDisplay(String),
150}
151
152impl std::error::Error for ConfigLoadError {}
153
154impl std::fmt::Display for ConfigLoadError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            ConfigLoadError::FileReadFailed => write!(f, "failed to read the config file"),
158            ConfigLoadError::TomlParseFailed => {
159                write!(f, "failed to parse the config file as TOML")
160            }
161            ConfigLoadError::InvalidColorValue(color) => {
162                write!(f, "invalid color value: '{}'", color)
163            }
164            ConfigLoadError::RegexCompileFailed(pattern) => {
165                write!(f, "failed to compile regex pattern: '{}'", pattern)
166            }
167            ConfigLoadError::DuplicateTrackedDisplay(display) => {
168                write!(f, "duplicate tracked display found: '{}'", display)
169            }
170        }
171    }
172}