pathlint 0.0.13

Lint the PATH environment variable against declarative ordering rules.
Documentation
// The shape types below mirror src/config.rs only to validate
// the input TOML's structure. Many fields are never read after
// deserialization succeeds — the side effect (reject mismatched
// shape) is the whole point. Silence dead_code warnings rather
// than uglify the types with `#[allow(dead_code)]` per field.
#![allow(dead_code)]

//! 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.
//!
//! 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).
//!
//! As of 0.0.12 every plugin file is parsed through `toml` +
//! `serde::Deserialize` against a build-script-local copy of the
//! Config / Relation / SourceDef shape. Shape mismatches (an
//! unknown field, a typo'd relation kind, a relation pointing at
//! a source no plugin defines) become build errors, not runtime
//! errors. The shape types here are intentionally a duplicate of
//! `src/config.rs` because build scripts cannot depend on the
//! crate they are building. When a relation kind or field is
//! added to `src/config.rs`, mirror the change here.
//!
//! Run `cargo test --workspace` to also exercise the
//! `#[cfg(test)] mod tests` block at the bottom of this file —
//! those tests pin the shape-checking behaviour without needing
//! the rest of the crate to build.

use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;

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 index: IndexFile = toml::from_str(&index_text).unwrap_or_else(|e| {
        panic!(
            "plugins/_index.toml failed shape check: {e}\n\
             expected `catalog_version = <int>` and `plugins = [\"name\", ...]`"
        )
    });

    // Parse + shape-check every plugin file before emitting the
    // concatenated catalog. We hold on to the parsed plugins so the
    // referential-integrity check can see every defined source
    // name in one pass.
    let mut plugins: Vec<(String, PluginFile, String)> = Vec::with_capacity(index.plugins.len());
    for name in &index.plugins {
        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()));
        let parsed: PluginFile = toml::from_str(&body).unwrap_or_else(|e| {
            panic!(
                "plugins/{name}.toml failed shape check: {e}\n\
                 (build.rs validates plugin TOML against the same shape src/config.rs uses)"
            )
        });
        plugins.push((name.clone(), parsed, body));
    }

    if let Err(msg) = check_referential_integrity(&plugins) {
        panic!(
            "plugin catalog failed referential integrity: {msg}\n\
             every relation must point at a source defined by some plugin file"
        );
    }

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

    for (name, _parsed, body) in &plugins {
        buf.push_str(&format!("# ---- plugin: {name} ----\n\n"));
        buf.push_str(body);
        if !body.ends_with('\n') {
            buf.push('\n');
        }
        buf.push('\n');
    }

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

/// Write `contents` to `path` only when the existing file differs.
/// Cargo re-runs the build script anyway, but this keeps the
/// generated file's mtime stable across no-op rebuilds (so
/// downstream incremental compilation does not invalidate).
fn write_if_changed(path: &Path, contents: &str) -> std::io::Result<()> {
    if let Ok(existing) = fs::read_to_string(path) {
        if existing == contents {
            return Ok(());
        }
    }
    fs::write(path, contents)
}

/// `plugins/_index.toml` shape.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct IndexFile {
    catalog_version: u32,
    plugins: Vec<String>,
}

/// Per-plugin TOML shape — mirrors `src/config.rs` enough to
/// catch typos and unknown fields at build time. Intentional
/// duplication; see module docs.
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct PluginFile {
    #[serde(default, rename = "source")]
    sources: BTreeMap<String, PluginSourceDef>,
    #[serde(default, rename = "relation")]
    relations: Vec<PluginRelation>,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct PluginSourceDef {
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    windows: Option<String>,
    #[serde(default)]
    macos: Option<String>,
    #[serde(default)]
    linux: Option<String>,
    #[serde(default)]
    termux: Option<String>,
    #[serde(default)]
    unix: Option<String>,
    #[serde(default)]
    uninstall_command: Option<String>,
}

#[derive(Deserialize, Debug)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum PluginRelation {
    AliasOf {
        parent: String,
        children: Vec<String>,
    },
    ConflictsWhenBothInPath {
        sources: Vec<String>,
        diagnostic: String,
    },
    ServedByVia {
        host: String,
        guest_pattern: String,
        guest_provider: String,
        #[serde(default)]
        installer_token: Option<String>,
    },
    DependsOn {
        source: String,
        target: String,
    },
    PreferOrderOver {
        earlier: String,
        later: String,
    },
}

