llmlint 0.4.1

LLM-as-judge linter: enforce code-quality checks deterministic linters can't express, by driving real coding harnesses through oneharness.
Documentation
//! Versions for plugin configs: 1 to 3 dot-separated non-negative integers
//! (`1`, `1.1`, `1.1.1`).
//!
//! A config **declares** its own published [`Version`] via the top-level
//! `version` field. A consumer that pulls it in as a plugin can **pin** a
//! desired version with an `@` suffix on the URL (`url@1`, `url@1.1`,
//! `url@1.1.1`); that pin is a [`VersionReq`]. A pin matches by *prefix*: `@1`
//! matches any `1.x.y`, `@1.2` matches any `1.2.x`, and `@1.2.3` matches exactly
//! `1.2.3`. The pin is therefore an assertion the fetched config must satisfy;
//! the *resolved* [`Version`] — never the pin — keys the on-disk plugin cache,
//! so a non-breaking bump is a new entry rather than a hit on the old one (see
//! [`crate::io::plugins`]).

use std::borrow::Cow;
use std::fmt;

use schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};

/// A declared config version: 1–3 numeric components. Ordered by its components
/// (so the newest cached plugin entry satisfying a pin is `max`); a shorter
/// version sorts before a longer one that extends it (`1` < `1.0` < `1.1`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version(Vec<u64>);

/// A requested version pin (the `@` suffix on a plugin URL): 1–3 numeric
/// components, matched against a [`Version`] by prefix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionReq(Vec<u64>);

/// Parse `1`, `1.2`, or `1.2.3` into its components, rejecting anything with
/// more than three parts or a non-integer part.
fn parse_components(s: &str) -> Result<Vec<u64>, String> {
    let s = s.trim();
    if s.is_empty() {
        return Err("version is empty".to_string());
    }
    let parts: Vec<&str> = s.split('.').collect();
    if parts.len() > 3 {
        return Err(format!(
            "version {s:?} has too many components (expected 1 to 3, like 1, 1.2, or 1.2.3)"
        ));
    }
    let mut out = Vec::with_capacity(parts.len());
    for p in parts {
        let n: u64 = p
            .parse()
            .map_err(|_| format!("version {s:?} component {p:?} is not a non-negative integer"))?;
        out.push(n);
    }
    Ok(out)
}

fn join(components: &[u64]) -> String {
    components
        .iter()
        .map(u64::to_string)
        .collect::<Vec<_>>()
        .join(".")
}

impl Version {
    /// Parse a version from its textual form.
    pub fn parse(s: &str) -> Result<Self, String> {
        Ok(Version(parse_components(s)?))
    }

    /// The numeric components, most-significant first.
    pub fn components(&self) -> &[u64] {
        &self.0
    }
}

impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&join(&self.0))
    }
}

impl VersionReq {
    /// Parse a version pin from its textual form.
    pub fn parse(s: &str) -> Result<Self, String> {
        Ok(VersionReq(parse_components(s)?))
    }

    /// Whether `version` satisfies this pin: the pin's components are a prefix
    /// of the version's, so the version must be at least as specific. `@1`
    /// accepts `1`, `1.4`, `1.4.2`; `@1.4.2` accepts only `1.4.2`.
    pub fn matches(&self, version: &Version) -> bool {
        version.0.len() >= self.0.len() && version.0[..self.0.len()] == self.0[..]
    }
}

impl fmt::Display for VersionReq {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&join(&self.0))
    }
}

// A version serializes as a string ("1.2.3") and deserializes from a YAML
// integer (`1`), float (`1.2`), or string (`"1.2.3"`) — so `version: 1`,
// `version: 1.2`, and `version: "1.2.3"` all work in a config. A pin travels the
// same way, so a persisted record (the plugin cache's metadata) can hold the
// parsed types rather than re-validating text every time it is read.
impl Serialize for Version {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for Version {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        Version::parse(&scalar_text(d, "version")?).map_err(de::Error::custom)
    }
}

impl Serialize for VersionReq {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for VersionReq {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        VersionReq::parse(&scalar_text(d, "version pin")?).map_err(de::Error::custom)
    }
}

/// The textual form of a numeric-or-string scalar, for the two impls above.
fn scalar_text<'de, D: Deserializer<'de>>(d: D, what: &str) -> Result<String, D::Error> {
    let value = serde_yaml_ng::Value::deserialize(d)?;
    match &value {
        serde_yaml_ng::Value::Number(n) => Ok(n.to_string()),
        serde_yaml_ng::Value::String(s) => Ok(s.clone()),
        other => Err(de::Error::custom(format!(
            "{what} must be a number or string like 1, 1.2, or \"1.2.3\"; got {other:?}"
        ))),
    }
}

