distributed_cli 4.0.0

The `distributed` CLI for Distributed applications: contracts check/accept, scaffold projects, describe manifests, compile clients, and render schema artifacts. Also a library so other CLIs (e.g. hops) can mount its commands.
Documentation
use std::collections::BTreeMap;

use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};

use super::*;

pub(crate) fn validate_exact_operation_hash(
    operation: &str,
    expected: &str,
    label: &str,
) -> Result<(), ClientCompileError> {
    validate_hash(expected, &format!("{label} operation hash"))?;
    let actual = hash_bytes(operation.as_bytes());
    if actual != expected {
        return Err(ClientCompileError::manifest(
            "client.manifest.operation_hash",
            format!("{label} operation hash mismatch: expected `{expected}`, computed `{actual}`"),
        ));
    }
    Ok(())
}

pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    format!("sha256:{digest:x}")
}

pub(crate) fn validate_hash(value: &str, label: &str) -> Result<(), ClientCompileError> {
    if value.len() != 71
        || !value.starts_with("sha256:")
        || !value[7..]
            .bytes()
            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    {
        return Err(ClientCompileError::manifest(
            "client.manifest.hash",
            format!("{label} must be a lowercase sha256 fingerprint"),
        ));
    }
    Ok(())
}

pub(crate) fn validate_nonempty(value: &str, label: &str) -> Result<(), ClientCompileError> {
    if value.trim().is_empty() {
        return Err(ClientCompileError::manifest(
            "client.manifest.empty",
            format!("{label} must not be empty"),
        ));
    }
    Ok(())
}

pub(crate) fn canonical_json_value(value: JsonValue) -> JsonValue {
    match value {
        JsonValue::Array(values) => {
            JsonValue::Array(values.into_iter().map(canonical_json_value).collect())
        }
        JsonValue::Object(values) => {
            let sorted = values
                .into_iter()
                .map(|(key, value)| (key, canonical_json_value(value)))
                .collect::<BTreeMap<_, _>>();
            JsonValue::Object(sorted.into_iter().collect())
        }
        scalar => scalar,
    }
}

#[cfg(test)]
mod canonical_wire_tests {
    use super::super::{ManifestFilterExpr, ManifestLitValue, ManifestOperand};

    #[test]
    fn filter_expr_serialization_is_canonical_across_map_backends() {
        let mut value = serde_json::Map::new();
        value.insert("z".into(), serde_json::json!(2));
        value.insert("a".into(), serde_json::json!(1));
        let expression = ManifestFilterExpr::In {
            column: "metadata".into(),
            values: vec![ManifestOperand::Lit(ManifestLitValue::Json(
                serde_json::Value::Object(value),
            ))],
            negated: false,
        };

        assert_eq!(
            serde_json::to_string(&expression).unwrap(),
            r#"{"kind":"in","value":{"column":"metadata","negated":false,"values":[{"kind":"lit","value":{"kind":"json","value":{"a":1,"z":2}}}]}}"#
        );
    }
}