knowledge-base-crud 0.2.0

Filesystem CRUD operations for a file-based knowledge base
Documentation
mod apply;
mod edit;
mod planner;

use crate::Error;
use knowledge_base_models::{EntityId, PropertyId, Qualifier, ReferenceId, StatementId, Value};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApplyMode {
    Preview,
    Commit,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatementBatch {
    pub statements: Vec<StatementInput>,
}

impl StatementBatch {
    pub fn read(path: impl AsRef<Path>) -> Result<Self, Error> {
        let path = path.as_ref();
        let source = fs::read_to_string(path).map_err(|source| Error::Read { path: path.to_path_buf(), source })?;
        serde_yaml::from_str(&source).map_err(|source| Error::ParseStatementBatch { path: path.to_path_buf(), source })
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatementInput {
    pub entity: EntityId,
    pub property: PropertyId,
    pub value: Value,
    #[serde(default)]
    pub qualifiers: Vec<Qualifier>,
    pub references: Vec<ReferenceId>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StatementResultStatus {
    WouldAdd,
    Added,
    AlreadyPresent,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct StatementResult {
    pub index: usize,
    pub entity: EntityId,
    pub property: PropertyId,
    pub statement: StatementId,
    pub status: StatementResultStatus,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(tag = "outcome", content = "results", rename_all = "snake_case")]
pub enum ApplyStatementsOutcome {
    Previewed(Vec<StatementResult>),
    Applied(Vec<StatementResult>),
    NotApplied(Vec<StatementResult>),
}

impl ApplyStatementsOutcome {
    pub fn results(&self) -> &[StatementResult] {
        match self {
            Self::Previewed(results) | Self::Applied(results) | Self::NotApplied(results) => results,
        }
    }

    pub fn was_applied(&self) -> bool {
        matches!(self, Self::Applied(_))
    }

    pub fn was_rejected(&self) -> bool {
        matches!(self, Self::NotApplied(_))
    }
}

fn validate_batch(batch: &StatementBatch) -> Result<(), Error> {
    if batch.statements.is_empty() {
        return Err(Error::InvalidRequest("statements must not be empty".to_owned()));
    }
    for (offset, statement) in batch.statements.iter().enumerate() {
        if statement.references.is_empty() {
            return Err(Error::InvalidRequest(format!("statements[{}].references must not be empty", offset + 1)));
        }
        for (qualifier_offset, qualifier) in statement.qualifiers.iter().enumerate() {
            if statement.qualifiers[..qualifier_offset].contains(qualifier) {
                return Err(Error::InvalidRequest(format!(
                    "statements[{}].qualifiers contains duplicate property/value entry",
                    offset + 1
                )));
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{StatementBatch, validate_batch};

    fn parses(value: &str) -> bool {
        serde_yaml::from_str::<StatementBatch>(value).is_ok()
    }

    #[test]
    fn statement_batches_are_strict() {
        let valid = r#"
statements:
  - entity: Q1
    property: P2
    value: { type: string, value: Q99 }
    references: [R3]
"#;
        assert!(parses(valid));

        for invalid in [
            valid.replace("statements:\n", "statements:\nunknown: true\n"),
            valid.replace("    property: P2\n", "    property: P2\n    property: P3\n"),
            valid.replace("entity: Q1", "entity: P1"),
            valid.replace("type: string", "type: unsupported"),
        ] {
            assert!(!parses(&invalid), "invalid batch unexpectedly parsed:\n{invalid}");
        }

        assert!(parses(&valid.replace("    references: [R3]\n", "    qualifiers: []\n    references: [R3]\n")));
    }

    #[test]
    fn statement_batches_reject_duplicate_qualifiers() {
        let source = r#"
statements:
  - entity: Q1
    property: P1
    value: { type: integer, value: 7 }
    qualifiers:
      - property: P2
        value: { type: date, value: "2024-01-01" }
      - property: P2
        value: { type: date, value: "2024-01-01" }
    references: [R1]
"#;
        let batch: StatementBatch = serde_yaml::from_str(source).expect("manifest parsing is separate from request validation");
        assert!(validate_batch(&batch).is_err());
    }
}