pathlint 0.0.11

Lint the PATH environment variable against declarative ordering rules.
Documentation
//! Concatenate the per-package-manager plugin TOML files in
//! `plugins/` into a single `embedded_catalog.toml` placed in
//! `OUT_DIR`, and emit the path so `src/catalog.rs` can
//! `include_str!` it. Pure file IO; no `toml` crate dependency
//! at build time.
//!
//! The order is `catalog_version` line + each plugin in the
//! order listed in `plugins/_index.toml`. Plugin files are
//! concatenated verbatim (already-written comments survive).

use std::env;
use std::fs;
use std::path::PathBuf;

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let plugins_dir = manifest_dir.join("plugins");
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let out_file = out_dir.join("embedded_catalog.toml");

    println!("cargo:rerun-if-changed=plugins");

    let index_path = plugins_dir.join("_index.toml");
    let index_text = fs::read_to_string(&index_path)
        .unwrap_or_else(|e| panic!("could not read {}: {e}", index_path.display()));

    let catalog_version = parse_catalog_version(&index_text)
        .expect("plugins/_index.toml must declare `catalog_version`");
    let plugin_names = parse_plugins_list(&index_text)
        .expect("plugins/_index.toml must declare `plugins = [...]`");

    let mut buf = String::new();
    buf.push_str("# Generated by build.rs from plugins/*.toml. Edit those.\n");
    buf.push_str(&format!("catalog_version = {catalog_version}\n\n"));

    for name in &plugin_names {
        let path = plugins_dir.join(format!("{name}.toml"));
        let body = fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display()));
        buf.push_str(&format!("# ---- plugin: {name} ----\n\n"));
        buf.push_str(&body);
        if !body.ends_with('\n') {
            buf.push('\n');
        }
        buf.push('\n');
    }

    fs::write(&out_file, buf)
        .unwrap_or_else(|e| panic!("could not write {}: {e}", out_file.display()));
}

/// Pull the `catalog_version = N` line out of `_index.toml` by
/// hand. Refuses to be clever — only matches a literal integer
/// at the top level. Avoids pulling `toml` into the build script
/// just for one int.
fn parse_catalog_version(text: &str) -> Option<u32> {
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('#') {
            continue;
        }
        let rest = trimmed.strip_prefix("catalog_version")?;
        let rest = rest.trim_start();
        let rest = rest.strip_prefix('=')?.trim_start();
        // Cut off any trailing comment.
        let value = rest.split('#').next()?.trim();
        return value.parse::<u32>().ok();
    }
    None
}

/// Parse `plugins = [\n  "a",\n  "b",\n]` (or single-line form)
/// out of `_index.toml`. Returns the list of plugin names in
/// declaration order. Only handles the shape we actually write —
/// no nested arrays, no inline tables, no escapes inside the
/// quoted strings.
fn parse_plugins_list(text: &str) -> Option<Vec<String>> {
    let start = text.find("plugins")?;
    let after_key = &text[start..];
    let bracket_open = after_key.find('[')?;
    let bracket_close = after_key.find(']')?;
    if bracket_close <= bracket_open {
        return None;
    }
    let body = &after_key[bracket_open + 1..bracket_close];
    let names: Vec<String> = body
        .split(',')
        .filter_map(|part| {
            let trimmed = part.trim().trim_matches('"');
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed.to_string())
            }
        })
        .collect();
    if names.is_empty() { None } else { Some(names) }
}