feature-check 2.3.2

Query a program for supported features
Documentation
// SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
// SPDX-License-Identifier: BSD-2-Clause
//! Check whether the program's features satisfy the specified condition.

#![expect(clippy::pub_use, reason = "re-export commonly used symbols")]

use std::cmp::Ordering;
use std::collections::HashMap;
use std::str::FromStr;

use crate::defs::{Mode, ParseError};
use crate::version::Version;

pub mod parser;

pub use crate::defs::{CalcResult, Calculable};

/// The type of a boolean comparison operation requested.
#[derive(Debug)]
enum BoolOpKind {
    /// The first version string sorts before the second one.
    LessThan,
    /// The first version string sorts before the second one or is the same.
    LessThanOrEqual,
    /// The version strings are the same.
    Equal,
    /// The first version string sorts after the second one or is the same.
    GreaterThanOrEqual,
    /// The first version string sorts after the second one.
    GreaterThan,
}

impl BoolOpKind {
    /// The symbol representing the "is less than" comparison operation.
    const LT: &'static str = "<";
    /// The symbol representing the "is less than or equal to" comparison operation.
    const LE: &'static str = "<=";
    /// The symbol representing the "is equal to" comparison operation.
    const EQ: &'static str = "=";
    /// The symbol representing the "is greater than or equal to" comparison operation.
    const GT: &'static str = ">";
    /// The symbol representing the "is greater than" comparison operation.
    const GE: &'static str = ">=";

    /// The string representing the "is less than" comparison operation.
    const LT_S: &'static str = "lt";
    /// The string representing the "is less than or equal to" comparison operation.
    const LE_S: &'static str = "le";
    /// The string representing the "is equal to" comparison operation.
    const EQ_S: &'static str = "eq";
    /// The string representing the "is greater than or equal to" comparison operation.
    const GE_S: &'static str = "ge";
    /// The string representing the "is greater than" comparison operation.
    const GT_S: &'static str = "gt";
}

impl FromStr for BoolOpKind {
    type Err = ParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            Self::LT | Self::LT_S => Ok(Self::LessThan),
            Self::LE | Self::LE_S => Ok(Self::LessThanOrEqual),
            Self::EQ | Self::EQ_S => Ok(Self::Equal),
            Self::GE | Self::GE_S => Ok(Self::GreaterThanOrEqual),
            Self::GT | Self::GT_S => Ok(Self::GreaterThan),
            other => Err(ParseError::InvalidComparisonOperator(other.to_owned())),
        }
    }
}

/// A boolean comparison operation with its arguments.
#[derive(Debug)]
struct BoolOp {
    /// The comparison type.
    op: BoolOpKind,
    /// The left (first) operand.
    left: Box<dyn Calculable + 'static>,
    /// The right (second) operand.
    right: Box<dyn Calculable + 'static>,
}

impl BoolOp {
    /// Construct a boolean operation object with the specified parameters.
    fn new(op: BoolOpKind, left: Box<dyn Calculable>, right: Box<dyn Calculable>) -> Self {
        Self { op, left, right }
    }
}

impl Calculable for BoolOp {
    fn get_value(&self, features: &HashMap<String, Version>) -> Result<CalcResult, ParseError> {
        let left = self.left.get_value(features)?;
        let right = self.right.get_value(features)?;
        if let CalcResult::Version(ver_left) = left {
            if let CalcResult::Version(ver_right) = right {
                let ncomp = ver_left.cmp(&ver_right);
                match self.op {
                    BoolOpKind::LessThan => Ok(CalcResult::Bool(ncomp == Ordering::Less)),
                    BoolOpKind::LessThanOrEqual => Ok(CalcResult::Bool(ncomp != Ordering::Greater)),
                    BoolOpKind::Equal => Ok(CalcResult::Bool(ncomp == Ordering::Equal)),
                    BoolOpKind::GreaterThanOrEqual => Ok(CalcResult::Bool(ncomp != Ordering::Less)),
                    BoolOpKind::GreaterThan => Ok(CalcResult::Bool(ncomp == Ordering::Greater)),
                }
            } else {
                Err(ParseError::CannotCompare(
                    format!("{ver_left:?}"),
                    format!("{right:?}"),
                ))
            }
        } else {
            Err(ParseError::Uncomparable(
                format!("{left:?}"),
                format!("{right:?}"),
            ))
        }
    }
}

/// A feature name as a term in an expression.
#[derive(Debug)]
struct FeatureOp {
    /// The name of the queried feature.
    name: String,
}

