Skip to main content

forehead_core/
lib.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::{
39    config::Config,
40    error::ForeheadError,
41    header::{apply_header_to_file, check_header_on_file, remove_header_from_file, FileStatus},
42};
43use std::{fs, path::Path};
44use walkdir::WalkDir;
45
46pub mod comment;
47pub mod config;
48pub mod error;
49pub mod header;
50pub mod template;
51
52const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules"];
53const SKIP_FILES: &[&str] = &["Cargo.lock", "forehead.toml"];
54
55pub struct Forehead {
56    config: Config,
57    root: std::path::PathBuf,
58}
59
60impl Forehead {
61    pub fn new(config: Config) -> Self {
62        let root = config.project_dir.clone();
63        Forehead { config, root }
64    }
65
66    pub fn from_config_path(path: &Path) -> Result<Self, ForeheadError> {
67        let config = Config::from_path(path)?;
68        Ok(Forehead::new(config))
69    }
70
71    fn walk_entries(&self) -> impl Iterator<Item = walkdir::DirEntry> {
72        WalkDir::new(&self.root)
73            .into_iter()
74            .filter_entry(|e| {
75                let fname = e.file_name().to_str().unwrap_or("");
76                !SKIP_DIRS.contains(&fname)
77            })
78            .filter_map(|e| e.ok())
79            .filter(|e| e.path().is_file())
80            .filter(|e| {
81                let fname = e.file_name().to_str().unwrap_or("");
82                !SKIP_FILES.contains(&fname)
83            })
84    }
85
86    pub fn apply(&self, dry_run: bool) -> Result<ApplyReport, ForeheadError> {
87        let mut report = ApplyReport::new();
88        let indicators = self.config.header.all_indicators();
89        let greetings = &self.config.header.greetings;
90
91        for entry in self.walk_entries() {
92            let path = entry.path().to_path_buf();
93            let fname = entry.file_name().to_str().unwrap_or("").to_string();
94            let rel = path.strip_prefix(&self.root).unwrap_or(&path).to_path_buf();
95
96            if fname == "Cargo.toml" {
97                self.apply_cargo_toml_license(&path, &rel, dry_run, &mut report);
98                continue;
99            }
100
101            let comment_style = match comment::comment_style_for(&path) {
102                Some(s) => s,
103                None => continue,
104            };
105
106            let template = match self.config.template_for(&path, &self.root) {
107                Some(t) => t,
108                None => continue,
109            };
110
111            let subst = self.config.substitution_for(&path, &self.root);
112
113            match apply_header_to_file(
114                &path,
115                &template,
116                &comment_style,
117                &subst,
118                dry_run,
119                &indicators,
120                greetings,
121            ) {
122                Ok(true) => report.applied.push(rel),
123                Ok(false) => {}
124                Err(e) => report.errors.push((rel, e.to_string())),
125            }
126        }
127
128        Ok(report)
129    }
130
131    pub fn check(&self) -> Result<CheckReport, ForeheadError> {
132        let mut report = CheckReport::new();
133        let indicators = self.config.header.all_indicators();
134        let greetings = &self.config.header.greetings;
135
136        for entry in self.walk_entries() {
137            let path = entry.path().to_path_buf();
138            let fname = entry.file_name().to_str().unwrap_or("").to_string();
139            let rel = path.strip_prefix(&self.root).unwrap_or(&path).to_path_buf();
140
141            if fname == "Cargo.toml" {
142                if !self.check_cargo_toml_license(&path, &rel) {
143                    report.missing.push(rel);
144                }
145                continue;
146            }
147
148            let comment_style = match comment::comment_style_for(&path) {
149                Some(s) => s,
150                None => continue,
151            };
152
153            let template = match self.config.template_for(&path, &self.root) {
154                Some(t) => t,
155                None => continue,
156            };
157
158            let subst = self.config.substitution_for(&path, &self.root);
159
160            match check_header_on_file(
161                &path,
162                &template,
163                &comment_style,
164                &subst,
165                &indicators,
166                greetings,
167            ) {
168                Ok(FileStatus::Correct) => {}
169                Ok(FileStatus::Missing | FileStatus::Wrong) => {
170                    report.missing.push(rel);
171                }
172                Err(e) => report.errors.push((rel, e.to_string())),
173            }
174        }
175
176        Ok(report)
177    }
178
179    pub fn list(&self) -> Result<Vec<(std::path::PathBuf, FileStatus)>, ForeheadError> {
180        let mut results = Vec::new();
181        let indicators = self.config.header.all_indicators();
182        let greetings = &self.config.header.greetings;
183
184        for entry in self.walk_entries() {
185            let path = entry.path().to_path_buf();
186            let fname = entry.file_name().to_str().unwrap_or("").to_string();
187            let rel = path.strip_prefix(&self.root).unwrap_or(&path).to_path_buf();
188
189            if fname == "Cargo.toml" {
190                let status = if self.check_cargo_toml_license(&path, &rel) {
191                    FileStatus::Correct
192                } else {
193                    FileStatus::Missing
194                };
195                results.push((rel, status));
196                continue;
197            }
198
199            let comment_style = match comment::comment_style_for(&path) {
200                Some(s) => s,
201                None => continue,
202            };
203
204            let template = match self.config.template_for(&path, &self.root) {
205                Some(t) => t,
206                None => continue,
207            };
208
209            let subst = self.config.substitution_for(&path, &self.root);
210
211            match check_header_on_file(
212                &path,
213                &template,
214                &comment_style,
215                &subst,
216                &indicators,
217                greetings,
218            ) {
219                Ok(status) => results.push((rel, status)),
220                Err(_) => results.push((rel, FileStatus::Wrong)),
221            }
222        }
223
224        Ok(results)
225    }
226
227    pub fn config(&self) -> &Config {
228        &self.config
229    }
230
231    pub fn remove(&self, dry_run: bool) -> Result<ApplyReport, ForeheadError> {
232        let mut report = ApplyReport::new();
233        let indicators = self.config.header.all_indicators();
234
235        for entry in self.walk_entries() {
236            let path = entry.path().to_path_buf();
237            let fname = entry.file_name().to_str().unwrap_or("").to_string();
238            let rel = path.strip_prefix(&self.root).unwrap_or(&path).to_path_buf();
239
240            if fname == "Cargo.toml" || fname == "Cargo.lock" {
241                continue;
242            }
243
244            let comment_style = match comment::comment_style_for(&path) {
245                Some(s) => s,
246                None => continue,
247            };
248
249            match remove_header_from_file(&path, &comment_style, &indicators, dry_run) {
250                Ok(true) => report.applied.push(rel),
251                Ok(false) => {}
252                Err(e) => report.errors.push((rel, e.to_string())),
253            }
254        }
255
256        Ok(report)
257    }
258
259    fn apply_cargo_toml_license(
260        &self,
261        path: &Path,
262        rel: &Path,
263        _dry_run: bool,
264        report: &mut ApplyReport,
265    ) {
266        let license_str = self.config.license_for(path, &self.root);
267
268        let content = match fs::read_to_string(path) {
269            Ok(c) => c,
270            Err(e) => {
271                report.errors.push((rel.to_path_buf(), e.to_string()));
272                return;
273            }
274        };
275
276        let new_content = update_cargo_toml_license(&content, &license_str);
277
278        if new_content != content {
279            if let Err(e) = fs::write(path, &new_content) {
280                report.errors.push((rel.to_path_buf(), e.to_string()));
281            } else {
282                report.applied.push(rel.to_path_buf());
283            }
284        }
285    }
286
287    fn check_cargo_toml_license(&self, path: &Path, _rel: &Path) -> bool {
288        let license_str = self.config.license_for(path, &self.root);
289
290        let content = match fs::read_to_string(path) {
291            Ok(c) => c,
292            Err(_) => return false,
293        };
294
295        let new_content = update_cargo_toml_license(&content, &license_str);
296        new_content == content
297    }
298}
299
300fn update_cargo_toml_license(content: &str, license_str: &str) -> String {
301    let mut new_lines: Vec<String> = Vec::new();
302    let mut in_package = false;
303    let mut found_license = false;
304
305    for line in content.lines() {
306        let trimmed = line.trim();
307        if trimmed == "[package]" {
308            in_package = true;
309            new_lines.push(line.to_string());
310        } else if in_package
311            && !found_license
312            && (trimmed.starts_with("license") || trimmed.starts_with("license.workspace"))
313        {
314            new_lines.push(format!("license = \"{license_str}\""));
315            found_license = true;
316            in_package = false;
317        } else if in_package && !found_license && trimmed.starts_with('[') {
318            new_lines.push(format!("license = \"{license_str}\""));
319            new_lines.push(String::new());
320            new_lines.push(line.to_string());
321            found_license = true;
322            in_package = false;
323        } else {
324            new_lines.push(line.to_string());
325        }
326    }
327
328    new_lines.join("\n")
329}
330
331#[derive(Debug, Default)]
332pub struct ApplyReport {
333    pub applied: Vec<std::path::PathBuf>,
334    pub errors: Vec<(std::path::PathBuf, String)>,
335}
336
337impl ApplyReport {
338    pub fn new() -> Self {
339        ApplyReport::default()
340    }
341    pub fn is_empty(&self) -> bool {
342        self.applied.is_empty() && self.errors.is_empty()
343    }
344}
345
346#[derive(Debug, Default)]
347pub struct CheckReport {
348    pub missing: Vec<std::path::PathBuf>,
349    pub errors: Vec<(std::path::PathBuf, String)>,
350}
351
352impl CheckReport {
353    pub fn new() -> Self {
354        CheckReport::default()
355    }
356    pub fn is_clean(&self) -> bool {
357        self.missing.is_empty() && self.errors.is_empty()
358    }
359}