composer-semver 0.2.0

A faithful Rust port of composer/semver: Composer-flavored Version, Constraint, and Stability — normalize, compare, and match version constraints.
Documentation
//! Layer 1 conformance harness — runs the ported composer/semver
//! data-provider cases against composer-semver's implementation.
//!
//! The fixture file (`tests/data/conformance.json`) was generated
//! from `composer/semver` (the pinned commit referenced in
//! `version.rs`) and is committed so the pass/fail signal stays loud
//! and locatable as the implementation evolves.
//!
//! Run with: `cargo test -p composer-semver`.

// Test-harness ergonomics: the data-driven dispatch over fixture suites is one
// long flat function with `()`-returning match arms. Not worth contorting for
// pedantic style in non-shipping code.
#![allow(clippy::too_many_lines, clippy::semicolon_if_nothing_returned)]

use composer_semver::Stability;
use composer_semver::constraint::Constraint;
use composer_semver::version::{CmpOp, Version};
use serde::Deserialize;

const FIXTURE: &str = include_str!("data/conformance.json");

#[derive(Debug, Deserialize)]
struct Fixture {
    source: Source,
    suites: Vec<Suite>,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Source {
    repo: String,
    commit: String,
}

#[derive(Debug, Deserialize)]
struct Suite {
    class: String,
    method: String,
    cases: Vec<Vec<serde_json::Value>>,
}

/// Always-on smoke test: the committed fixture parses, and every suite
/// has a non-empty case list. Catches accidental regeneration with a
/// broken upstream checkout or a port-script bug.
#[test]
fn fixture_is_well_formed() {
    let fx: Fixture = serde_json::from_str(FIXTURE).expect("conformance.json is valid JSON");
    assert!(
        !fx.source.commit.is_empty(),
        "fixture must record upstream commit"
    );
    assert!(!fx.suites.is_empty(), "fixture must have suites");
    for s in &fx.suites {
        assert!(
            !s.cases.is_empty(),
            "suite {}::{} is empty — port script regression?",
            s.class,
            s.method
        );
    }
}

/// The full conformance run. Each suite dispatches by `method`
/// name; suites without a dispatch handler are counted in `skipped`
/// (not silently passed). Failures are aggregated so we see the
/// whole picture at once instead of fix-one-rerun.
#[test]
fn upstream_conformance() {
    let fx: Fixture = serde_json::from_str(FIXTURE).unwrap();

    let mut covered = 0usize;
    let mut skipped = 0usize;
    let mut failures: Vec<String> = vec![];

    for suite in &fx.suites {
        let dispatched = dispatch(suite, &mut covered, &mut failures);
        if !dispatched {
            skipped += suite.cases.len();
        }
    }

    if !failures.is_empty() {
        // Cap the output so a wholesale algorithm bug doesn't drown
        // out the diagnostic value.
        let show: Vec<String> = failures.iter().take(40).cloned().collect();
        let extra = failures.len().saturating_sub(show.len());
        panic!(
            "{} conformance failures (covered {covered}, skipped {skipped}){}:\n  {}",
            failures.len(),
            if extra > 0 {
                format!(", showing first {}", show.len())
            } else {
                String::new()
            },
            show.join("\n  ")
        );
    }
    eprintln!("conformance: covered={covered} skipped={skipped}");
}

fn dispatch(suite: &Suite, covered: &mut usize, failures: &mut Vec<String>) -> bool {
    let id = format!("{}::{}", suite.class, suite.method);
    match suite.method.as_str() {
        "isValidVersions" => {
            for case in &suite.cases {
                let input = case[0].as_str().unwrap();
                let expected = case[1].as_bool().unwrap();
                let actual = Version::parse(input).is_ok();
                if actual != expected {
                    failures.push(format!(
                        "{id}: input={input:?} expected={expected} actual={actual}"
                    ));
                }
                *covered += 1;
            }
            true
        }
        "successfulNormalizedVersions" => {
            for case in &suite.cases {
                let input = case[0].as_str().unwrap();
                let expected = case[1].as_str().unwrap();
                match Version::parse(input) {
                    Ok(v) if v.normalized == expected => {}
                    Ok(v) => failures.push(format!(
                        "{id}: input={input:?} expected={expected:?} actual={:?}",
                        v.normalized
                    )),
                    Err(e) => failures.push(format!(
                        "{id}: input={input:?} expected={expected:?} parse_err={e}"
                    )),
                }
                *covered += 1;
            }
            true
        }
        "failingNormalizedVersions" => {
            for case in &suite.cases {
                let input = case[0].as_str().unwrap();
                if let Ok(v) = Version::parse(input) {
                    failures.push(format!(
                        "{id}: input={input:?} should fail but parsed as {:?}",
                        v.normalized
                    ));
                }
                *covered += 1;
            }
            true
        }
        "successfulNormalizedBranches" => {
            for case in &suite.cases {
                let input = case[0].as_str().unwrap();
                let expected = case[1].as_str().unwrap();
                let actual = Version::normalize_branch(input);
                if actual != expected {
                    failures.push(format!(
                        "{id}: input={input:?} expected={expected:?} actual={actual:?}"
                    ));
                }
                *covered += 1;
            }
            true
        }
        "numericAliasVersions" => {
            for case in &suite.cases {
                let input = case[0].as_str().unwrap();
                let actual = Version::parse_numeric_alias_prefix(input);
                let expected: Option<String> = match &case[1] {
                    serde_json::Value::String(s) => Some(s.clone()),
                    serde_json::Value::Bool(false) => None,
                    other => panic!("{id}: unexpected expected value {other:?}"),
                };
                if actual != expected {
                    failures.push(format!(
                        "{id}: input={input:?} expected={expected:?} actual={actual:?}"
                    ));
                }
                *covered += 1;
            }
            true
        }
        "stabilityProvider" => {
            for case in &suite.cases {
                let expected = case[0].as_str().unwrap();
                let input = case[1].as_str().unwrap();
                let actual = Version::parse_stability(input).as_str();
                // Composer's `parseStability` returns lower-case
                // keywords (`rc`/`beta`/...); ours returns the
                // canonical mixed-case (RC for that one). Normalize
                // for the comparison so the suite agrees with us
                // exactly per the fixture (which uses lower-case).
                let actual_lower = actual.to_ascii_lowercase();
                let expected_lower = expected.to_ascii_lowercase();
                if actual_lower != expected_lower {
                    failures.push(format!(
                        "{id}: input={input:?} expected={expected:?} actual={actual:?}"
                    ));
                }
                *covered += 1;
            }
            true
        }
        "compareProvider" => {
            for case in &suite.cases {
                let a = case[0].as_str().unwrap();
                let op = case[1].as_str().unwrap();
                let b = case[2].as_str().unwrap();
                let expected = case[3].as_bool().unwrap();
                let actual = compare_op(a, op, b);
                if actual != expected {
                    failures.push(format!(
                        "{id}: {a:?} {op} {b:?} expected={expected} actual={actual}"
                    ));
                }
                *covered += 1;
            }
            true
        }
        "greaterThanProvider" => binary_op(suite, ">", covered, failures),
        "greaterThanOrEqualToProvider" => binary_op(suite, ">=", covered, failures),
        "lessThanProvider" => binary_op(suite, "<", covered, failures),
        "lessThanOrEqualToProvider" => binary_op(suite, "<=", covered, failures),
        "equalToProvider" => binary_op(suite, "==", covered, failures),
        "notEqualToProvider" => binary_op(suite, "!=", covered, failures),
        "sortProvider" => {
            for case in &suite.cases {
                let input: Vec<String> = case[0]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|v| v.as_str().unwrap().to_owned())
                    .collect();
                let want_asc: Vec<String> = case[1]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|v| v.as_str().unwrap().to_owned())
                    .collect();
                let want_desc: Vec<String> = case[2]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|v| v.as_str().unwrap().to_owned())
                    .collect();
                if let Some(got) = sort_versions(&input, true)
                    && got != want_asc
                {
                    failures.push(format!(
                        "{id}: sort_asc({input:?}) expected={want_asc:?} actual={got:?}"
                    ));
                }
                if let Some(got) = sort_versions(&input, false)
                    && got != want_desc
                {
                    failures.push(format!(
                        "{id}: sort_desc({input:?}) expected={want_desc:?} actual={got:?}"
                    ));
                }
                *covered += 2;
            }
            true
        }
        "satisfiesProviderPositive" => {
            for case in &suite.cases {
                let v = case[0].as_str().unwrap();
                let c = case[1].as_str().unwrap();
                match (Version::parse(v), Constraint::parse(c)) {
                    (Ok(version), Ok(constraint)) => {
                        if !constraint.matches(&version) {
                            failures.push(format!("{id}: {v:?} should satisfy {c:?} but doesn't"));
                        }
                    }
                    (Err(e), _) => {
                        failures.push(format!("{id}: {v:?} {c:?} version parse err: {e}"))
                    }
                    (_, Err(e)) => {
                        failures.push(format!("{id}: {v:?} {c:?} constraint parse err: {e}"))
                    }
                }
                *covered += 1;
            }
            true
        }
        "satisfiesProviderNegative" => {
            for case in &suite.cases {
                let v = case[0].as_str().unwrap();
                let c = case[1].as_str().unwrap();
                match (Version::parse(v), Constraint::parse(c)) {
                    (Ok(version), Ok(constraint)) => {
                        if constraint.matches(&version) {
                            failures.push(format!("{id}: {v:?} should NOT satisfy {c:?} but does"));
                        }
                    }
                    (Err(e), _) => {
                        failures.push(format!("{id}: {v:?} {c:?} version parse err: {e}"))
                    }
                    (_, Err(e)) => {
                        failures.push(format!("{id}: {v:?} {c:?} constraint parse err: {e}"))
                    }
                }
                *covered += 1;
            }
            true
        }
        _ => false,
    }
}

