gixor 0.5.2

An API for managing .gitignore files.
Documentation
//! Turns the vendored boilerplates under `boilerplates/` into tables the `embedded` feature
//! reads, so that a build carrying that feature needs neither a clone nor a file system.
//!
//! The contents are not written into the generated code. Each entry points at its file with
//! `include_str!`, which keeps the generated file small and leaves the escaping to rustc.
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// One line of `boilerplates/SOURCES`: the repository the snapshot was taken from.
struct Source {
    name: String,
    url: String,
    owner: String,
    repo_name: String,
    commit: String,
}

fn main() {
    let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let dir = manifest.join("boilerplates");
    println!("cargo:rerun-if-changed={}", dir.display());

    // Walking the snapshot costs nothing, but only the embedded build has any use for the
    // result, and only it should fail when the snapshot is missing.
    let code = if env::var_os("CARGO_FEATURE_EMBEDDED").is_some() {
        generate(&dir)
    } else {
        String::new()
    };
    let out = PathBuf::from(env::var("OUT_DIR").unwrap());
    fs::write(out.join("embedded.rs"), code).expect("failed to write the embedded boilerplates");
}

fn generate(dir: &Path) -> String {
    let sources = read_sources(&dir.join("SOURCES"));
    let mut code = String::from(
        "// Generated by build.rs from the snapshot under boilerplates/. Do not edit.\n\n\
         /// (repository name, url, owner, repository name, the commit the snapshot came from)\n\
         pub(super) static REPOSITORIES: &[(&str, &str, &str, &str, &str)] = &[\n",
    );
    for s in &sources {
        code.push_str(&format!(
            "    ({:?}, {:?}, {:?}, {:?}, {:?}),\n",
            s.name, s.url, s.owner, s.repo_name, s.commit
        ));
    }
    code.push_str(
        "];\n\n\
         /// (repository name, path from the repository root, content)\n\
         pub(super) static BOILERPLATES: &[(&str, &str, &str)] = &[\n",
    );
    for s in &sources {
        let root = dir.join(&s.name);
        // BTreeMap rather than the order the file system hands them back, so that the same
        // snapshot always produces the same generated file.
        let mut files = BTreeMap::new();
        collect(&root, &root, &mut files);
        assert!(
            !files.is_empty(),
            "{}: the snapshot holds no boilerplate; run `just vendor_boilerplates`",
            root.display()
        );
        for (relative, absolute) in files {
            println!("cargo:rerun-if-changed={}", absolute.display());
            code.push_str(&format!(
                "    ({:?}, {:?}, include_str!({:?})),\n",
                s.name,
                relative,
                absolute.display().to_string().replace('\\', "/")
            ));
        }
    }
    code.push_str("];\n");
    code
}

/// Collects every `*.gitignore` below `dir`, keyed by its path relative to `root`.
fn collect(root: &Path, dir: &Path, found: &mut BTreeMap<String, PathBuf>) {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) => panic!("{}: cannot read the snapshot: {e}", dir.display()),
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect(root, &path, found);
        } else if path.extension().is_some_and(|e| e == "gitignore") {
            let relative = path
                .strip_prefix(root)
                .expect("walked from root")
                .display()
                .to_string()
                .replace('\\', "/");
            found.insert(relative, path);
        }
    }
}

fn read_sources(path: &Path) -> Vec<Source> {
    println!("cargo:rerun-if-changed={}", path.display());
    let content = fs::read_to_string(path).unwrap_or_else(|e| {
        panic!("{}: cannot read the snapshot sources: {e}", path.display())
    });
    let sources = content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(|line| {
            let fields = line.split_whitespace().collect::<Vec<_>>();
            assert_eq!(
                fields.len(),
                5,
                "SOURCES: expected `name url owner repo-name commit`, found {line:?}"
            );
            Source {
                name: fields[0].to_string(),
                url: fields[1].to_string(),
                owner: fields[2].to_string(),
                repo_name: fields[3].to_string(),
                commit: fields[4].to_string(),
            }
        })
        .collect::<Vec<_>>();
    assert!(!sources.is_empty(), "SOURCES: no repository is listed");
    sources
}