1use crate::{error::ForeheadError, template::HeaderTemplate};
39use serde::Deserialize;
40use std::{
41 collections::HashMap,
42 fs,
43 path::{Path, PathBuf},
44};
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct Config {
48 #[serde(skip)]
49 pub project_dir: PathBuf,
50
51 pub project: ProjectConfig,
52
53 pub templates: HashMap<String, String>,
54
55 #[serde(default)]
56 pub mapping: Vec<Mapping>,
57
58 #[serde(default)]
59 pub header: HeaderConfig,
60
61 #[serde(default)]
62 pub file_types: HashMap<String, FileTypeConfig>,
63
64 #[serde(default)]
65 pub skip: Vec<String>,
66
67 #[serde(default)]
68 pub include: Vec<String>,
69}
70
71#[derive(Debug, Clone, Default, Deserialize)]
72pub struct HeaderConfig {
73 #[serde(default)]
74 pub indicators: Vec<String>,
75 #[serde(default)]
76 pub greetings: String,
77}
78
79const BUILTIN_INDICATORS: &[&str] = &["Copyright", "SPDX", "License"];
80
81impl HeaderConfig {
82 pub fn all_indicators(&self) -> Vec<String> {
83 if self.indicators.len() == 1 && self.indicators[0].eq_ignore_ascii_case("none") {
84 return vec![];
85 }
86 let mut all: Vec<String> = BUILTIN_INDICATORS.iter().map(|s| s.to_string()).collect();
87 all.extend(self.indicators.iter().cloned());
88 all
89 }
90}
91
92#[derive(Debug, Clone, Deserialize)]
93pub struct ProjectConfig {
94 pub name: String,
95 pub default_license: String,
96 #[serde(default = "default_author")]
97 pub default_author: String,
98 #[serde(default = "default_year")]
99 pub default_year: u32,
100 #[serde(default)]
101 pub repository: String,
102 #[serde(default)]
103 pub description: String,
104 #[serde(default)]
105 pub year_span: String,
106}
107
108fn default_author() -> String {
109 "Afsall Inc".to_string()
110}
111
112fn default_year() -> u32 {
113 2026
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct Mapping {
118 pub paths: Vec<String>,
119 pub template: String,
120 #[serde(default)]
121 pub author: Option<String>,
122 #[serde(default)]
123 pub license: Option<String>,
124 #[serde(default)]
125 pub year: Option<u32>,
126 #[serde(default)]
127 pub year_span: Option<String>,
128 #[serde(default)]
129 pub repository: Option<String>,
130 #[serde(default)]
131 pub description: Option<String>,
132 #[serde(default)]
133 pub project: Option<String>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
137pub struct FileTypeConfig {
138 pub line: Option<String>,
139 pub block: Option<Vec<String>>,
140}
141
142#[derive(Debug, Clone)]
143pub struct Substitution {
144 pub project: String,
145 pub author: String,
146 pub year: String,
147 pub year_span: String,
148 pub license: String,
149 pub repository: String,
150 pub description: String,
151 pub file: String,
152}
153
154impl Config {
155 pub fn from_path(path: &Path) -> Result<Self, ForeheadError> {
156 let contents = fs::read_to_string(path).map_err(|e| {
157 ForeheadError::Config(format!("failed to read {}: {e}", path.display()))
158 })?;
159 let mut config: Config = toml::from_str(&contents).map_err(|e| {
160 ForeheadError::Config(format!("failed to parse {}: {e}", path.display()))
161 })?;
162 config.project_dir = path
163 .parent()
164 .filter(|p| !p.as_os_str().is_empty())
165 .unwrap_or_else(|| {
166 std::path::Path::new(".")
168 })
169 .to_path_buf();
170
171 if let Ok(cwd) = std::env::current_dir() {
173 if config.project_dir.is_relative() {
174 config.project_dir = cwd.join(&config.project_dir);
175 }
176 }
177
178 let base = config.project_dir.clone();
180 for (_, template_path) in config.templates.iter_mut() {
181 let p = Path::new(&template_path);
182 if p.is_relative() {
183 *template_path = base.join(p).to_string_lossy().to_string();
184 }
185 }
186
187 Ok(config)
188 }
189
190 pub fn template_for(&self, file_path: &Path, root: &Path) -> Option<HeaderTemplate> {
191 let rel = file_path.strip_prefix(root).unwrap_or(file_path);
192 let rel_str = rel.to_string_lossy();
193
194 let template_key = self
195 .mapping
196 .iter()
197 .find(|m| {
198 m.paths
199 .iter()
200 .any(|p| p.as_str() == "." || p.is_empty() || rel_str.starts_with(p.as_str()))
201 })
202 .map(|m| &m.template);
203
204 let template_key = template_key.unwrap_or_else(|| {
205 self.templates
206 .keys()
207 .next()
208 .expect("at least one template required")
209 });
210
211 let tpath = self.templates.get(template_key)?;
212 HeaderTemplate::from_file(Path::new(tpath)).ok()
213 }
214
215 pub fn substitution_for(&self, file_path: &Path, root: &Path) -> Substitution {
216 let rel = file_path.strip_prefix(root).unwrap_or(file_path);
217 let rel_str = rel.to_string_lossy();
218 let fname = file_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
219
220 let mapping = self.mapping.iter().find(|m| {
221 m.paths
222 .iter()
223 .any(|p| p.as_str() == "." || p.is_empty() || rel_str.starts_with(p.as_str()))
224 });
225
226 let project = mapping
227 .and_then(|m| m.project.clone())
228 .unwrap_or_else(|| self.project.name.clone());
229
230 let author = mapping
231 .and_then(|m| m.author.clone())
232 .unwrap_or_else(|| self.project.default_author.clone());
233
234 let year = mapping
235 .and_then(|m| m.year)
236 .unwrap_or(self.project.default_year)
237 .to_string();
238
239 let year_span = mapping
240 .and_then(|m| m.year_span.clone())
241 .unwrap_or_else(|| {
242 if self.project.year_span.is_empty() {
243 format!("{}-Present", self.project.default_year)
244 } else {
245 self.project.year_span.clone()
246 }
247 });
248
249 let license = mapping
250 .and_then(|m| m.license.clone())
251 .unwrap_or_else(|| self.project.default_license.clone());
252
253 let repository = mapping
254 .and_then(|m| m.repository.clone())
255 .unwrap_or_else(|| self.project.repository.clone());
256
257 let description = mapping
258 .and_then(|m| m.description.clone())
259 .unwrap_or_else(|| self.project.description.clone());
260
261 Substitution {
262 project,
263 author,
264 year,
265 year_span,
266 license,
267 repository,
268 description,
269 file: fname.to_string(),
270 }
271 }
272
273 pub fn license_for(&self, file_path: &Path, root: &Path) -> String {
274 let rel = file_path.strip_prefix(root).unwrap_or(file_path);
275 let rel_str = rel.to_string_lossy();
276
277 let mapping = self.mapping.iter().find(|m| {
278 m.paths
279 .iter()
280 .any(|p| p.as_str() == "." || p.is_empty() || rel_str.starts_with(p.as_str()))
281 });
282
283 mapping
284 .and_then(|m| m.license.clone())
285 .unwrap_or_else(|| self.project.default_license.clone())
286 }
287}