fn binary_op(suite: &Suite, op: &str, covered: &mut usize, failures: &mut Vec<String>) -> bool {
    let id = format!("{}::{}", suite.class, suite.method);
    for case in &suite.cases {
        let a = case[0].as_str().unwrap();
        let b = case[1].as_str().unwrap();
        let expected = case[2].as_bool().unwrap();
        let actual = compare_op(a, op, b);
        if actual != expected {
            failures.push(format!(
                "{id}: {a:?} {op} {b:?} expected={expected} actual={actual}"
            ));
        }
        *covered += 1;
    }
    true
}

fn compare_op(a: &str, op: &str, b: &str) -> bool {
    let (Ok(va), Ok(vb)) = (Version::parse(a), Version::parse(b)) else {
        return false;
    };
    let cmp_op = match op {
        ">" => CmpOp::Gt,
        ">=" => CmpOp::Ge,
        "<" => CmpOp::Lt,
        "<=" => CmpOp::Le,
        "==" | "=" => CmpOp::Eq,
        "!=" | "<>" => CmpOp::Ne,
        _ => panic!("unknown op {op}"),
    };
    va.compare(cmp_op, &vb)
}

fn sort_versions(input: &[String], ascending: bool) -> Option<Vec<String>> {
    // Composer's Semver::sort normalizes default branches to
    // `9999999-dev` so they sort above numerics; we mirror that.
    let mut parsed: Vec<(String, Version)> = Vec::with_capacity(input.len());
    for s in input {
        let v = Version::parse(s).ok()?;
        let n = Version::normalize_default_branch(&v.normalized);
        let v2 = Version::parse(&n).ok()?;
        parsed.push((s.clone(), v2));
    }
    parsed.sort_by(|a, b| {
        if ascending {
            a.1.cmp(&b.1)
        } else {
            b.1.cmp(&a.1)
        }
    });
    Some(parsed.into_iter().map(|(s, _)| s).collect())
}

/// Suppress the unused-`Stability` import warning when `Constraint`
/// has no behavior yet — keeps the imports stable across iterations
/// of this file.
#[allow(dead_code)]
const _STABILITY_KEEP_IMPORT: Option<Stability> = None;