bounds-cli 0.5.0

Add a .gitignore and LICENSE to a repository
use std::{
    collections::HashSet,
    fs::{self, OpenOptions},
    io::Write,
    path::PathBuf,
};

use anyhow::{Context, Result};

use crate::core::model::{Config, GitignoreTemplate, WriteMode};

pub fn write(config: &Config) -> Result<Option<PathBuf>> {
    let path = config.target.join(".gitignore");

    match config.gitignore_mode {
        WriteMode::Skip => return Ok(None),

        WriteMode::Create => {
            let contents = render(&config.gitignores);

            let mut file = OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&path)
                .with_context(|| format!("failed to create {}", path.display()))?;

            file.write_all(contents.as_bytes())?;
        }

        WriteMode::Replace => {
            fs::write(&path, render(&config.gitignores))
                .with_context(|| format!("failed to replace {}", path.display()))?;
        }

        WriteMode::Merge => {
            let existing = fs::read_to_string(&path)
                .with_context(|| format!("failed to read {}", path.display()))?;

            let merged = merge(&existing, &config.gitignores);

            fs::write(&path, merged)
                .with_context(|| format!("failed to update {}", path.display()))?;
        }
    }

    Ok(Some(path))
}

fn render(templates: &[GitignoreTemplate]) -> String {
    let mut known_rules = HashSet::new();
    let mut output = String::new();

    for template in templates {
        let additions = new_rules(&template.source, &mut known_rules);

        if additions.is_empty() {
            continue;
        }

        output.push_str(&format!("# {}\n", template.name));

        for line in additions {
            output.push_str(line);
            output.push('\n');
        }

        output.push('\n');
    }

    output
}

fn merge(existing: &str, templates: &[GitignoreTemplate]) -> String {
    let (mut output, mut known_rules) = compact_existing(existing);

    for template in templates {
        let additions = new_rules(&template.source, &mut known_rules);

        if additions.is_empty() {
            continue;
        }

        if !output.is_empty() {
            if !output.ends_with('\n') {
                output.push_str("\n\n");
            } else if !output.ends_with("\n\n") {
                output.push('\n');
            }
        }

        output.push_str(&format!("# {}\n", template.name));

        for line in additions {
            output.push_str(line);
            output.push('\n');
        }
    }

    if !output.is_empty() && !output.ends_with('\n') {
        output.push('\n');
    }

    output
}

fn compact_existing(existing: &str) -> (String, HashSet<String>) {
    let mut known_rules = HashSet::new();
    let mut output = String::new();

    for line in existing.trim_end().lines() {
        let line = line.trim_end();
        let trimmed = line.trim_start();

        if !trimmed.is_empty() && !trimmed.starts_with('#') && !known_rules.insert(line.to_owned())
        {
            continue;
        }

        output.push_str(line);
        output.push('\n');
    }

    (output.trim_end().to_owned(), known_rules)
}

fn new_rules<'a>(source: &'a str, known_rules: &mut HashSet<String>) -> Vec<&'a str> {
    source
        .lines()
        .map(str::trim_end)
        .filter(|line| {
            let trimmed = line.trim_start();

            !trimmed.is_empty()
                && !trimmed.starts_with('#')
                && known_rules.insert((*line).to_owned())
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{merge, render};
    use crate::core::model::GitignoreTemplate;

    fn template(name: &str, source: &str) -> GitignoreTemplate {
        GitignoreTemplate {
            key: name.to_lowercase(),
            name: name.to_owned(),
            source: source.to_owned(),
        }
    }

    #[test]
    fn render_labels_and_separates_templates() {
        let templates = [
            template("Rust", "\ntarget/\n"),
            template("Node", "node_modules/\n"),
        ];

        assert_eq!(
            render(&templates),
            "# Rust\ntarget/\n\n# Node\nnode_modules/\n\n"
        );
    }

    #[test]
    fn merge_keeps_existing_rules_and_adds_only_new_rules() {
        let templates = [
            template("Rust", "target/\nCargo.lock\n"),
            template("Node", "target/\nnode_modules/\n"),
        ];

        assert_eq!(
            merge("# Existing\ntarget/\n", &templates),
            "# Existing\ntarget/\n\n# Rust\nCargo.lock\n\n# Node\nnode_modules/\n"
        );
    }

    #[test]
    fn merge_into_empty_file_has_no_leading_blank_lines() {
        let templates = [template("Rust", "target/\n")];

        assert_eq!(merge("", &templates), "# Rust\ntarget/\n");
    }

    #[test]
    fn render_removes_comments_blank_lines_and_duplicate_rules() {
        let templates = [
            template(
                "Astro",
                "# Created by Toptal\n\n### Astro ###\ndist/\n.astro/\n.env\n",
            ),
            template("Node", "# dependencies\nnode_modules/\ndist/\n.env\n"),
        ];

        assert_eq!(
            render(&templates),
            "# Astro\ndist/\n.astro/\n.env\n\n# Node\nnode_modules/\n\n"
        );
    }

    #[test]
    fn render_preserves_escaped_hash_patterns() {
        let templates = [template("Example", "# comment\n\\#file\n")];

        assert_eq!(render(&templates), "# Example\n\\#file\n\n");
    }

    #[test]
    fn merge_removes_rules_already_repeated_in_existing_file() {
        let templates = [template("Astro", ".env\ndist/\n")];

        assert_eq!(
            merge("# Existing\n.env\nnode_modules/\n.env\n", &templates),
            "# Existing\n.env\nnode_modules/\n\n# Astro\ndist/\n"
        );
    }

    #[test]
    fn distinct_gitignore_patterns_are_not_fuzzily_deduplicated() {
        let templates = [template("Paths", "dist/\n/dist/\n**/dist/\n")];

        assert_eq!(render(&templates), "# Paths\ndist/\n/dist/\n**/dist/\n\n");
    }
}