impl FeatureOp {
    /// Construct a feature object with the specified name.
    fn new(name: &str) -> Self {
        Self {
            name: name.to_owned(),
        }
    }
}

impl Calculable for FeatureOp {
    fn get_value(&self, features: &HashMap<String, Version>) -> Result<CalcResult, ParseError> {
        Ok(features
            .get(&self.name)
            .map_or(CalcResult::Null, |value| CalcResult::Version(value.clone())))
    }
}

/// A version string used as a term in an expression.
#[derive(Debug)]
struct VersionOp {
    /// The parsed version string.
    value: Version,
}

impl VersionOp {
    /// Construct a version object for the specified [`Version`].
    const fn from_version(version: Version) -> Self {
        Self { value: version }
    }
}

impl Calculable for VersionOp {
    fn get_value(&self, _features: &HashMap<String, Version>) -> Result<CalcResult, ParseError> {
        Ok(CalcResult::Version(self.value.clone()))
    }
}

/// Parse a "feature" or "feature op version" expression for later evaluation.
///
/// Returns either [`Mode::Single`] or [`Mode::Simple`].
///
/// # Errors
///
/// Will return an error if the expression is neither a single feature name nor in
/// the "var op value" format or if an unrecognized comparison operator is specified.
#[inline]
pub fn parse(expr: &str) -> Result<Mode, ParseError> {
    parser::parse_expr(expr)
}

#[cfg(test)]
mod tests {
    #![expect(clippy::panic, reason = "this is a test suite")]
    #![expect(clippy::panic_in_result_fn, reason = "this is a test suite")]
    #![expect(clippy::unwrap_used, reason = "this is a test suite")]
    #![expect(clippy::wildcard_enum_match_arm, reason = "this is a test suite")]

    use std::collections::HashMap;
    use std::error::Error;

    use crate::defs::{CalcResult, Mode};

    #[test]
    fn parse_mode_simple_sign_no_space() -> Result<(), Box<dyn Error>> {
        let mode = super::parse("hello<3.1")?;
        let res = match mode {
            Mode::Simple(res) => res,
            other => panic!("{other:?}"),
        };
        match res.get_value(&HashMap::from([("hello".to_owned(), "2".parse()?)]))? {
            CalcResult::Bool(true) => (),
            other => panic!("{other:?}"),
        }
        match res.get_value(&HashMap::from([("hello".to_owned(), "4".parse()?)]))? {
            CalcResult::Bool(false) => (),
            other => panic!("{other:?}"),
        }
        res.get_value(&HashMap::new()).unwrap_err();
        Ok(())
    }

    #[test]
    fn parse_mode_simple_sign_space() -> Result<(), Box<dyn Error>> {
        let mode = super::parse("hello < 3.1")?;
        let res = match mode {
            Mode::Simple(res) => res,
            other => panic!("{other:?}"),
        };
        match res.get_value(&HashMap::from([("hello".to_owned(), "2".parse()?)]))? {
            CalcResult::Bool(true) => (),
            other => panic!("{other:?}"),
        }
        match res.get_value(&HashMap::from([("hello".to_owned(), "4".parse()?)]))? {
            CalcResult::Bool(false) => (),
            other => panic!("{other:?}"),
        }
        res.get_value(&HashMap::new()).unwrap_err();
        Ok(())
    }

    #[test]
    fn parse_mode_simple_word() -> Result<(), Box<dyn Error>> {
        let mode = super::parse("hello lt 3.1")?;
        let res = match mode {
            Mode::Simple(res) => res,
            other => panic!("{other:?}"),
        };
        match res.get_value(&HashMap::from([("hello".to_owned(), "2".parse()?)]))? {
            CalcResult::Bool(true) => (),
            other => panic!("{other:?}"),
        }
        match res.get_value(&HashMap::from([("hello".to_owned(), "4".parse()?)]))? {
            CalcResult::Bool(false) => (),
            other => panic!("{other:?}"),
        }
        res.get_value(&HashMap::new()).unwrap_err();
        Ok(())
    }

    #[test]
    fn parse_mode_single() -> Result<(), Box<dyn Error>> {
        let mode = super::parse("hello")?;
        let res = match mode {
            Mode::Single(res) => res,
            other => panic!("{other:?}"),
        };
        match res.get_value(&HashMap::from([("hello".to_owned(), "2".parse()?)]))? {
            CalcResult::Version(ver) => assert_eq!(ver.as_ref(), "2"),
            other => panic!("{other:?}"),
        }
        match res.get_value(&HashMap::new())? {
            CalcResult::Null => (),
            other => panic!("{other:?}"),
        }
        Ok(())
    }
}