deps-core 1.0.1

Core abstractions for deps-lsp: caching, errors, and traits
Documentation
//! Version requirement matching abstractions.
//!
//! Provides traits and implementations for version requirement matching
//! across different package ecosystems (semver, PEP 440, etc.).

use crate::ConcreteVersion;
use semver::Version;
use std::borrow::Cow;

/// Generic version requirement matcher.
///
/// Each ecosystem implements this to provide version matching logic.
/// Used by handlers to determine if a dependency is up-to-date.
pub trait VersionRequirementMatcher: Send + Sync {
    /// Check if the latest available version satisfies the requirement.
    ///
    /// Returns true if the dependency is "up to date" within its constraint.
    ///
    /// # Examples
    ///
    /// For Cargo/npm (semver):
    /// - `"^1.0.0"` with latest `"1.5.0"` → true (satisfies ^1.0.0)
    /// - `"^1.0.0"` with latest `"2.0.0"` → false (new major version)
    ///
    /// For PyPI (PEP 440):
    /// - `">=8.0"` with latest `"8.3.5"` → true (same major version)
    /// - `">=8.0"` with latest `"9.0.0"` → false (new major version)
    fn is_latest_satisfying(&self, requirement: &ConcreteVersion, latest: &ConcreteVersion)
    -> bool;
}

/// Semver-based version matcher for Cargo and npm.
///
/// Uses the semver crate to match version requirements.
/// Handles caret (^) and tilde (~) requirements according to semver semantics.
#[derive(Debug, Clone, Copy)]
pub struct SemverMatcher;

impl VersionRequirementMatcher for SemverMatcher {
    fn is_latest_satisfying(
        &self,
        requirement: &ConcreteVersion,
        latest: &ConcreteVersion,
    ) -> bool {
        use semver::VersionReq;

        let requirement = requirement.as_str();
        let latest = latest.as_str();

        let latest_ver = match latest.parse::<Version>() {
            Ok(v) => v,
            Err(_) => return requirement == latest,
        };

        if let Ok(req) = requirement.parse::<VersionReq>() {
            return req.matches(&latest_ver);
        }

        // Cargo's default convention: "1.0" means "^1.0"
        if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
            return req.matches(&latest_ver);
        }

        requirement == latest
    }
}

/// PEP 440 version matcher for PyPI dependencies.
///
/// Implements major version comparison strategy:
/// - For versions >= 1.0: compares major version only
/// - For versions 0.x: compares major and minor version
///
/// This matches the typical Python ecosystem convention where breaking
/// changes happen on major version bumps (or minor bumps for 0.x versions).
#[derive(Debug, Clone, Copy)]
pub struct Pep440Matcher;

impl VersionRequirementMatcher for Pep440Matcher {
    fn is_latest_satisfying(
        &self,
        requirement: &ConcreteVersion,
        latest: &ConcreteVersion,
    ) -> bool {
        let requirement = requirement.as_str();
        let latest = latest.as_str();

        let latest_ver = match normalize_and_parse_version(latest) {
            Some(v) => v,
            None => return requirement == latest,
        };

        let min_version = extract_pypi_min_version(requirement);

        let min_ver = match min_version.and_then(|v| normalize_and_parse_version(&v)) {
            Some(v) => v,
            None => return requirement == latest,
        };

        if min_ver.major == 0 {
            min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
        } else {
            min_ver.major == latest_ver.major
        }
    }
}

/// Normalize a version string and parse it as semver.
///
/// Adds missing patch version if needed (e.g., "8.0" → "8.0.0").
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::normalize_and_parse_version;
/// assert_eq!(normalize_and_parse_version("1.0.0").unwrap().to_string(), "1.0.0");
/// assert_eq!(normalize_and_parse_version("1.0").unwrap().to_string(), "1.0.0");
/// assert_eq!(normalize_and_parse_version("8").unwrap().to_string(), "8.0.0");
/// ```
pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
    if let Ok(v) = version.parse::<Version>() {
        return Some(v);
    }

    let dot_count = version.chars().filter(|&c| c == '.').count();

    let normalized = match dot_count {
        0 => format!("{}.0.0", version),
        1 => format!("{}.0", version),
        _ => version.to_string(),
    };

    normalized.parse::<Version>().ok()
}