/// Build-time referential-integrity check: every relation field
/// that names a source must refer to a source actually defined
/// by some plugin file. A typo'd `host = "mise_install"` (missing
/// `s`) gets caught here instead of failing silently at runtime.
///
/// Pure: `plugins` is the deserialized catalog; the only output
/// is a `Result<(), String>` describing the first violation found.
fn check_referential_integrity(plugins: &[(String, PluginFile, String)]) -> Result<(), String> {
    let mut all_sources: BTreeSet<&str> = BTreeSet::new();
    for (_name, plugin, _body) in plugins {
        for src_name in plugin.sources.keys() {
            all_sources.insert(src_name.as_str());
        }
    }

    for (plugin_name, plugin, _body) in plugins {
        for rel in &plugin.relations {
            let referenced = relation_source_refs(rel);
            for r in referenced {
                if !all_sources.contains(r) {
                    return Err(format!(
                        "{plugin_name}.toml: relation references undefined source `{r}`"
                    ));
                }
            }
        }
    }

    Ok(())
}

/// Every source name that a relation references. Pure helper for
/// `check_referential_integrity`. Returning &str ties the lifetime
/// to the relation; the caller uses the values immediately, no
/// allocation needed.
fn relation_source_refs(rel: &PluginRelation) -> Vec<&str> {
    match rel {
        PluginRelation::AliasOf { parent, children } => {
            let mut out = vec![parent.as_str()];
            out.extend(children.iter().map(|c| c.as_str()));
            out
        }
        PluginRelation::ConflictsWhenBothInPath { sources, .. } => {
            sources.iter().map(|s| s.as_str()).collect()
        }
        PluginRelation::ServedByVia {
            host,
            guest_provider,
            ..
        } => vec![host.as_str(), guest_provider.as_str()],
        PluginRelation::DependsOn { source, target } => vec![source.as_str(), target.as_str()],
        PluginRelation::PreferOrderOver { earlier, later } => {
            vec![earlier.as_str(), later.as_str()]
        }
    }
}

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

    fn parse_plugin(text: &str) -> Result<PluginFile, toml::de::Error> {
        toml::from_str(text)
    }

    #[test]
    fn empty_plugin_parses_to_empty_collections() {
        let p = parse_plugin("").unwrap();
        assert!(p.sources.is_empty());
        assert!(p.relations.is_empty());
    }

    #[test]
    fn shape_check_rejects_unknown_top_level_field() {
        let err = parse_plugin("unknown_top = 1").unwrap_err();
        assert!(
            err.to_string().contains("unknown"),
            "expected unknown-field error, got: {err}"
        );
    }

    #[test]
    fn shape_check_rejects_unknown_source_field() {
        let err = parse_plugin(
            r#"
[source.x]
unix = "/foo/bin"
typo_field = "oops"
"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("typo_field"),
            "expected unknown-field error, got: {err}"
        );
    }

    #[test]
    fn shape_check_rejects_unknown_relation_kind() {
        let err = parse_plugin(
            r#"
[[relation]]
kind = "made_up_kind"
foo = "bar"
"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("made_up_kind"),
            "expected unknown-variant error, got: {err}"
        );
    }

    #[test]
    fn referential_integrity_passes_when_every_relation_target_exists() {
        let plugin = parse_plugin(
            r#"
[source.host]
unix = "/h"

[source.guest]
unix = "/g"

[[relation]]
kind = "served_by_via"
host = "host"
guest_pattern = "x-*"
guest_provider = "guest"
"#,
        )
        .unwrap();
        let plugins = vec![("p".to_string(), plugin, String::new())];
        assert!(check_referential_integrity(&plugins).is_ok());
    }

    #[test]
    fn referential_integrity_rejects_dangling_relation_target() {
        let plugin = parse_plugin(
            r#"
[source.host]
unix = "/h"

[[relation]]
kind = "served_by_via"
host = "host"
guest_pattern = "x-*"
guest_provider = "missing_guest"
"#,
        )
        .unwrap();
        let plugins = vec![("p".to_string(), plugin, String::new())];
        let err = check_referential_integrity(&plugins).unwrap_err();
        assert!(
            err.contains("missing_guest"),
            "expected the dangling source name in the error: {err}"
        );
    }

    #[test]
    fn referential_integrity_sees_sources_across_plugins() {
        // Plugin A defines the host, plugin B uses it via a
        // relation. The integrity check pools every plugin's
        // sources before validating relations.
        let plugin_a = parse_plugin(
            r#"
[source.host]
unix = "/h"
"#,
        )
        .unwrap();
        let plugin_b = parse_plugin(
            r#"
[source.guest]
unix = "/g"

[[relation]]
kind = "served_by_via"
host = "host"
guest_pattern = "x-*"
guest_provider = "guest"
"#,
        )
        .unwrap();
        let plugins = vec![
            ("a".to_string(), plugin_a, String::new()),
            ("b".to_string(), plugin_b, String::new()),
        ];
        assert!(check_referential_integrity(&plugins).is_ok());
    }
}