knowledge-base-cli 0.3.0

Command-line interface for a file-based knowledge base
Documentation
use super::super::{CommandError, write_content};
use chrono::{DateTime, NaiveDate};
use knowledge_base_crud::{EntityFilter, KnowledgeBaseRepository};
use knowledge_base_models::{EntityId, Property, PropertyId, Value, ValueType};
use std::process::ExitCode;
use url::Url;

pub fn execute(knowledge_base: &KnowledgeBaseRepository, raw_filters: &[String], limit: usize, offset: usize) -> Result<ExitCode, CommandError> {
    let filters = raw_filters.iter().map(|filter| parse_filter(knowledge_base, filter)).collect::<Result<Vec<_>, _>>()?;
    let page = knowledge_base.read().entities().query(&filters, limit, offset)?;
    let output = serde_yaml::to_string(&page).map_err(CommandError::Serialization)?;
    write_content(&output)
}

fn parse_filter(knowledge_base: &KnowledgeBaseRepository, filter: &str) -> Result<EntityFilter, CommandError> {
    let (property, raw_value) = filter
        .split_once('=')
        .ok_or_else(|| CommandError::InvalidFilter(format!("invalid entity filter {filter:?}; expected P<n>=value")))?;
    if raw_value.is_empty() {
        return Err(CommandError::InvalidFilter(format!("invalid entity filter {filter:?}; value must not be empty")));
    }
    let property = property
        .parse::<PropertyId>()
        .map_err(|error| CommandError::InvalidFilter(format!("invalid entity filter {filter:?}: {error}")))?;
    let source = knowledge_base.read().properties().read(&property)?;
    let definition =
        serde_yaml::from_str::<Property>(&source).map_err(|error| CommandError::InvalidFilter(format!("cannot parse property {property} while resolving filter: {error}")))?;
    if definition.id != property {
        return Err(CommandError::InvalidFilter(format!(
            "property file {}.yaml declares identifier {}",
            property, definition.id
        )));
    }
    let value = parse_value(definition.value_type, raw_value).map_err(|message| CommandError::InvalidFilter(format!("invalid value for property {property}: {message}")))?;
    Ok(EntityFilter { property, value })
}

fn parse_value(value_type: ValueType, raw: &str) -> Result<Value, String> {
    match value_type {
        ValueType::Entity => raw.parse::<EntityId>().map(|value| Value::Entity { value }).map_err(|error| error.to_string()),
        ValueType::String => Ok(Value::String { value: raw.to_owned() }),
        ValueType::Integer => raw
            .parse::<i64>()
            .map(|value| Value::Integer { value })
            .map_err(|_| "expected a base-10 integer".to_owned()),
        ValueType::Decimal => canonical_decimal(raw)
            .then(|| Value::Decimal { value: raw.to_owned() })
            .ok_or_else(|| "expected canonical base-10 decimal syntax".to_owned()),
        ValueType::Quantity => {
            let (amount, unit) = raw.split_once(',').ok_or_else(|| "expected amount,unit".to_owned())?;
            if !canonical_decimal(amount) {
                return Err("amount must use canonical base-10 decimal syntax".to_owned());
            }
            if unit.trim().is_empty() {
                return Err("unit must not be empty".to_owned());
            }
            Ok(Value::Quantity {
                amount: amount.to_owned(),
                unit: unit.to_owned(),
            })
        }
        ValueType::Boolean => match raw {
            "true" => Ok(Value::Boolean { value: true }),
            "false" => Ok(Value::Boolean { value: false }),
            _ => Err("expected true or false".to_owned()),
        },
        ValueType::Date if valid_partial_date(raw) => Ok(Value::Date { value: raw.to_owned() }),
        ValueType::Date => Err("expected a real ISO 8601 calendar year, month, or day (YYYY, YYYY-MM, or YYYY-MM-DD)".to_owned()),
        ValueType::Datetime => DateTime::parse_from_rfc3339(raw)
            .map(|_| Value::Datetime { value: raw.to_owned() })
            .map_err(|_| "expected an RFC 3339 timestamp".to_owned()),
        ValueType::Url => Url::parse(raw)
            .map(|_| Value::Url { value: raw.to_owned() })
            .map_err(|_| "expected an absolute URL".to_owned()),
        ValueType::Coordinate => {
            let (latitude, longitude) = raw.split_once(',').ok_or_else(|| "expected latitude,longitude".to_owned())?;
            if !canonical_decimal(latitude) || !within_absolute_bound(latitude, 90) {
                return Err("latitude must be canonical decimal text between -90 and 90".to_owned());
            }
            if !canonical_decimal(longitude) || !within_absolute_bound(longitude, 180) {
                return Err("longitude must be canonical decimal text between -180 and 180".to_owned());
            }
            Ok(Value::Coordinate {
                latitude: latitude.to_owned(),
                longitude: longitude.to_owned(),
                precision: None,
            })
        }
    }
}

