Skip to main content

lichenn/
config.rs

1//! # Configuration support
2//!
3//! Manages the loading of options from a TOML config input
4
5use crate::error::LichenError;
6use crate::models::License;
7use crate::models::{Author, Authors};
8use jiff::civil::Date;
9use log::{debug, warn};
10use regex::Regex;
11use serde::Deserialize;
12use std::fmt;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// Complete configuration. Holds fields for a the sum of both Apply and Gen args
17#[derive(Debug, Deserialize, Default)]
18pub struct Config {
19    /// When applying headers, which kind of comment token the user *wants*
20    /// Completely possible line or block doesn't exist, in which case it falls back to the other.
21    #[serde(default)]
22    pub prefer_block: Option<bool>,
23
24    // By default conflicts from multiple licenses will warn and replace instead of merging
25    #[serde(default)]
26    pub multiple: Option<bool>,
27
28    // Global exclude list
29    #[serde(skip_serializing_if = "Option::is_none", with = "serde_regex", default)]
30    pub exclude: Option<Vec<Regex>>,
31
32    // By default conflicts from multiple licenses will error instead of merging
33    #[serde(default)]
34    pub all: Option<bool>,
35
36    /// Per‑license configuration blocks.
37    #[serde(rename = "license", default)]
38    pub licenses: Option<Vec<LicenseConfig>>,
39}
40
41/// Try to load and parse the config file.
42/// Converts I/O or parse errors into your `FileProcessingError`.
43impl Config {
44    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, LichenError> {
45        let s = fs::read_to_string(path.as_ref()).map_err(LichenError::from)?;
46        toml::from_str(&s).map_err(|e| LichenError::Msg(format!("config parse error: {}", e)))
47    }
48
49    /// Like `load`, but if the file was *not found*, you get `Config::default()`.
50    pub fn load_or_default<P: AsRef<Path>>(path: P) -> Result<Self, LichenError> {
51        match Self::load(&path) {
52            Ok(cfg) => {
53                debug!("Running with config");
54                Ok(cfg)
55            }
56            Err(LichenError::IoError(ref io_err))
57                if io_err.kind() == std::io::ErrorKind::NotFound =>
58            {
59                // no file → empty‐config
60                warn!("No config found, falling back on CLI and defaults");
61                Ok(Config::default())
62            }
63            Err(other) => Err(other),
64        }
65    }
66}
67
68/// Per‑license settings.
69#[derive(Debug, Deserialize)]
70pub struct LicenseConfig {
71    /// Regex for matching file paths to apply this license.
72    #[serde(skip_serializing_if = "Option::is_none", with = "serde_regex", default)]
73    pub exclude: Option<Regex>,
74
75    /// File‑path patterns, files or directories..
76    #[serde(default)]
77    pub targets: Option<Vec<PathBuf>>,
78
79    // Provided date
80    #[serde(default)]
81    pub date: Option<Date>,
82
83    /// SPDX identifier.
84    pub id: License,
85
86    /// List of named authors.
87    #[serde(default)]
88    pub authors: Option<Authors>,
89}
90
91impl fmt::Display for Author {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        // Open to this being changed, just what made sense at the time.
94        let wrapper_char_left = "[";
95        let wrapper_char_right = "]";
96
97        // Ex output: John Pork [johnpork@pig.com]
98        if let Some(email) = &self.email {
99            write!(
100                f,
101                "{name} {left}{email}{right}",
102                name = self.name,
103                left = wrapper_char_left,
104                email = email,
105                right = wrapper_char_right
106            )
107        } else {
108            write!(f, "{name}", name = self.name)
109        }
110    }
111}
112
113impl fmt::Display for Authors {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        // Simple and easy, just get the string representation of each author, and join with comma.
116        let joined = self
117            .0
118            .iter()
119            .map(|a| a.to_string())
120            .collect::<Vec<_>>()
121            .join(", ");
122        write!(f, "{}", joined)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn author_display_with_email() {
132        let a = Author {
133            name: "Alice".into(),
134            email: Some("a@e.com".into()),
135        };
136        let s = format!("{}", a);
137        assert_eq!(s, "Alice [a@e.com]");
138    }
139
140    #[test]
141    fn author_display_no_email() {
142        let a = Author {
143            name: "Bob".into(),
144            email: None,
145        };
146        let s = format!("{}", a);
147        assert_eq!(s, "Bob");
148    }
149
150    #[test]
151    fn authors_display_multiple() {
152        let authors = Authors(vec![
153            Author {
154                name: "X".into(),
155                email: None,
156            },
157            Author {
158                name: "Y".into(),
159                email: Some("y@z".into()),
160            },
161        ]);
162        let s = format!("{}", authors);
163        assert_eq!(s, "X, Y [y@z]");
164    }
165}
166
167#[cfg(test)]
168mod tests_load {
169    // Separate module to avoid conflicts with existing tests mod
170    use super::*;
171    use crate::models::License;
172    use std::fs;
173    use tempfile::NamedTempFile; // Import License
174
175    #[test]
176    fn config_load_valid_toml() {
177        let content = r#"
178# Configuration for Lichen, a tool for managing licenses
179# This file allows you to specify global settings and per-license configurations.
180
181# ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ #
182# Global Configuration #
183# ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ #
184
185# prefer_block = true
186# multiple = true
187
188exclude = [
189  "\\.gitignore",
190  ".*lock",
191  "\\.git/.*",
192  "\\.licensure\\.yml",
193  "README.*",
194  "LICENSE.*",
195  ".*\\.(md|rst|txt)",
196  "Cargo.toml",
197  ".*\\.github/.*",
198]
199
200# all = true
201
202# ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ #
203# Per-License Configuration #
204# ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ #
205
206[[license]]
207# exclude = "some/pattern/to/exclude"
208targets = ["."]
209id = "MIT"
210# date = "2023-10-27"
211authors = [
212  { name = "Core Contributor", email = "core@example.com" },
213  { name = "Another Contributor" }, # Email is optional
214]
215
216# [[license]]
217# targets = ["src/cli/"]
218# id = "Apache-2.0"
219# authors = [
220#   { name = "CLI Developer" }
221# ]
222
223# [[license]]
224# targets = ["examples/"]
225# id = "GPL-3.0-or-later"
226# date = "2024-01-01"
227"#;
228        let file = NamedTempFile::new().unwrap();
229        fs::write(file.path(), content).unwrap();
230
231        let config = Config::load(file.path()).unwrap();
232
233        // --- Global Assertions ---
234        assert_eq!(config.prefer_block, None); // Commented out
235        assert_eq!(config.multiple, None); // Commented out
236        assert_eq!(config.all, None); // Commented out
237
238        assert!(config.exclude.is_some());
239        let excludes = config.exclude.as_ref().unwrap();
240        assert_eq!(excludes.len(), 9); // Updated count
241
242        // --- License Assertions ---
243        assert!(config.licenses.is_some());
244        let licenses = config.licenses.unwrap();
245        assert_eq!(licenses.len(), 1); // Only one license block is active
246
247        // Check the first (and only) license
248        let lic1 = &licenses[0];
249        assert_eq!(lic1.id.spdx_id(), "MIT"); // Check the raw string ID from TOML
250
251        assert_eq!(
252            lic1.targets,
253            Some(vec![PathBuf::from(".")]) // Updated target
254        );
255
256        assert!(lic1.authors.is_some());
257        // lic1.authors is Option<Authors>
258        // lic1.authors.as_ref() is Option<&Authors>
259        // lic1.authors.as_ref().unwrap() is &Authors
260        // lic1.authors.as_ref().unwrap().0 is Vec<Author>
261        let authors_vec = &lic1.authors.as_ref().unwrap().0; // Access the inner Vec<&Author> using .0
262        assert_eq!(authors_vec.len(), 2); // Two authors specified
263
264        // Check first author
265        assert_eq!(authors_vec[0].name, "Core Contributor"); // Now index into the Vec
266        assert_eq!(authors_vec[0].email, Some("core@example.com".to_string()));
267
268        // Check second author
269        assert_eq!(authors_vec[1].name, "Another Contributor");
270        assert_eq!(authors_vec[1].email, None); // Email is optional and not provided
271
272        assert_eq!(lic1.date, None); // Commented out in the license block
273    }
274
275    #[test]
276    fn config_load_minimal_toml() {
277        let content = r#"
278# Only specify one license ID
279[[license]]
280id = "Unlicense"
281"#;
282        let file = NamedTempFile::new().unwrap();
283        fs::write(file.path(), content).unwrap();
284        let config = Config::load(file.path()).unwrap();
285
286        assert!(config.prefer_block.is_none()); // Defaults to None
287        assert!(config.multiple.is_none());
288        assert!(config.exclude.is_none());
289        assert!(config.all.is_none());
290
291        assert!(config.licenses.is_some());
292        let licenses = config.licenses.unwrap();
293        assert_eq!(licenses.len(), 1);
294        assert_eq!(licenses[0].id, License::Unlicense);
295        assert!(licenses[0].targets.is_none());
296        assert!(licenses[0].authors.is_none());
297        assert!(licenses[0].exclude.is_none());
298        assert!(licenses[0].date.is_none());
299    }
300
301    #[test]
302    fn config_load_invalid_toml_returns_err() {
303        let content = r#"
304prefer_block = true
305multiple = "not a boolean" # Invalid type
306"#;
307        let file = NamedTempFile::new().unwrap();
308        fs::write(file.path(), content).unwrap();
309
310        let result = Config::load(file.path());
311        assert!(result.is_err());
312        assert!(matches!(result, Err(LichenError::Msg(_))));
313        assert!(
314            result
315                .unwrap_err()
316                .to_string()
317                .contains("config parse error")
318        );
319    }
320
321    #[test]
322    fn config_load_or_default_file_not_found_returns_default() {
323        let non_existent_path = PathBuf::from("this_file_definitely_does_not_exist.toml");
324        let result = Config::load_or_default(&non_existent_path);
325
326        assert!(result.is_ok());
327        let config = result.unwrap();
328        // Check if it's the default config
329        assert!(config.prefer_block.is_none());
330        assert!(config.multiple.is_none());
331        assert!(config.exclude.is_none());
332        assert!(config.all.is_none());
333        assert!(config.licenses.is_none());
334    }
335
336    #[test]
337    fn config_load_or_default_loads_existing_file() {
338        let content = r#"prefer_block = true"#;
339        let file = NamedTempFile::new().unwrap();
340        fs::write(file.path(), content).unwrap();
341
342        let result = Config::load_or_default(file.path());
343        assert!(result.is_ok());
344        let config = result.unwrap();
345        assert_eq!(config.prefer_block, Some(true));
346    }
347
348    #[test]
349    fn config_load_or_default_invalid_toml_returns_err() {
350        let content = r#"invalid toml content"#;
351        let file = NamedTempFile::new().unwrap();
352        fs::write(file.path(), content).unwrap();
353
354        let result = Config::load_or_default(file.path());
355        assert!(result.is_err());
356        assert!(matches!(result, Err(LichenError::Msg(_))));
357    }
358}