use std::io::{Cursor, Write};
use std::sync::Arc;
use memstead_schema::{ARCHIVE_CONFIG_PATH, PublishedMemConfig, Schema, type_by_name};
use zip::CompressionMethod;
use zip::DateTime;
use zip::write::SimpleFileOptions;
use super::ValidationError;
use super::archive::SchemaFile;
use crate::entity::generator::generate_markdown;
use crate::entity::{Entity, id::id_to_file_path};
pub fn canonical_bytes(
config: &PublishedMemConfig,
entities: &[Entity],
schema_files: &[SchemaFile],
embedded_schema: Option<&Arc<Schema>>,
provenance_bytes: Option<&[u8]>,
anchors_bytes: Option<&[u8]>,
) -> Result<Vec<u8>, ValidationError> {
let mut files: Vec<(String, Vec<u8>)> =
Vec::with_capacity(entities.len() + schema_files.len() + 3);
let config_text = canonical_json(config)?;
files.push((ARCHIVE_CONFIG_PATH.to_string(), config_text.into_bytes()));
if let Some(prov) = provenance_bytes {
files.push((
memstead_schema::ARCHIVE_PROVENANCE_PATH.to_string(),
prov.to_vec(),
));
}
if let Some(anchors) = anchors_bytes {
files.push((
memstead_schema::ARCHIVE_ANCHORS_PATH.to_string(),
anchors.to_vec(),
));
}
for sf in schema_files {
files.push((sf.archive_path.clone(), sf.content.clone().into_bytes()));
}
for entity in entities {
let schema = embedded_schema
.and_then(|s| s.get_type(&entity.entity_type))
.or_else(|| type_by_name(&entity.entity_type))
.ok_or_else(|| {
ValidationError::GraphConstructionFailed(format!(
"entity {} references unresolved type {:?}",
entity.id.as_ref(),
entity.entity_type
))
})?;
let md = generate_markdown(entity, &schema);
let lf = normalize_lf(&md);
let path = id_to_file_path(&entity.id);
files.push((path, lf.into_bytes()));
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut buf = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut writer = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(6))
.last_modified_time(DateTime::default());
for (path, content) in files {
writer
.start_file(&path, options)
.map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
writer
.write_all(&content)
.map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
}
writer
.finish()
.map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
}
Ok(buf)
}
fn normalize_lf(s: &str) -> String {
s.replace("\r\n", "\n")
}
pub fn canonical_json(config: &PublishedMemConfig) -> Result<String, ValidationError> {
let value = serde_json::to_value(config).map_err(|e| ValidationError::InvalidConfig {
reason: e.to_string(),
})?;
let mut out = String::new();
write_canonical(&value, &mut out, 0);
out.push('\n');
Ok(out)
}
fn write_canonical(value: &serde_json::Value, out: &mut String, indent: usize) {
match value {
serde_json::Value::Null => out.push_str("null"),
serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
serde_json::Value::Number(n) => out.push_str(&n.to_string()),
serde_json::Value::String(s) => {
let escaped = serde_json::to_string(s).expect("string escape infallible");
out.push_str(&escaped);
}
serde_json::Value::Array(items) => {
if items.is_empty() {
out.push_str("[]");
return;
}
out.push('[');
for (i, item) in items.iter().enumerate() {
out.push('\n');
push_indent(out, indent + 1);
write_canonical(item, out, indent + 1);
if i < items.len() - 1 {
out.push(',');
}
}
out.push('\n');
push_indent(out, indent);
out.push(']');
}
serde_json::Value::Object(map) => {
if map.is_empty() {
out.push_str("{}");
return;
}
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, key) in keys.iter().enumerate() {
out.push('\n');
push_indent(out, indent + 1);
let key_escaped = serde_json::to_string(*key).expect("string escape infallible");
out.push_str(&key_escaped);
out.push_str(": ");
write_canonical(&map[*key], out, indent + 1);
if i < keys.len() - 1 {
out.push(',');
}
}
out.push('\n');
push_indent(out, indent);
out.push('}');
}
}
}
fn push_indent(out: &mut String, indent: usize) {
for _ in 0..indent * 2 {
out.push(' ');
}
}
#[cfg(test)]
mod tests {
use super::*;
use semver::Version;
fn config() -> PublishedMemConfig {
PublishedMemConfig {
format: memstead_schema::PUBLISHED_MEM_FORMAT,
name: "example".to_string(),
version: Version::parse("0.1.0").unwrap(),
description: Some("a test mem".to_string()),
title: None,
subject: None,
authors: Some(vec!["Alice".to_string(), "Bob".to_string()]),
schema: "default@1.0.0".parse().unwrap(),
}
}
#[test]
fn canonical_json_is_alpha_sorted() {
let json = canonical_json(&config()).unwrap();
let expected = "{\n \"authors\": [\n \"Alice\",\n \"Bob\"\n ],\n \"description\": \"a test mem\",\n \"format\": 4,\n \"name\": \"example\",\n \"schema\": \"default@1.0.0\",\n \"version\": \"0.1.0\"\n}\n";
assert_eq!(json, expected);
}
#[test]
fn canonical_json_handles_empty_array_inline() {
let mut c = config();
c.authors = Some(Vec::new());
let json = canonical_json(&c).unwrap();
assert!(json.contains("\"authors\": []"));
}
#[test]
fn canonical_json_ends_with_lf() {
let json = canonical_json(&config()).unwrap();
assert!(json.ends_with('\n'));
assert!(!json.ends_with("\r\n"));
}
#[test]
fn canonical_json_escapes_quotes_in_strings() {
let mut c = config();
c.description = Some("he said \"hi\"".to_string());
let json = canonical_json(&c).unwrap();
assert!(json.contains("\"he said \\\"hi\\\"\""));
}
#[test]
fn canonical_bytes_produces_deterministic_output() {
let c = config();
let a = canonical_bytes(&c, &[], &[], None, None, None).unwrap();
let b = canonical_bytes(&c, &[], &[], None, None, None).unwrap();
assert_eq!(a, b);
}
#[test]
fn normalize_lf_converts_crlf() {
assert_eq!(normalize_lf("a\r\nb\r\nc"), "a\nb\nc");
assert_eq!(normalize_lf("a\nb\nc"), "a\nb\nc");
assert_eq!(normalize_lf("a\rb"), "a\rb");
}
}