fn canonical_decimal(value: &str) -> bool {
    let unsigned = value.strip_prefix('-').unwrap_or(value);
    if unsigned.is_empty() || unsigned.starts_with('+') {
        return false;
    }
    let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
    !integer.is_empty()
        && integer.bytes().all(|byte| byte.is_ascii_digit())
        && (integer == "0" || !integer.starts_with('0'))
        && (!unsigned.contains('.') || (!fraction.is_empty() && fraction.bytes().all(|byte| byte.is_ascii_digit())))
}

fn valid_partial_date(value: &str) -> bool {
    match value.len() {
        4 => value.bytes().all(|byte| byte.is_ascii_digit()),
        7 => value
            .get(..4)
            .zip(value.get(5..))
            .filter(|(year, month)| year.bytes().all(|byte| byte.is_ascii_digit()) && month.bytes().all(|byte| byte.is_ascii_digit()))
            .and_then(|(year, month)| year.parse::<i32>().ok().zip(month.parse::<u32>().ok()))
            .is_some_and(|(year, month)| value.as_bytes().get(4) == Some(&b'-') && NaiveDate::from_ymd_opt(year, month, 1).is_some()),
        10 => NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok(),
        _ => false,
    }
}

fn within_absolute_bound(value: &str, bound: u64) -> bool {
    let unsigned = value.strip_prefix('-').unwrap_or(value);
    let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
    match integer.len().cmp(&bound.to_string().len()) {
        std::cmp::Ordering::Less => true,
        std::cmp::Ordering::Greater => false,
        std::cmp::Ordering::Equal => match integer.parse::<u64>() {
            Ok(integer) if integer < bound => true,
            Ok(integer) if integer == bound => fraction.bytes().all(|byte| byte == b'0'),
            _ => false,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::parse_value;
    use knowledge_base_models::{Value, ValueType};

    #[test]
    fn parses_every_property_value_type() {
        let cases = [
            (ValueType::Entity, "Q43", Value::Entity { value: "Q43".parse().unwrap() }),
            (ValueType::String, "a=b", Value::String { value: "a=b".to_owned() }),
            (ValueType::Integer, "-42", Value::Integer { value: -42 }),
            (ValueType::Decimal, "-0.25", Value::Decimal { value: "-0.25".to_owned() }),
            (
                ValueType::Quantity,
                "12.5,km",
                Value::Quantity {
                    amount: "12.5".to_owned(),
                    unit: "km".to_owned(),
                },
            ),
            (ValueType::Boolean, "true", Value::Boolean { value: true }),
            (ValueType::Date, "2024-02", Value::Date { value: "2024-02".to_owned() }),
            (
                ValueType::Datetime,
                "2024-02-29T12:34:56Z",
                Value::Datetime {
                    value: "2024-02-29T12:34:56Z".to_owned(),
                },
            ),
            (
                ValueType::Url,
                "https://example.com/?a=b",
                Value::Url {
                    value: "https://example.com/?a=b".to_owned(),
                },
            ),
            (
                ValueType::Coordinate,
                "40.1,-29.2",
                Value::Coordinate {
                    latitude: "40.1".to_owned(),
                    longitude: "-29.2".to_owned(),
                    precision: None,
                },
            ),
        ];

        for (value_type, raw, expected) in cases {
            assert_eq!(parse_value(value_type, raw).unwrap(), expected);
        }
    }

    #[test]
    fn rejects_invalid_typed_values() {
        let cases = [
            (ValueType::Entity, "P1"),
            (ValueType::Integer, "1.5"),
            (ValueType::Decimal, "01.2"),
            (ValueType::Quantity, "01.2,km"),
            (ValueType::Quantity, "1.2,   "),
            (ValueType::Boolean, "yes"),
            (ValueType::Date, "2023-02-29"),
            (ValueType::Date, "2024-2-1"),
            (ValueType::Datetime, "yesterday"),
            (ValueType::Url, "relative/path"),
            (ValueType::Coordinate, "91,0"),
            (ValueType::Coordinate, "0,181"),
        ];

        for (value_type, raw) in cases {
            assert!(parse_value(value_type, raw).is_err(), "{value_type:?} accepted {raw:?}");
        }
    }
}