bake-license 0.2.2

License and copyright maintenance tasks for Bake
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

use crate::authorship::Copyright;

pub(crate) fn update_source(contents: &str, copyrights: &[Copyright]) -> String {
    let lines: Vec<&str> = contents.split_inclusive('\n').collect();
    let newline = line_ending(contents);
    let mut prefix_length = 0;

    if lines
        .first()
        .is_some_and(|line| line.starts_with("#!") && !line.starts_with("#!["))
    {
        prefix_length += 1;
    }

    while lines
        .get(prefix_length)
        .is_some_and(|line| clean_line(line).starts_with("#!["))
    {
        let mut open_brackets = 0isize;
        let mut in_string = false;
        let mut escaped = false;
        while let Some(line) = lines.get(prefix_length) {
            for character in clean_line(line).chars() {
                if in_string {
                    if escaped {
                        escaped = false;
                    } else if character == '\\' {
                        escaped = true;
                    } else if character == '"' {
                        in_string = false;
                    }
                    continue;
                }

                match character {
                    '"' => in_string = true,
                    '[' => open_brackets += 1,
                    ']' => open_brackets -= 1,
                    _ => {}
                }
            }
            prefix_length += 1;
            if open_brackets <= 0 {
                break;
            }
        }
    }

    let mut leading_comments = Vec::new();
    while let Some(line) = lines.get(prefix_length + leading_comments.len()) {
        let clean = clean_line(line);
        if clean.trim().is_empty() || clean.trim_start().starts_with("//") {
            leading_comments.push(*line);
        } else {
            break;
        }
    }

    let mut documentation: Vec<_> = leading_comments
        .into_iter()
        .filter(|line| !is_license_metadata(clean_line(line)))
        .collect();
    while documentation
        .first()
        .is_some_and(|line| clean_line(line).trim().is_empty())
    {
        documentation.remove(0);
    }
    while documentation
        .last()
        .is_some_and(|line| clean_line(line).trim().is_empty())
    {
        documentation.pop();
    }

    let body_start = prefix_length
        + lines[prefix_length..]
            .iter()
            .take_while(|line| {
                let clean = clean_line(line);
                clean.trim().is_empty() || clean.trim_start().starts_with("//")
            })
            .count();

    let mut output = String::new();
    for line in &lines[..prefix_length] {
        output.push_str(line);
    }
    if prefix_length > 0 {
        output.push_str(newline);
    }

    output.push_str("// Released under the MIT License.");
    output.push_str(newline);
    for copyright in copyrights {
        output.push_str(&copyright.source_comment());
        output.push_str(newline);
    }
    output.push_str(newline);

    for line in documentation {
        output.push_str(line);
    }
    for line in &lines[body_start..] {
        output.push_str(line);
    }

    output
}

fn is_license_metadata(line: &str) -> bool {
    let Some(comment) = line.trim_start().strip_prefix("//") else {
        return false;
    };
    let comment = comment.trim_start();
    let comment = comment
        .strip_prefix('!')
        .or_else(|| comment.strip_prefix('/'))
        .unwrap_or(comment)
        .trim_start();
    comment.starts_with("Released under ")
        || comment.starts_with("Copyright,")
        || comment.starts_with("SPDX-License-Identifier:")
}

fn clean_line(line: &str) -> &str {
    line.trim_end_matches('\n').trim_end_matches('\r')
}

fn line_ending(contents: &str) -> &str {
    match contents.find('\n') {
        Some(index) if contents[..index].ends_with('\r') => "\r\n",
        _ => "\n",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn copyright() -> Copyright {
        Copyright::new(2020, 2024, "Alice")
    }

    #[test]
    fn inserts_headers_after_a_shebang_and_multiline_inner_attributes() {
        let contents = concat!(
            "#!/usr/bin/env rustx\n",
            "#![cfg_attr(\n",
            "    feature = \"doc\",\n",
            "    doc = \"contains ] and escaped \\\" [\"\n",
            ")]\n",
            "//! Module documentation.\n",
            "// Released under an old license.\n",
            "// Copyright, 2018, by Previous Author.\n",
            "// SPDX-License-Identifier: Apache-2.0\n",
            "/// Public API documentation.\n",
            "// Keep this comment.\n",
            "\n",
            "pub fn example() {}\n",
        );

        let updated = update_source(contents, &[copyright()]);

        assert_eq!(
            updated,
            concat!(
                "#!/usr/bin/env rustx\n",
                "#![cfg_attr(\n",
                "    feature = \"doc\",\n",
                "    doc = \"contains ] and escaped \\\" [\"\n",
                ")]\n",
                "\n",
                "// Released under the MIT License.\n",
                "// Copyright, 2020-2024, by Alice.\n",
                "\n",
                "//! Module documentation.\n",
                "/// Public API documentation.\n",
                "// Keep this comment.\n",
                "pub fn example() {}\n",
            )
        );
    }

    #[test]
    fn preserves_crate_inner_attributes_and_replaces_old_metadata() {
        let contents = concat!(
            "#![allow(dead_code)]\r\n",
            "\r\n",
            "// old comment\r\n",
            "// Copyright, 2018, by Previous Author.\r\n",
            "\r\n",
            "fn main() {}\r\n",
        );

        let updated = update_source(contents, &[copyright()]);

        assert!(updated.starts_with(
            "#![allow(dead_code)]\r\n\r\n// Released under the MIT License.\r\n// Copyright, 2020-2024, by Alice.\r\n\r\n// old comment\r\n"
        ));
        assert!(updated.ends_with("fn main() {}\r\n"));
    }

    #[test]
    fn handles_a_source_file_without_a_prefix_or_existing_comments() {
        assert_eq!(
            update_source("pub fn example() {}\n", &[]),
            "// Released under the MIT License.\n\npub fn example() {}\n"
        );
        assert_eq!(line_ending("single line"), "\n");
    }

    #[test]
    fn recognizes_license_metadata_in_plain_and_documentation_comments() {
        for line in [
            "// Released under the MIT License.",
            "// Copyright, 2024, by Alice.",
            "// SPDX-License-Identifier: MIT",
            "//! Released under the MIT License.",
            "/// Copyright, 2024, by Alice.",
        ] {
            assert!(is_license_metadata(line), "{line:?}");
        }

        for line in [
            "pub fn example() {}",
            "// Ordinary comment.",
            "//! Module docs.",
        ] {
            assert!(!is_license_metadata(line), "{line:?}");
        }

        assert_eq!(clean_line("// comment\r\n"), "// comment");
    }
}