axiomsync 1.0.1

Local retrieval runtime and CLI for AxiomSync.
Documentation
use std::collections::HashSet;

use crate::error::{AxiomError, Result};
use crate::fs::LocalContextFs;
use crate::models::RelationLink;
use crate::uri::AxiomUri;

const RELATIONS_FILE_NAME: &str = ".relations.json";

fn relations_uri(owner: &AxiomUri) -> Result<AxiomUri> {
    owner.join(RELATIONS_FILE_NAME)
}

fn ensure_owner_is_directory(fs: &LocalContextFs, owner: &AxiomUri) -> Result<()> {
    let base = fs.resolve_uri(owner);
    if base.exists() && !base.is_dir() {
        return Err(AxiomError::Validation(format!(
            "relations owner must be directory: {owner}"
        )));
    }
    Ok(())
}

pub(crate) fn read_relations(fs: &LocalContextFs, owner: &AxiomUri) -> Result<Vec<RelationLink>> {
    ensure_owner_is_directory(fs, owner)?;
    let relation_uri = relations_uri(owner)?;
    if !fs.exists(&relation_uri) {
        return Ok(Vec::new());
    }
    let raw = fs.read(&relation_uri)?;
    let relations = serde_json::from_str::<Vec<RelationLink>>(&raw)
        .map_err(|err| AxiomError::Validation(format!("invalid relations schema: {err}")))?;
    validate_relations(&relations)?;
    Ok(relations)
}

pub(crate) fn write_relations(
    fs: &LocalContextFs,
    owner: &AxiomUri,
    relations: &[RelationLink],
    system: bool,
) -> Result<()> {
    ensure_owner_is_directory(fs, owner)?;
    validate_relations(relations)?;
    let mut canonical = relations.to_vec();
    canonical.sort_by(|a, b| a.id.cmp(&b.id));
    let payload = serde_json::to_string_pretty(&canonical)
        .map_err(|err| AxiomError::Validation(format!("invalid relations payload: {err}")))?;
    let relation_uri = relations_uri(owner)?;
    fs.write(&relation_uri, &payload, system)
}

pub(crate) fn validate_relations(relations: &[RelationLink]) -> Result<()> {
    let mut ids = HashSet::<String>::new();
    for relation in relations {
        let id = relation.id.trim();
        if id.is_empty() {
            return Err(AxiomError::Validation(
                "relation id must not be empty".to_string(),
            ));
        }
        if !ids.insert(id.to_string()) {
            return Err(AxiomError::Validation(format!(
                "duplicate relation id: {id}"
            )));
        }

        if relation.reason.trim().is_empty() {
            return Err(AxiomError::Validation(format!(
                "relation reason must not be empty: {id}"
            )));
        }

        if relation.uris.len() < 2 {
            return Err(AxiomError::Validation(format!(
                "relation must include at least 2 uris: {id}"
            )));
        }

        let mut unique_uris = HashSet::<String>::new();
        for uri in &relation.uris {
            let parsed = AxiomUri::parse(uri).map_err(|err| {
                AxiomError::Validation(format!("invalid relation uri in {id}: {err}"))
            })?;
            if !unique_uris.insert(parsed.to_string()) {
                return Err(AxiomError::Validation(format!(
                    "duplicate uri in relation {id}: {uri}"
                )));
            }
        }
    }
    Ok(())
}