1use 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
53pub 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
79pub 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
115pub 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
145pub 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
200fn 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(); 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 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 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![]; 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}