// A version accepts a YAML integer (`1`), float (`1.2`), or string (`"1.2.3"`),
// so the schema admits all three. (The 1–3-component constraint is enforced at
// parse time, not expressible cleanly here.)
impl JsonSchema for Version {
    fn schema_name() -> Cow<'static, str> {
        "Version".into()
    }

    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
        json_schema!({
            "type": ["integer", "number", "string"],
        })
    }
}

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

    #[test]
    fn parses_one_to_three_components() {
        assert_eq!(Version::parse("1").unwrap().components(), &[1]);
        assert_eq!(Version::parse("1.2").unwrap().components(), &[1, 2]);
        assert_eq!(Version::parse(" 1.2.3 ").unwrap().components(), &[1, 2, 3]);
    }

    #[test]
    fn rejects_malformed_versions() {
        assert!(Version::parse("").is_err());
        assert!(Version::parse("1.2.3.4").is_err());
        assert!(Version::parse("1.x").is_err());
        assert!(Version::parse("-1").is_err());
    }

    #[test]
    fn display_roundtrips() {
        assert_eq!(Version::parse("1.2.3").unwrap().to_string(), "1.2.3");
        assert_eq!(VersionReq::parse("1.2").unwrap().to_string(), "1.2");
    }

    #[test]
    fn pin_matches_by_prefix() {
        let req = VersionReq::parse("1").unwrap();
        assert!(req.matches(&Version::parse("1").unwrap()));
        assert!(req.matches(&Version::parse("1.4").unwrap()));
        assert!(req.matches(&Version::parse("1.4.2").unwrap()));
        assert!(!req.matches(&Version::parse("2.0").unwrap()));

        let req = VersionReq::parse("1.4.2").unwrap();
        assert!(req.matches(&Version::parse("1.4.2").unwrap()));
        // A pin more specific than the declared version cannot be satisfied.
        assert!(!req.matches(&Version::parse("1.4").unwrap()));
        assert!(!req.matches(&Version::parse("1.4.3").unwrap()));
    }

    #[test]
    fn versions_order_by_components() {
        let v = |s: &str| Version::parse(s).unwrap();
        // The cache picks the newest entry satisfying a pin, so ordering must
        // put a later minor above an earlier one.
        assert!(v("1.4") > v("1.2"));
        assert!(v("1.10") > v("1.9"));
        assert!(v("2") > v("1.99.99"));
        // A shorter version sorts below a longer one that extends it.
        assert!(v("1") < v("1.0"));
        let mut all = [v("1.2"), v("2.0"), v("1.10")];
        all.sort();
        assert_eq!(all.iter().max().unwrap(), &v("2.0"));
    }

    #[test]
    fn deserializes_from_int_float_and_string() {
        let v: Version = serde_yaml_ng::from_str("1").unwrap();
        assert_eq!(v.to_string(), "1");
        let v: Version = serde_yaml_ng::from_str("1.2").unwrap();
        assert_eq!(v.to_string(), "1.2");
        let v: Version = serde_yaml_ng::from_str("\"1.2.3\"").unwrap();
        assert_eq!(v.to_string(), "1.2.3");
    }

    #[test]
    fn rejects_non_scalar_version() {
        assert!(serde_yaml_ng::from_str::<Version>("[1, 2]").is_err());
    }

    #[test]
    fn serializes_as_string() {
        let v = Version::parse("1.2").unwrap();
        assert_eq!(serde_json::to_string(&v).unwrap(), "\"1.2\"");
        let req = VersionReq::parse("1").unwrap();
        assert_eq!(serde_json::to_string(&req).unwrap(), "\"1\"");
    }

    #[test]
    fn pins_round_trip_through_a_persisted_record() {
        // The plugin cache stores a pin in its metadata, so it must survive a
        // write/read cycle as the parsed type — and malformed text must not.
        let req = VersionReq::parse("1.2").unwrap();
        let json = serde_json::to_string(&req).unwrap();
        assert_eq!(serde_json::from_str::<VersionReq>(&json).unwrap(), req);
        assert_eq!(
            serde_json::from_str::<VersionReq>("1").unwrap(),
            VersionReq::parse("1").unwrap()
        );
        assert!(serde_json::from_str::<VersionReq>("\"1.x\"").is_err());
        assert!(serde_json::from_str::<VersionReq>("[1]").is_err());
        assert!(serde_json::from_str::<Version>("[1]").is_err());
    }
}