Skip to main content

forehead_core/
config.rs

1// بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيم
2// This file is part of forehead.
3//
4// Copyright (C) 2026-Present Afsall Inc.
5// SPDX-License-Identifier: Apache-2.0 OR MIT
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License at
10//
11// http://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19// Alternatively, this file is available under the MIT License:
20// Permission is hereby granted, free of charge, to any person obtaining a copy
21// of this software and associated documentation files (the "Software"), to deal
22// in the Software without restriction, including without limitation the rights
23// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24// copies of the Software, and to permit persons to whom the Software is
25// furnished to do so, subject to the following conditions:
26//
27// The above copyright notice and this permission notice shall be included in all
28// copies or substantial portions of the Software.
29//
30// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36// SOFTWARE.
37
38use 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                // If parent is empty (config in current dir), use "."
167                std::path::Path::new(".")
168            })
169            .to_path_buf();
170
171        // Normalize to absolute if possible
172        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        // Resolve template paths relative to config file
179        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}