/// Extract the minimum version number from a PEP 440 version specifier.
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::extract_pypi_min_version;
/// assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
/// assert_eq!(extract_pypi_min_version(">=1.0,<2.0"), Some("1.0".to_string()));
/// assert_eq!(extract_pypi_min_version("~=1.4.2"), Some("1.4.2".to_string()));
/// assert_eq!(extract_pypi_min_version("==2.0.0"), Some("2.0.0".to_string()));
/// ```
pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
    for part in version_req.split(',') {
        let trimmed = part.trim();

        if let Some(ver) = trimmed.strip_prefix(">=") {
            return Some(ver.trim().to_string());
        }
        if let Some(ver) = trimmed.strip_prefix("~=") {
            return Some(ver.trim().to_string());
        }
        if let Some(ver) = trimmed.strip_prefix("==") {
            return Some(ver.trim().to_string());
        }
        if let Some(ver) = trimmed.strip_prefix('>') {
            // Treated as an approximation of ">=", not strict-greater-than.
            return Some(ver.trim().to_string());
        }
    }

    // Poetry's "^1.0" style: strip the caret and parse the rest as a version.
    let stripped = version_req.trim_start_matches('^').trim_start_matches('~');
    if stripped.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        return Some(stripped.to_string());
    }

    None
}

/// Collapses whitespace between a range operator (`>=`, `<=`, `>`, `<`) and its version
/// number, e.g. `">= 1.0 < 2.0"` becomes `">=1.0 <2.0"`.
///
/// Several ecosystem requirement grammars (Dart's pubspec constraints, Composer's version
/// constraints) accept a space after a range operator, but the ecosystem's own AND-splitting
/// logic (splitting a requirement on whitespace to get individual clauses) would otherwise
/// treat the operator and its version as separate clauses.
///
/// Borrows `requirement` unchanged when there is no spaced operator to collapse (the common
/// case) instead of always allocating — callers that check many candidate versions against
/// the same requirement should normalize once and reuse the result rather than re-normalizing
/// per candidate.
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::normalize_operator_spacing;
/// assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
/// assert_eq!(normalize_operator_spacing(">=1.0 <2.0"), ">=1.0 <2.0");
/// ```
pub fn normalize_operator_spacing(requirement: &str) -> Cow<'_, str> {
    if !has_spaced_operator(requirement) {
        return Cow::Borrowed(requirement);
    }

    let mut result = String::with_capacity(requirement.len());
    let mut chars = requirement.chars().peekable();
    while let Some(c) = chars.next() {
        result.push(c);
        if c == '>' || c == '<' {
            if chars.peek() == Some(&'=') {
                result.push('=');
                chars.next();
            }
            while chars.peek().is_some_and(|ws| ws.is_whitespace()) {
                chars.next();
            }
        }
    }
    Cow::Owned(result)
}

