knowledge-base-crud 0.2.0

Filesystem CRUD operations for a file-based knowledge base
Documentation
use crate::Error;
use knowledge_base_models::{Entity, PropertyId, Qualifier, ReferenceId, Statement, StatementId, Value};
use serde::Serialize;
use std::path::Path;
use std::str::FromStr;
use yaml_edit::YamlFile;

pub(super) fn append_statements(source: &str, statements: &[Statement], path: &Path) -> Result<String, Error> {
    let yaml = YamlFile::from_str(source).map_err(|error| Error::Edit {
        path: path.to_path_buf(),
        message: error.to_string(),
    })?;
    let document = yaml.document().ok_or_else(|| Error::Edit {
        path: path.to_path_buf(),
        message: "expected exactly one YAML document".to_owned(),
    })?;
    let sequence = document.get_sequence("statements").ok_or_else(|| Error::Edit {
        path: path.to_path_buf(),
        message: "statements must be a YAML sequence".to_owned(),
    })?;
    let range = sequence.byte_range();
    let start = range.start as usize;
    let end = range.end as usize;
    let existing = &source[start..end];
    let line_start = source[..start].rfind('\n').map_or(0, |offset| offset + 1);
    let leading = &source[line_start..start];
    let mapping_indent = leading.chars().take_while(|character| character.is_whitespace()).collect::<String>();
    let entry_indent = if existing.trim() == "[]" { format!("{mapping_indent}  ") } else { leading.to_owned() };
    let additions = render_statement_entries(statements, &entry_indent, path)?;

    let mut replacement = String::with_capacity(source.len() + additions.len() + 1);
    replacement.push_str(&source[..start]);
    if existing.trim() == "[]" {
        replacement.push('\n');
        replacement.push_str(&additions);
        replacement.push_str(&source[end..]);
    } else if existing.trim_start().starts_with('[') {
        return Err(Error::Edit {
            path: path.to_path_buf(),
            message: "non-empty flow-style statements sequences cannot be extended without reformatting".to_owned(),
        });
    } else {
        replacement.push_str(existing);
        if !existing.ends_with('\n') {
            replacement.push('\n');
        }
        replacement.push_str(&additions);
        replacement.push_str(&source[end..]);
    }
    serde_yaml::from_str::<Entity>(&replacement).map_err(|source| Error::ParseEntity { path: path.to_path_buf(), source })?;
    Ok(replacement)
}

#[derive(Serialize)]
struct StoredStatement<'a> {
    id: &'a StatementId,
    property: &'a PropertyId,
    value: &'a Value,
    #[serde(skip_serializing_if = "slice_is_empty")]
    qualifiers: &'a [Qualifier],
    references: &'a [ReferenceId],
}

fn slice_is_empty<T>(items: &[T]) -> bool {
    items.is_empty()
}

fn render_statement_entries(statements: &[Statement], indentation: &str, path: &Path) -> Result<String, Error> {
    let mut output = String::new();
    for statement in statements {
        let stored = StoredStatement {
            id: &statement.id,
            property: &statement.property,
            value: &statement.value,
            qualifiers: &statement.qualifiers,
            references: &statement.references,
        };
        let generated = serde_yaml::to_string(&stored).map_err(|error| Error::Edit {
            path: path.to_path_buf(),
            message: format!("cannot serialize statement: {error}"),
        })?;
        for (line_index, line) in generated.trim_end_matches('\n').lines().enumerate() {
            output.push_str(indentation);
            output.push_str(if line_index == 0 { "- " } else { "  " });
            output.push_str(line);
            output.push('\n');
        }
    }
    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::append_statements;
    use knowledge_base_models::{PropertyId, Qualifier, ReferenceId, Statement, StatementId, Value};
    use std::path::Path;

    fn statement(id: &str, value: i64) -> Statement {
        Statement {
            id: id.parse::<StatementId>().unwrap(),
            property: "P1".parse::<PropertyId>().unwrap(),
            value: Value::Integer { value },
            qualifiers: Vec::new(),
            references: vec!["R1".parse::<ReferenceId>().unwrap()],
        }
    }

    #[test]
    fn lossless_append_preserves_existing_text() {
        let source = "id: Q1\n# keep this comment\nlabels: {}\nentity_types: []\nstatements:\n  - id: S1 # keep inline\n    property: P1\n    value: { type: integer, value: 1 }\n    references: [R1]\n";
        let replacement = append_statements(source, &[statement("S2", 2)], Path::new("Q1.yaml")).unwrap();

        assert!(replacement.starts_with(source));
        assert!(replacement.contains("  - id: S2\n    property: P1\n"));
        assert!(replacement.contains("# keep this comment"));
        assert!(replacement.contains("id: S1 # keep inline"));
    }

    #[test]
    fn empty_inline_sequence_is_changed_only_at_the_statements_value() {
        let source = "id: Q1\nlabels: {}\nentity_types: []\nstatements: []\n";
        let replacement = append_statements(source, &[statement("S1", 2)], Path::new("Q1.yaml")).unwrap();

        assert!(replacement.starts_with("id: Q1\nlabels: {}\nentity_types: []\nstatements: \n  - id: S1\n"));
    }

    #[test]
    fn appended_qualifiers_are_serialized_in_manifest_order() {
        let source = "id: Q1\nlabels: {}\nentity_types: []\nstatements: []\n";
        let mut appended = statement("S1", 2);
        appended.qualifiers = vec![
            Qualifier {
                property: "P3".parse().unwrap(),
                value: Value::String { value: "first".to_owned() },
            },
            Qualifier {
                property: "P2".parse().unwrap(),
                value: Value::Date { value: "2024-01-01".to_owned() },
            },
        ];

        let replacement = append_statements(source, &[appended], Path::new("Q1.yaml")).unwrap();

        let first = replacement.find("property: P3").unwrap();
        let second = replacement.find("property: P2").unwrap();
        assert!(replacement.contains("qualifiers:"));
        assert!(first < second);
    }
}