Skip to main content

forehead_core/
header.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    comment::{format_as_comment, CommentStyle},
40    config::Substitution,
41    error::ForeheadError,
42    template::HeaderTemplate,
43};
44use std::{fs, path::Path};
45
46#[derive(Debug, Clone, PartialEq)]
47pub enum FileStatus {
48    Correct,
49    Missing,
50    Wrong,
51}
52
53/// Check if a file has the correct header
54pub fn check_header_on_file(
55    path: &Path,
56    template: &HeaderTemplate,
57    style: &CommentStyle,
58    subst: &Substitution,
59    indicators: &[String],
60    greetings: &str,
61) -> Result<FileStatus, ForeheadError> {
62    let content = fs::read_to_string(path)?;
63    let expected = build_expected_header(template, style, subst, greetings);
64
65    let header_block = extract_header_block(&content, style, indicators);
66
67    match header_block {
68        Some(existing) => {
69            if normalize_header(&existing) == normalize_header(&expected) {
70                Ok(FileStatus::Correct)
71            } else {
72                Ok(FileStatus::Wrong)
73            }
74        }
75        None => Ok(FileStatus::Missing),
76    }
77}
78
79/// Apply a header to a file. Returns true if the file was modified.
80pub fn apply_header_to_file(
81    path: &Path,
82    template: &HeaderTemplate,
83    style: &CommentStyle,
84    subst: &Substitution,
85    dry_run: bool,
86    indicators: &[String],
87    greetings: &str,
88) -> Result<bool, ForeheadError> {
89    let content = fs::read_to_string(path)?;
90    let expected = build_expected_header(template, style, subst, greetings);
91
92    let header_block = extract_header_block(&content, style, indicators);
93
94    let new_content = match header_block {
95        Some(existing) => {
96            if normalize_header(&existing) == normalize_header(&expected) {
97                return Ok(false);
98            }
99            replace_header(&content, &existing, &expected, style)
100        }
101        None => prepend_header(&content, &expected, style),
102    };
103
104    if new_content == content {
105        return Ok(false);
106    }
107
108    if !dry_run {
109        fs::write(path, &new_content)?;
110    }
111
112    Ok(true)
113}
114
115/// Remove the header from a file. Returns true if the file was modified.
116pub fn remove_header_from_file(
117    path: &Path,
118    style: &CommentStyle,
119    indicators: &[String],
120    dry_run: bool,
121) -> Result<bool, ForeheadError> {
122    let content = fs::read_to_string(path)?;
123
124    let header_block = extract_header_block(&content, style, indicators);
125
126    let new_content = match header_block {
127        Some(existing) => {
128            let after = content[existing.len()..].trim_start();
129            after.to_string()
130        }
131        None => return Ok(false),
132    };
133
134    if new_content == content {
135        return Ok(false);
136    }
137
138    if !dry_run {
139        fs::write(path, &new_content)?;
140    }
141
142    Ok(true)
143}
144
145/// Replace headers in a file using a template. Returns true if the file was modified.
146pub fn replace_header_on_file(
147    path: &Path,
148    template: &HeaderTemplate,
149    style: &CommentStyle,
150    subst: &Substitution,
151    indicators: &[String],
152    greetings: &str,
153    dry_run: bool,
154) -> Result<bool, ForeheadError> {
155    let content = fs::read_to_string(path)?;
156    let expected = build_expected_header(template, style, subst, greetings);
157
158    let header_block = extract_header_block(&content, style, indicators);
159
160    let new_content = match header_block {
161        Some(existing) => {
162            if normalize_header(&existing) == normalize_header(&expected) {
163                return Ok(false);
164            }
165            let after = content[existing.len()..].trim_start();
166            format!("{}\n\n{}", expected, after)
167        }
168        None => {
169            let trimmed = content.trim_start();
170            format!("{}\n\n{}", expected, trimmed)
171        }
172    };
173
174    if new_content == content {
175        return Ok(false);
176    }
177
178    if !dry_run {
179        fs::write(path, &new_content)?;
180    }
181
182    Ok(true)
183}
184
185fn build_expected_header(
186    template: &HeaderTemplate,
187    style: &CommentStyle,
188    subst: &Substitution,
189    greetings: &str,
190) -> String {
191    let mut all_lines = template.lines.clone();
192    if !greetings.is_empty() {
193        let substituted = substitute_header_text(greetings, subst);
194        all_lines.insert(0, substituted);
195    }
196    let formatted = format_as_comment(&all_lines, style);
197    substitute_header(&formatted, subst)
198}
199
200/// Extract the header comment block from file content
201fn extract_header_block(
202    content: &str,
203    style: &CommentStyle,
204    indicators: &[String],
205) -> Option<String> {
206    let lines: Vec<&str> = content.lines().collect();
207    if lines.is_empty() {
208        return None;
209    }
210
211    match style {
212        CommentStyle::Line(prefix) => {
213            let mut header_lines = Vec::new();
214            let mut has_indicator = indicators.is_empty(); // if no indicators, any block qualifies
215
216            for line in &lines {
217                let trimmed = line.trim();
218                if trimmed.starts_with(prefix) {
219                    header_lines.push(*line);
220                    if !has_indicator {
221                        has_indicator = is_header_line(trimmed, indicators);
222                    }
223                } else if trimmed.is_empty() {
224                    if !header_lines.is_empty() {
225                        header_lines.push(*line);
226                    } else {
227                        break;
228                    }
229                } else {
230                    break;
231                }
232            }
233
234            if header_lines.is_empty() || !has_indicator {
235                None
236            } else {
237                Some(header_lines.join("\n"))
238            }
239        }
240        CommentStyle::Block(open, close) => {
241            let mut in_block = false;
242            let mut block_lines = Vec::new();
243            let mut found = false;
244            let mut has_indicator = indicators.is_empty();
245
246            for line in &lines {
247                let trimmed = line.trim();
248                if trimmed.starts_with(open) && !in_block {
249                    let rest = trimmed.strip_prefix(open).unwrap_or("").trim();
250                    if !has_indicator {
251                        has_indicator = is_header_line(rest, indicators);
252                    }
253                    if has_indicator || rest.is_empty() {
254                        in_block = true;
255                        block_lines.push(*line);
256                    } else {
257                        break;
258                    }
259                } else if in_block {
260                    block_lines.push(*line);
261                    if !has_indicator {
262                        has_indicator = is_header_line(trimmed, indicators);
263                    }
264                    if trimmed.ends_with(close) {
265                        found = true;
266                        break;
267                    }
268                } else {
269                    break;
270                }
271            }
272
273            if found && has_indicator {
274                Some(block_lines.join("\n"))
275            } else {
276                None
277            }
278        }
279        CommentStyle::BlockTriple(_delim) => {
280            let mut in_block = false;
281            let mut block_lines = Vec::new();
282            let mut found = false;
283            let mut has_indicator = indicators.is_empty();
284
285            for line in &lines {
286                let trimmed = line.trim();
287                if (trimmed == "\"\"\"" || trimmed == "'''") && !in_block {
288                    in_block = true;
289                    block_lines.push(*line);
290                } else if in_block {
291                    block_lines.push(*line);
292                    if !has_indicator {
293                        has_indicator = is_header_line(trimmed, indicators);
294                    }
295                    if trimmed == "\"\"\"" || trimmed == "'''" {
296                        found = true;
297                        break;
298                    }
299                } else {
300                    break;
301                }
302            }
303
304            if found && has_indicator {
305                Some(block_lines.join("\n"))
306            } else {
307                None
308            }
309        }
310    }
311}
312
313fn is_header_line(line: &str, indicators: &[String]) -> bool {
314    if indicators.is_empty() {
315        return true;
316    }
317    let lower = line.to_lowercase();
318    indicators.iter().any(|i| lower.contains(&i.to_lowercase()))
319}
320
321fn normalize_header(header: &str) -> String {
322    header
323        .lines()
324        .map(|l| l.trim().to_lowercase())
325        .filter(|l| !l.is_empty())
326        .collect::<Vec<_>>()
327        .join("\n")
328}
329
330fn substitute_header(header: &str, subst: &Substitution) -> String {
331    substitute_header_text(header, subst)
332}
333
334fn substitute_header_text(text: &str, subst: &Substitution) -> String {
335    text.replace("{project}", &subst.project)
336        .replace("{author}", &subst.author)
337        .replace("{year}", &subst.year)
338        .replace("{year_span}", &subst.year_span)
339        .replace("{license}", &subst.license)
340        .replace("{repository}", &subst.repository)
341        .replace("{description}", &subst.description)
342        .replace("{file}", &subst.file)
343}
344
345fn replace_header(
346    content: &str,
347    old_header: &str,
348    new_header: &str,
349    _style: &CommentStyle,
350) -> String {
351    if let Some(pos) = content.find(old_header) {
352        let _before = &content[..pos];
353        let after = &content[pos + old_header.len()..];
354        let after = after.trim_start();
355        format!("{}\n\n{}", new_header, after)
356    } else {
357        prepend_header(content, new_header, _style)
358    }
359}
360
361fn prepend_header(content: &str, header: &str, _style: &CommentStyle) -> String {
362    let trimmed = content.trim_start();
363    format!("{}\n\n{}", header, trimmed)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::{comment::CommentStyle, config::Substitution, template::HeaderTemplate};
370
371    fn test_subst() -> Substitution {
372        Substitution {
373            project: "TestProject".into(),
374            author: "Test Author".into(),
375            year: "2026".into(),
376            year_span: "2026-Present".into(),
377            license: "Apache-2.0 OR MIT".into(),
378            repository: "https://github.com/test/test".into(),
379            description: "Test description".into(),
380            file: "test.rs".into(),
381        }
382    }
383
384    #[test]
385    fn test_prepend_header_to_rust_file() {
386        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
387        let style = CommentStyle::Line("//".into());
388        let subst = test_subst();
389
390        let content = "pub fn hello() {}\n";
391        let expected = "// This file is part of TestProject.\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
392
393        let result = apply_header_to_file_str(content, &template, &style, &subst);
394        assert_eq!(result, expected);
395    }
396
397    #[test]
398    fn test_replace_existing_header() {
399        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
400        let style = CommentStyle::Line("//".into());
401        let subst = test_subst();
402
403        let content =
404            "// Old copyright notice.\n// SPDX-License-Identifier: MIT\n\npub fn hello() {}\n";
405        let expected = "// This file is part of TestProject.\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
406
407        let result = apply_header_to_file_str(content, &template, &style, &subst);
408        assert_eq!(result, expected);
409    }
410
411    #[test]
412    fn test_header_already_correct() {
413        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
414        let style = CommentStyle::Line("//".into());
415        let subst = test_subst();
416
417        let content = "// This file is part of TestProject.\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
418
419        let result = apply_header_to_file_str(content, &template, &style, &subst);
420        // Should be unchanged (returns same content)
421        assert_eq!(result, content);
422    }
423
424    #[test]
425    fn test_python_hash_comment() {
426        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
427        let style = CommentStyle::Line("#".into());
428        let subst = test_subst();
429
430        let content = "def hello():\n    pass\n";
431        let expected = "# This file is part of TestProject.\n# Copyright (C) 2026-Present Test Author.\n# SPDX-License-Identifier: Apache-2.0 OR MIT.\n\ndef hello():\n    pass\n";
432
433        let result = apply_header_to_file_str(content, &template, &style, &subst);
434        assert_eq!(result, expected);
435    }
436
437    #[test]
438    fn test_html_block_comment() {
439        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
440        let style = CommentStyle::Block("<!--".into(), "-->".into());
441        let subst = test_subst();
442
443        let content = "<html><body>Hello</body></html>\n";
444        let result = apply_header_to_file_str(content, &template, &style, &subst);
445
446        assert!(result.contains("<!--"));
447        assert!(result.contains("-->"));
448        assert!(result.contains("This file is part of TestProject."));
449        assert!(result.contains("<html><body>Hello</body></html>"));
450    }
451
452    #[test]
453    fn test_check_correct_header() {
454        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
455        let style = CommentStyle::Line("//".into());
456        let subst = test_subst();
457
458        let content = "// This file is part of TestProject.\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
459
460        let status = check_header_on_file_str(content, &template, &style, &subst);
461        assert_eq!(status, FileStatus::Correct);
462    }
463
464    #[test]
465    fn test_check_missing_header() {
466        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
467        let style = CommentStyle::Line("//".into());
468        let subst = test_subst();
469
470        let content = "pub fn hello() {}\n";
471
472        let status = check_header_on_file_str(content, &template, &style, &subst);
473        assert_eq!(status, FileStatus::Missing);
474    }
475
476    #[test]
477    fn test_check_wrong_header() {
478        let template = HeaderTemplate::new("This file is part of {project}.\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
479        let style = CommentStyle::Line("//".into());
480        let subst = test_subst();
481
482        let content = "// Copyright (C) 2020 Wrong Author.\n// SPDX-License-Identifier: MIT\n\npub fn hello() {}\n";
483
484        let status = check_header_on_file_str(content, &template, &style, &subst);
485        assert_eq!(status, FileStatus::Wrong);
486    }
487
488    // Helper functions that work on strings directly
489    fn default_indicators() -> Vec<String> {
490        vec![
491            "Copyright".to_string(),
492            "SPDX".to_string(),
493            "License".to_string(),
494        ]
495    }
496
497    fn apply_header_to_file_str(
498        content: &str,
499        template: &HeaderTemplate,
500        style: &CommentStyle,
501        subst: &Substitution,
502    ) -> String {
503        apply_header_to_file_str_with(content, template, style, subst, &default_indicators(), "")
504    }
505
506    fn apply_header_to_file_str_with(
507        content: &str,
508        template: &HeaderTemplate,
509        style: &CommentStyle,
510        subst: &Substitution,
511        indicators: &[String],
512        greetings: &str,
513    ) -> String {
514        let expected = build_expected_header(template, style, subst, greetings);
515
516        let header_block = extract_header_block(content, style, indicators);
517
518        match header_block {
519            Some(existing) => {
520                if normalize_header(&existing) == normalize_header(&expected) {
521                    return content.to_string();
522                }
523                replace_header(content, &existing, &expected, style)
524            }
525            None => prepend_header(content, &expected, style),
526        }
527    }
528
529    fn check_header_on_file_str(
530        content: &str,
531        template: &HeaderTemplate,
532        style: &CommentStyle,
533        subst: &Substitution,
534    ) -> FileStatus {
535        check_header_on_file_str_with(content, template, style, subst, &default_indicators(), "")
536    }
537
538    fn check_header_on_file_str_with(
539        content: &str,
540        template: &HeaderTemplate,
541        style: &CommentStyle,
542        subst: &Substitution,
543        indicators: &[String],
544        greetings: &str,
545    ) -> FileStatus {
546        let expected = build_expected_header(template, style, subst, greetings);
547
548        let header_block = extract_header_block(content, style, indicators);
549
550        match header_block {
551            Some(existing) => {
552                if normalize_header(&existing) == normalize_header(&expected) {
553                    FileStatus::Correct
554                } else {
555                    FileStatus::Wrong
556                }
557            }
558            None => FileStatus::Missing,
559        }
560    }
561
562    #[test]
563    fn test_greetings_in_header() {
564        let template = HeaderTemplate::new(
565            "Copyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.",
566        );
567        let style = CommentStyle::Line("//".into());
568        let subst = test_subst();
569
570        let content = "// بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيم\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
571
572        let expected = "// بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيم\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
573
574        let result = apply_header_to_file_str_with(
575            content,
576            &template,
577            &style,
578            &subst,
579            &default_indicators(),
580            "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيم",
581        );
582        assert_eq!(result, expected);
583    }
584
585    #[test]
586    fn test_custom_indicator() {
587        let template = HeaderTemplate::new("CustomTag: Active\nCopyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.");
588        let style = CommentStyle::Line("//".into());
589        let subst = test_subst();
590        let mut indicators = default_indicators();
591        indicators.push("CustomTag".to_string());
592
593        let content = "// CustomTag: Active\n// Copyright (C) 2026-Present Test Author.\n// SPDX-License-Identifier: Apache-2.0 OR MIT.\n\npub fn hello() {}\n";
594
595        let status =
596            check_header_on_file_str_with(content, &template, &style, &subst, &indicators, "");
597        assert_eq!(status, FileStatus::Correct);
598    }
599
600    #[test]
601    fn test_none_indicator() {
602        let template = HeaderTemplate::new(
603            "Copyright (C) {year_span} {author}.\nSPDX-License-Identifier: {license}.",
604        );
605        let style = CommentStyle::Line("//".into());
606        let subst = test_subst();
607        let indicators: Vec<String> = vec![]; // "none" sentinel results in empty vec
608
609        let content = "// Some random comment.\n// Another random line.\n\npub fn hello() {}\n";
610
611        let status =
612            check_header_on_file_str_with(content, &template, &style, &subst, &indicators, "");
613        assert_eq!(status, FileStatus::Wrong);
614    }
615}