use crate::config::Config;
use crate::error::LichenError;
use crate::models::Authors;
use crate::models::CommentToken;
use futures::stream::{self, StreamExt};
use handlebars::{Handlebars, RenderError};
use jiff::civil::Date;
use log::{debug, error, info, trace, warn};
use regex::Regex;
use walkdir::{self, WalkDir};
use std::collections::{BTreeMap, HashSet};
use std::fs::{self};
use std::path::MAIN_SEPARATOR;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
const COMMENT_TOKENS_JSON: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/comment-tokens.json"
));
pub const HEADER_MARKER: char = '\u{2060}';
pub const HEADER_MARKER_STR: &str = "\u{2060}";
pub fn render_license(
source: &str,
date: &Date,
authors: &Option<Authors>,
) -> Result<String, RenderError> {
trace!("Began rendering template using handlebars");
let mut handlebars = Handlebars::new();
handlebars
.register_template_string("license", source)
.map_err(|e| {
RenderError::from(e)
})?;
let copyright_string;
if let Some(authors) = authors {
copyright_string = format!("Copyright (c) {} {}", date.year(), authors);
} else {
copyright_string = format!("Copyright (c) {}", date.year());
}
let mut data = BTreeMap::new();
data.insert("copyright".to_string(), ©right_string);
debug!(
"Rendering handlebars entry copyright with {}",
©right_string
);
handlebars.render("license", &data)
}
pub fn get_valid_files(
targets: &[PathBuf],
exclude_regex: &Option<Regex>,
) -> Result<Vec<PathBuf>, LichenError> {
debug!(
"Searching for processable files starting from targets: {:?}. Exclude pattern: {:?}",
targets, exclude_regex
);
let mut files_to_process = Vec::new();
let mut seen_paths = HashSet::new();
for target in targets {
if let Some(re) = exclude_regex.as_ref() {
if re.is_match(&target.to_string_lossy()) {
debug!("Excluding target file {}", target.display());
continue;
}
}
if !target.exists() {
error!("Target path does not exist: '{}'", target.display());
return Err(LichenError::InvalidPath(
target.to_string_lossy().to_string(),
));
}
trace!("Walking directory/file: '{}'", target.display());
let walker = WalkDir::new(target).follow_links(true).into_iter();
let filtered_walker = walker.filter_entry(|entry| {
let path = entry.path();
let path_string = &path.to_string_lossy();
let path_string = path_string.replace(MAIN_SEPARATOR, "/");
trace!("Considering entry: '{}'", path.display());
match exclude_regex {
Some(regex) => {
if regex.is_match(&path_string) {
debug!("Excluding path '{}' due to regex match.", path.display());
false } else {
true }
}
None => true, }
});
for entry_result in filtered_walker {
match entry_result {
Ok(entry) => {
let path = entry.into_path(); if path.is_file() {
trace!("Entry is a file: '{}'", path.display());
if seen_paths.insert(path.clone()) {
trace!("Adding unique file to list: '{}'", path.display());
files_to_process.push(path);
} else {
warn!(
"Duplicate file path encountered and ignored: '{}'. This might happen if targets overlap.",
path.display()
);
}
} else {
trace!(
"Entry is not a file (likely a directory), skipping: '{}'",
path.display()
);
}
}
Err(walk_err) => {
let path_display = walk_err
.path()
.map_or_else(|| "unknown path".to_string(), |p| p.display().to_string());
error!(
"Error accessing entry during directory walk at or near '{}': {}",
path_display, walk_err
);
}
}
}
}
if files_to_process.is_empty() {
warn!("No files found matching the criteria in the specified targets and exclusions.");
} else {
debug!(
"Found {} files to process across all targets.",
files_to_process.len()
);
trace!("Files identified for processing: {:?}", files_to_process);
}
Ok(files_to_process)
}
pub fn get_comment_tokens_for_ext(extension: &str) -> Result<Vec<CommentToken>, LichenError> {
use serde_json::Value;
trace!(
"Looking up comment character for extension: '{}' using embedded JSON",
extension
);
let mut tokens = Vec::new();
trace!("Parsing embedded JSON for comment tokens.");
let data: serde_json::Value = match serde_json::from_str(COMMENT_TOKENS_JSON) {
Ok(d) => d,
Err(e) => {
error!("Failed to parse embedded comment-tokens.json: {}", e);
return Err(LichenError::JsonError(e));
}
};
let languages_map = data.as_object().ok_or_else(|| {
LichenError::Msg("Invalid embedded JSON format: Top level is not an object.".to_string())
})?;
trace!(
"Searching for extension '{}' in parsed embedded JSON data.",
extension
);
for (_language_name, language_details) in languages_map {
if let Some(file_types_val) = language_details.get("file_types") {
if let Some(file_types_array) = file_types_val.as_array() {
let has_extension = file_types_array
.iter()
.filter_map(|v| v.as_str()) .any(|ext_str| ext_str == extension);
if has_extension {
trace!(
"Found matching extension '{}' under language entry.",
extension
);
if let Some(val) = language_details.get("comment_token") {
match val.as_str() {
Some(s) => {
debug!("Found comment_token='{}' for extension '{}'", s, extension);
tokens.push(CommentToken::Line(s.to_owned()));
}
None => warn!(
"'comment_token' for extension '{}' is not a string, skipping",
extension
),
}
}
if let Some(val) = language_details.get("comment_tokens") {
match val {
Value::String(s) => {
debug!("Found comment_token='{}' for extension '{}'", s, extension);
tokens.push(CommentToken::Line(s.clone()));
}
Value::Array(arr) => {
for item in arr {
if let Some(s) = item.as_str() {
debug!(
"Found comment_token='{}' for extension '{}'",
s, extension
);
tokens.push(CommentToken::Line(s.to_owned()));
} else {
warn!(
"Non‐string element in comment_tokens for '{}': {:?}, skipping",
extension, item
);
}
}
}
other => {
warn!(
"Unexpected type for comment_tokens under '{}': {:?}, skipping",
extension, other
);
}
}
}
if let Some(val) = language_details.get("block_comment_tokens") {
if let Some(obj) = val.as_object() {
let start = obj.get("start").and_then(|v| v.as_str());
let end = obj.get("end").and_then(|v| v.as_str());
match (start, end) {
(Some(s), Some(e)) => {
debug!(
"Block comments start with `{}` and end with `{}`",
s, e
);
tokens.push(CommentToken::Block {
start: s.to_owned(),
end: e.to_owned(),
});
}
_ => {
warn!(
"`block_comment_tokens` for extension '{}' is missing \
'start' or 'end' string.",
extension
);
}
}
}
else if let Some(arr) = val.as_array() {
for (idx, item) in arr.iter().enumerate() {
if let Some(obj) = item.as_object() {
let start = obj.get("start").and_then(|v| v.as_str());
let end = obj.get("end").and_then(|v| v.as_str());
match (start, end) {
(Some(s), Some(e)) => {
debug!(
"Block comment #{} starts with `{}` and ends with `{}`",
idx, s, e
);
tokens.push(CommentToken::Block {
start: s.to_owned(),
end: e.to_owned(),
});
}
_ => {
warn!(
"`block_comment_tokens[{}]` for extension '{}' is missing \
'start' or 'end'.",
idx, extension
);
}
}
} else {
warn!(
"`block_comment_tokens[{}]` for extension '{}' is not an object.",
idx, extension
);
}
}
}
else {
warn!(
"`block_comment_tokens` for extension '{}' is neither an object \
nor an array.",
extension
);
}
}
if tokens.is_empty() {
warn!(
"No comment tokens found for file extention {}, this probably means it prohibits comments or they present undefined behavior. Skipping.",
extension
)
}
return Ok(tokens);
}
} else {
warn!("'file_types' for language entry is not an array, skipping.");
}
}
}
warn!(
"Extension '{}' not found in embedded comment tokens data. Defaulting to '#'",
extension
);
Ok(vec![CommentToken::Line("#".to_string())])
}
pub fn format_header_with_comments(
header_content: &str,
comment_tokens: &[CommentToken],
prefers_block: bool,
separator: char,
) -> Option<String> {
trace!(
"Determining comment token from options: '{:?}', prefers_block: {}",
comment_tokens, prefers_block
);
let chosen_token = comment_tokens
.iter()
.find(|ct| match ct {
CommentToken::Block { .. } => prefers_block,
CommentToken::Line(_) => !prefers_block,
})
.or_else(|| {
comment_tokens.iter().find(|ct| match ct {
CommentToken::Block { .. } => !prefers_block, CommentToken::Line(_) => prefers_block, })
});
let comment_token = match chosen_token {
Some(token) => token,
None => {
warn!("No suitable comment token found in the provided list.");
return None;
}
};
trace!(
"Formatting header with chosen comment token: '{:?}'",
comment_token
);
let mut formatted_header = String::new();
let newline: char = '\n';
match comment_token {
CommentToken::Line(comment_token) => {
let lines: Vec<&str> = header_content.trim_end().lines().collect();
let line_count = lines.len();
for (i, line) in lines.iter().enumerate() {
formatted_header.push_str(comment_token);
if i == 0 {
formatted_header.push(separator);
}
if !line.is_empty() {
formatted_header.push(' ');
formatted_header.push_str(line);
}
if i < line_count - 1 {
formatted_header.push(newline);
}
}
formatted_header.push(separator);
}
CommentToken::Block { start, end } => {
formatted_header.push(newline); formatted_header.push_str(start);
formatted_header.push(separator); formatted_header.push(newline); formatted_header.push_str(header_content.trim()); formatted_header.push(newline); formatted_header.push(separator); formatted_header.push_str(end);
formatted_header.push(newline); }
}
debug!("Header formatting complete.");
Some(formatted_header)
}
pub trait ReplaceBetween {
fn replace_between<'a>(&'a self, delim: char, replacement: &str) -> Cow<'a, str>;
}
use std::borrow::Cow;
impl ReplaceBetween for str {
fn replace_between<'a>(&'a self, delim: char, replacement: &str) -> Cow<'a, str> {
let mut first_sight: Option<usize> = None;
let mut last_seen: Option<usize> = None;
let all_lines: Vec<&str> = self
.lines()
.enumerate()
.map(|(index, line)| {
if line.contains(delim) {
first_sight = first_sight.or(Some(index));
last_seen = Some(index);
}
line })
.collect();
if let (Some(first_idx), Some(last_idx)) = (first_sight, last_seen) {
let mut result_parts: Vec<Cow<'_, str>> = Vec::new();
if first_idx > 0 {
result_parts.push(Cow::Owned(all_lines[0..first_idx].join("\n")));
}
result_parts.push(Cow::Borrowed(replacement));
if last_idx + 1 < all_lines.len() {
result_parts.push(Cow::Owned(all_lines[last_idx + 1..].join("\n")));
}
let final_string = result_parts
.iter()
.filter(|s| !s.is_empty()) .map(|s| s.as_ref()) .collect::<Vec<&str>>()
.join("\n");
let mut final_result = final_string;
final_result.push('\n');
Cow::Owned(final_result)
} else {
Cow::Borrowed(self)
}
}
}
pub async fn remove_headers_from_files(
paths: &[PathBuf],
max_concurrency: std::num::NonZero<usize>,
) -> Result<(), LichenError> {
use tokio::fs;
debug!(
"Starting to remove headers from {} files with concurrency {}",
paths.len(),
max_concurrency
);
let results = stream::iter(paths.to_owned())
.map(|path| {
async move {
trace!("Processing file for header removal: '{}'", path.display());
if path.is_dir() {
warn!("Skipping directory during removal: '{}'", path.display());
return Ok((0, 1, 0));
}
let content = match fs::read_to_string(&path).await {
Ok(c) => c,
Err(e) => {
warn!(
"Failed to read '{}' for removal: {}. Skipping.",
path.display(),
e
);
return Ok((0, 1, 0));
}
};
let mut shebang_len = 0;
if content.starts_with("#!") {
if let Some(pos) = content.find('\n') {
shebang_len = pos + 1;
} else {
trace!(
"File '{}' is only a shebang line. Skipping removal.",
path.display()
);
return Ok((0, 1, 0)); }
}
let search_area = &content[shebang_len..];
let marker_pos_in_search_area = search_area.rfind(HEADER_MARKER_STR);
if let Some(relative_pos) = marker_pos_in_search_area {
let marker_start_pos = shebang_len + relative_pos;
let content_after_marker_pos = marker_start_pos + HEADER_MARKER_STR.len();
let mut new_text = String::with_capacity(
shebang_len + (content.len() - content_after_marker_pos),
);
if shebang_len > 0 {
new_text.push_str(&content[..shebang_len]);
}
let rest_content = &content[content_after_marker_pos..];
new_text.push_str(rest_content.trim_start_matches('\n'));
match fs::write(&path, new_text).await {
Ok(_) => {
info!("Removed header from '{}'", path.display());
Ok((1, 0, 0)) }
Err(e) => {
error!(
"Failed to write removed header to '{}': {}",
path.display(),
e
);
Ok((0, 0, 1)) }
}
} else {
debug!(
"Header marker not found in '{}'. Skipping removal.",
path.display()
);
Ok((0, 1, 0)) }
}
})
.buffer_unordered(max_concurrency.into()) .collect::<Vec<Result<(usize, usize, usize), LichenError>>>() .await;
let mut total_removed = 0;
let mut total_skipped = 0;
let mut total_errors = 0;
let mut first_error: Option<LichenError> = None;
for result in results {
match result {
Ok((removed, skipped, errors)) => {
total_removed += removed;
total_skipped += skipped;
total_errors += errors;
}
Err(e) => {
error!("Unexpected error during stream processing: {}", e);
total_errors += 1;
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
info!(
"Header removal summary: {} removed, {} skipped, {} errors.",
total_removed, total_skipped, total_errors
);
if total_errors > 0 {
Err(first_error.unwrap_or_else(|| {
LichenError::Msg(format!(
"Encountered {} errors during header removal.",
total_errors
))
}))
} else {
Ok(())
}
}
pub async fn apply_headers_to_files(
header_content: &str,
paths: &[PathBuf],
max_concurrency: std::num::NonZero<usize>,
prefers_block: bool,
multiple: bool,
) -> Result<(), LichenError> {
use tokio::fs;
debug!(
"Starting to apply headers to {} files with concurrency {}",
paths.len(),
max_concurrency
);
let header_content_arc = Arc::new(header_content.to_string());
let results = stream::iter(paths.to_owned())
.map(|path| {
let header_content = header_content_arc.clone(); async move {
trace!("Processing file: '{}'", path.display());
if path.is_dir() {
warn!("Skipping directory: '{}'", path.display());
return Ok((0, 1, 0));
}
let content = match fs::read_to_string(&path).await {
Ok(c) => c,
Err(e) => {
warn!("Failed to read '{}': {}. Skipping.", path.display(), e);
return Ok((0, 1, 0));
}
};
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let comment_tokens = match get_comment_tokens_for_ext(ext) {
Ok(tokens) if !tokens.is_empty() => tokens,
Ok(_) => {
warn!(
"No comment tokens defined for extension '{}' (file '{}'). Skipping.",
ext,
path.display()
);
return Ok((0, 1, 0)); }
Err(e) => {
error!(
"Failed to get comment tokens for '{}': {}. Skipping.",
path.display(),
e
);
return Ok((0, 0, 1)); }
};
let formatted_header = match format_header_with_comments(
&header_content,
&comment_tokens,
prefers_block,
HEADER_MARKER,
) {
Some(h) => h,
None => {
error!(
"Failed to format header for '{}' (no suitable token found). Skipping.",
path.display()
);
return Ok((0, 1, 0)); }
};
if !multiple {
if content.contains(HEADER_MARKER) {
debug!(
"Already contains header marker, replacing '{}'",
path.display()
);
let content = content.replace_between(HEADER_MARKER, &formatted_header);
fs::write(&path, content.to_string()).await?;
return Ok((1, 0, 0));
}
}
let (shebang, rest) = if content.starts_with("#!") {
if let Some(pos) = content.find('\n') {
let (sb, rem) = content.split_at(pos + 1);
(Some(sb), rem)
} else {
(Some(content.as_str()), "")
}
} else {
(None, content.as_str())
};
let mut new_text = String::with_capacity(
shebang.map_or(0, |s| s.len()) +
formatted_header.len() +
1 + rest.len(),
);
if let Some(sb) = shebang {
new_text.push_str(sb);
if !sb.ends_with('\n') {
new_text.push('\n');
}
}
new_text.push_str(&formatted_header);
if !formatted_header.ends_with('\n') {
new_text.push('\n');
}
new_text.push_str(rest.trim_start_matches('\n'));
match fs::write(&path, new_text).await {
Ok(_) => {
debug!("Applied header to '{}'", path.display());
Ok((1, 0, 0)) }
Err(e) => {
error!("Failed to write header to '{}': {}", path.display(), e);
Ok((0, 0, 1)) }
}
}
})
.buffer_unordered(max_concurrency.into()) .collect::<Vec<Result<(usize, usize, usize), LichenError>>>() .await;
let mut total_applied = 0;
let mut total_skipped = 0;
let mut total_errors = 0;
let mut first_error: Option<LichenError> = None;
for result in results {
match result {
Ok((applied, skipped, errors)) => {
total_applied += applied;
total_skipped += skipped;
total_errors += errors;
}
Err(e) => {
error!("Unexpected error during stream processing: {}", e);
total_errors += 1;
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
info!(
"Header application summary: {} applied, {} skipped, {} errors.",
total_applied, total_skipped, total_errors
);
if total_errors > 0 {
Err(first_error.unwrap_or_else(|| {
LichenError::Msg(format!(
"Encountered {} errors during header application.",
total_errors
))
}))
} else {
Ok(())
}
}
fn load_gitignore_patterns() -> Result<Option<Vec<String>>, LichenError> {
let mut patterns = Vec::new();
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("Git command failed: {}", stderr);
return Ok(None); }
let project_directory = String::from_utf8(output.stdout).unwrap().trim().to_string();
let gitignore = PathBuf::from(project_directory).join(".gitignore");
let content = match fs::read_to_string(gitignore) {
Ok(s) => s,
Err(_) => return Ok(None),
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let is_dir = line.ends_with('/');
let pat = if is_dir {
&line[..line.len() - 1]
} else {
line
};
let mut re = regex::escape(pat).replace(r"\*", ".*").replace(r"\?", ".");
if is_dir {
re.push_str("/.*");
}
patterns.push(re);
}
Ok(Some(patterns))
}
pub fn build_exclude_regex(
cli_exclude: &Option<Regex>,
cfg: Option<&Config>,
all: bool,
index: Option<usize>,
) -> Result<Option<Regex>, LichenError> {
let mut pats = Vec::new();
let defaults: Vec<String> = vec![
"\\.gitignore".to_string(),
".*lock".to_string(),
"\\.git/.*".to_string(),
"\\.licensure\\.yml".to_string(),
"README.*".to_string(),
"LICENSE.*".to_string(),
".*\\.(md|rst|txt)".to_string(),
"Cargo.toml".to_string(),
".*\\.github/.*".to_string(),
];
if !all {
if let Some(gitignore) = load_gitignore_patterns()? {
pats.extend(gitignore);
}
pats.extend(defaults); } else {
return Ok(None);
}
if let Some(cfg) = cfg {
if let Some(globs) = cfg.exclude.as_ref() {
for re in globs.iter() {
pats.push(re.as_str().to_string());
}
}
if let Some(i) = index {
if let Some(licenses) = cfg.licenses.as_ref() {
if let Some(lic) = licenses.get(i) {
if let Some(exc) = lic.exclude.as_ref() {
pats.push(exc.to_string());
}
} else {
return Err(LichenError::InvalidIndex(i));
}
}
}
}
if let Some(cli_exc) = cli_exclude {
pats.push(cli_exc.to_string());
}
if pats.is_empty() {
return Ok(None);
}
let alternation = pats
.into_iter()
.map(|p| format!("(?:{})", p))
.collect::<Vec<_>>()
.join("|");
match Regex::new(&alternation) {
Ok(re) => Ok(Some(re)),
Err(err) => Err(LichenError::RegexError(alternation, err)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Author, Authors};
use jiff::civil::Date;
use std::borrow::Cow;
#[test]
fn render_license_injects_copyright_and_authors() {
let template = "/* {{copyright}} */";
let authors = Some(Authors(vec![
Author {
name: "A".into(),
email: None,
},
Author {
name: "B".into(),
email: Some("b@e".into()),
},
]));
let year = Date::new(2025, 1, 1).unwrap();
let out = render_license(template, &year, &authors).unwrap();
assert!(out.contains("2025"));
assert!(out.contains("A"));
assert!(out.contains("B [b@e]"));
}
#[test]
fn replace_between_replaces_delimited_region() {
let text = "line1\n* old\n* old2\nline4";
let replaced = text.replace_between('*', "NEW\nNEW2");
let expect = "line1\nNEW\nNEW2\nline4\n";
match replaced {
Cow::Owned(ref s) => assert_eq!(s, expect),
_ => panic!("expected owned String"),
}
}
#[test]
fn replace_between_no_delimiter_returns_borrowed() {
let text = "no markers here";
let replaced = text.replace_between('#', "X");
assert!(matches!(replaced, Cow::Borrowed(_)));
assert_eq!(replaced, "no markers here");
}
}
#[cfg(test)]
mod tests_utils {
use super::*;
use crate::models::CommentToken;
#[test]
fn get_comment_tokens_known_extensions() {
let rs_tokens = get_comment_tokens_for_ext("rs").unwrap();
assert!(rs_tokens.contains(&CommentToken::Line("//".to_string())));
assert!(rs_tokens.contains(&CommentToken::Block {
start: "/*".to_string(),
end: "*/".to_string()
}));
let py_tokens = get_comment_tokens_for_ext("py").unwrap();
assert!(py_tokens.contains(&CommentToken::Line("#".to_string())));
let c_tokens = get_comment_tokens_for_ext("c").unwrap();
print!("{:?}", c_tokens);
assert!(c_tokens.contains(&CommentToken::Block {
start: "/*".to_string(),
end: "*/".to_string()
}));
let js_tokens = get_comment_tokens_for_ext("js").unwrap();
assert!(js_tokens.contains(&CommentToken::Line("//".to_string())));
assert!(js_tokens.contains(&CommentToken::Block {
start: "/*".to_string(),
end: "*/".to_string()
}));
}
#[test]
fn get_comment_tokens_unknown_extension_defaults() {
let unknown_tokens = get_comment_tokens_for_ext("not_a_real_extension_qwerty").unwrap();
assert_eq!(unknown_tokens, vec![CommentToken::Line("#".to_string())]);
}
#[test]
fn format_header_line_comment() {
let header = "Line 1\nLine 2";
let tokens = vec![CommentToken::Line("//".to_string())];
let formatted = format_header_with_comments(header, &tokens, false, HEADER_MARKER).unwrap(); let expected = format!(
"//{marker} Line 1\n// Line 2{marker}", marker = HEADER_MARKER
);
assert_eq!(formatted.trim(), expected.trim()); assert!(formatted.contains(HEADER_MARKER));
}
#[test]
fn format_header_block_comment() {
let header = "Line 1\nLine 2";
let tokens = vec![CommentToken::Block {
start: "/*".to_string(),
end: "*/".to_string(),
}];
let formatted = format_header_with_comments(header, &tokens, true, HEADER_MARKER).unwrap(); let expected = format!(
"\n/*{marker}\nLine 1\nLine 2\n{marker}*/\n", marker = HEADER_MARKER
);
assert_eq!(formatted, expected);
assert!(formatted.contains(HEADER_MARKER));
}
}