/// Reports whether `requirement` contains a `>`/`<`/`>=`/`<=` operator immediately followed
/// by whitespace, i.e. whether [`normalize_operator_spacing`] would need to allocate. Pure
/// scan, no allocation, so the common no-op case stays cheap.
fn has_spaced_operator(requirement: &str) -> bool {
    let mut chars = requirement.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '>' || c == '<' {
            if chars.peek() == Some(&'=') {
                chars.next();
            }
            if chars.peek().is_some_and(|ws| ws.is_whitespace()) {
                return true;
            }
        }
    }
    false
}

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

    use std::assert_matches;

    #[test]
    fn test_semver_matcher_exact_match() {
        let matcher = SemverMatcher;
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("1.0.0"),
            &ConcreteVersion::new("1.0.0")
        ));
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("^1.0.0"),
            &ConcreteVersion::new("1.0.0")
        ));
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("~1.0.0"),
            &ConcreteVersion::new("1.0.0")
        ));
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("=1.0.0"),
            &ConcreteVersion::new("1.0.0")
        ));
    }

    #[test]
    fn test_semver_matcher_compatible_versions() {
        let matcher = SemverMatcher;
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("1.0.0"),
            &ConcreteVersion::new("1.0.5")
        )); // ^1.0.0 allows 1.0.5
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("^1.0.0"),
            &ConcreteVersion::new("1.5.0")
        )); // ^1.0.0 allows 1.5.0
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("0.1"),
            &ConcreteVersion::new("0.1.83")
        )); // ^0.1 allows 0.1.83
        assert!(
            matcher
                .is_latest_satisfying(&ConcreteVersion::new("1"), &ConcreteVersion::new("1.5.0"))
        ); // ^1 allows 1.5.0
    }

    #[test]
    fn test_semver_matcher_incompatible_versions() {
        let matcher = SemverMatcher;
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new("1.0.0"),
            &ConcreteVersion::new("2.0.0")
        )); // 2.0.0 breaks ^1.0.0
        assert!(
            !matcher
                .is_latest_satisfying(&ConcreteVersion::new("0.1"), &ConcreteVersion::new("0.2.0"))
        ); // 0.2.0 breaks ^0.1
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new("~1.0.0"),
            &ConcreteVersion::new("1.1.0")
        )); // ~1.0.0 doesn't allow 1.1.0
    }

    #[test]
    fn test_pep440_matcher_same_major() {
        let matcher = Pep440Matcher;
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=8.0"),
            &ConcreteVersion::new("8.3.5")
        )); // 8.x matches 8.x
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0"),
            &ConcreteVersion::new("1.5.0")
        )); // 1.x matches 1.x
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0,<2.0"),
            &ConcreteVersion::new("1.9.0")
        )); // constrained but same major
    }

    #[test]
    fn test_pep440_matcher_new_major() {
        let matcher = Pep440Matcher;
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=8.0"),
            &ConcreteVersion::new("9.0.2")
        )); // 8.x vs 9.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0"),
            &ConcreteVersion::new("2.0.0")
        )); // 1.x vs 2.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=4.0,<8.0"),
            &ConcreteVersion::new("8.0.0")
        )); // 4.x vs 8.x
    }

    #[test]
    fn test_pep440_matcher_zero_version() {
        let matcher = Pep440Matcher;
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=0.8"),
            &ConcreteVersion::new("0.8.5")
        )); // 0.8.x matches 0.8.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=0.8"),
            &ConcreteVersion::new("0.9.0")
        )); // 0.8.x vs 0.9.x
    }

    #[test]
    fn test_extract_pypi_min_version() {
        assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
        assert_eq!(
            extract_pypi_min_version(">=1.0,<2.0"),
            Some("1.0".to_string())
        );
        assert_eq!(
            extract_pypi_min_version("~=1.4.2"),
            Some("1.4.2".to_string())
        );
        assert_eq!(
            extract_pypi_min_version("==2.0.0"),
            Some("2.0.0".to_string())
        );
        assert_eq!(extract_pypi_min_version("^1.0"), Some("1.0".to_string())); // Poetry style
        assert_eq!(extract_pypi_min_version(">1.0"), Some("1.0".to_string()));
    }

    #[test]
    fn test_normalize_operator_spacing_collapses_spaced_operators() {
        assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
        assert_eq!(normalize_operator_spacing("> 1.0"), ">1.0");
        assert_eq!(normalize_operator_spacing("<= 1.0"), "<=1.0");
    }

    #[test]
    fn test_normalize_operator_spacing_borrows_when_no_spaced_operator() {
        let input = ">=1.0 <2.0";
        assert_matches!(
            normalize_operator_spacing(input),
            Cow::Borrowed(s) if s == input
        );
    }

    #[test]
    fn test_normalize_and_parse_version() {
        assert_eq!(
            normalize_and_parse_version("1.0.0").unwrap().to_string(),
            "1.0.0"
        );
        assert_eq!(
            normalize_and_parse_version("1.0").unwrap().to_string(),
            "1.0.0"
        );
        assert_eq!(
            normalize_and_parse_version("8").unwrap().to_string(),
            "8.0.0"
        );
        assert!(normalize_and_parse_version("invalid").is_none());
    }
}