1use 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#[derive(Debug, Deserialize, Default)]
18pub struct Config {
19 #[serde(default)]
22 pub prefer_block: Option<bool>,
23
24 #[serde(default)]
26 pub multiple: Option<bool>,
27
28 #[serde(skip_serializing_if = "Option::is_none", with = "serde_regex", default)]
30 pub exclude: Option<Vec<Regex>>,
31
32 #[serde(default)]
34 pub all: Option<bool>,
35
36 #[serde(rename = "license", default)]
38 pub licenses: Option<Vec<LicenseConfig>>,
39}
40
41impl 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 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 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#[derive(Debug, Deserialize)]
70pub struct LicenseConfig {
71 #[serde(skip_serializing_if = "Option::is_none", with = "serde_regex", default)]
73 pub exclude: Option<Regex>,
74
75 #[serde(default)]
77 pub targets: Option<Vec<PathBuf>>,
78
79 #[serde(default)]
81 pub date: Option<Date>,
82
83 pub id: License,
85
86 #[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 let wrapper_char_left = "[";
95 let wrapper_char_right = "]";
96
97 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 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 use super::*;
171 use crate::models::License;
172 use std::fs;
173 use tempfile::NamedTempFile; #[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 assert_eq!(config.prefer_block, None); assert_eq!(config.multiple, None); assert_eq!(config.all, None); assert!(config.exclude.is_some());
239 let excludes = config.exclude.as_ref().unwrap();
240 assert_eq!(excludes.len(), 9); assert!(config.licenses.is_some());
244 let licenses = config.licenses.unwrap();
245 assert_eq!(licenses.len(), 1); let lic1 = &licenses[0];
249 assert_eq!(lic1.id.spdx_id(), "MIT"); assert_eq!(
252 lic1.targets,
253 Some(vec![PathBuf::from(".")]) );
255
256 assert!(lic1.authors.is_some());
257 let authors_vec = &lic1.authors.as_ref().unwrap().0; assert_eq!(authors_vec.len(), 2); assert_eq!(authors_vec[0].name, "Core Contributor"); assert_eq!(authors_vec[0].email, Some("core@example.com".to_string()));
267
268 assert_eq!(authors_vec[1].name, "Another Contributor");
270 assert_eq!(authors_vec[1].email, None); assert_eq!(lic1.date, None); }
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()); 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 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}