gitignore-in 0.2.1

A command line tool for managing .gitignore files with gitignore.in
const HEADER_LINES: [&str; 5] = [
    "# DO NOT EDIT THIS FILE",
    "# Generated by gitignore.in",
    "# See https://gitignore.in/",
    "# Edit .gitignore.in instead of this file",
    "# Run `gitignore.in` to build .gitignore",
];

const SEPARATOR: &str =
    "# -----------------------------------------------------------------------------";

pub(crate) fn restore(text: &str) -> String {
    let mut result = Vec::new();
    let mut lines = text.lines().peekable();

    while let Some(line) = lines.next() {
        if HEADER_LINES.contains(&line) {
            continue;
        }

        if line == SEPARATOR {
            let Some(section_head) = lines.next() else {
                break;
            };

            if let Some(target) = section_head.strip_prefix("# gibo dump ") {
                result.push(format!("gibo dump {target}"));
                skip_generated_section(&mut lines);
                continue;
            }

            if let Some(target) = section_head.strip_prefix("# gi ") {
                result.push(format!("gi {target}"));
                skip_generated_section(&mut lines);
                continue;
            }

            if !section_head.is_empty() {
                result.push(format!("echo {}", shell_quote(section_head)));
            }

            continue;
        }

        if line.is_empty() {
            result.push(line.to_string());
            continue;
        }

        if let Some(comment) = restore_comment_line(line) {
            result.push(comment);
            continue;
        }

        result.push(format!("echo {}", shell_quote(line)));
    }

    result.join("\n") + "\n"
}

pub(crate) fn looks_generated(text: &str) -> bool {
    HEADER_LINES.iter().all(|line| text.contains(line))
}

fn skip_generated_section<'a, I>(lines: &mut std::iter::Peekable<I>)
where
    I: Iterator<Item = &'a str>,
{
    while let Some(next) = lines.peek() {
        if *next == SEPARATOR {
            break;
        }
        lines.next();
    }
}

fn restore_comment_line(line: &str) -> Option<String> {
    line.strip_prefix("# #")
        .map(|comment| format!("#{comment}"))
}

fn shell_quote(text: &str) -> String {
    format!("'{}'", text.replace('\'', r#"'\''"#))
}

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

    #[test]
    fn restores_generated_gitignore() {
        let text = r#"# DO NOT EDIT THIS FILE
# Generated by gitignore.in
# See https://gitignore.in/
# Edit .gitignore.in instead of this file
# Run `gitignore.in` to build .gitignore
# # project comment
# -----------------------------------------------------------------------------
# gibo dump Linux
### Generated by gibo
foo
# -----------------------------------------------------------------------------
# gi Rust
### Created by gitignore.io
bar
# -----------------------------------------------------------------------------
!.env
"#;

        let expected = "# project comment\ngibo dump Linux\ngi Rust\necho '!.env'\n";
        assert_eq!(restore(text), expected);
    }

    #[test]
    fn quotes_echo_content() {
        let text = "# -----------------------------------------------------------------------------\na'b\n";
        assert_eq!(restore(text), "echo 'a'\\''b'\n");
    }

    #[test]
    fn restores_mixed_content_sections() {
        let text = r#"# DO NOT EDIT THIS FILE
# Generated by gitignore.in
# See https://gitignore.in/
# Edit .gitignore.in instead of this file
# Run `gitignore.in` to build .gitignore
# # keep this comment
# -----------------------------------------------------------------------------
plain-entry
# -----------------------------------------------------------------------------
!important.txt
# trailing note
"#;

        let expected =
            "# keep this comment\necho 'plain-entry'\necho '!important.txt'\necho '# trailing note'\n";
        assert_eq!(restore(text), expected);
    }

    #[test]
    fn round_trips_generated_comment_shape() {
        let restored = restore("# # original comment\n");
        assert_eq!(restored, "# original comment\n");
    }
}