use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::canonical_json;
use super::knowledge::{FactV1, PageItemV1, PageV1, ResearchContextDocumentV1};
use crate::ValidationError;
fn object_id(value: &Value) -> Result<String, ValidationError> {
let digest = Sha256::digest(canonical_json(value)?);
let mut out = String::with_capacity(71);
out.push_str("sha256:");
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
Ok(out)
}
fn to_value<T: serde::Serialize>(value: &T) -> Result<Value, ValidationError> {
serde_json::to_value(value).map_err(|error| ValidationError::new(format!("ctxpkg: {error}")))
}
pub fn fact_blob_id(fact: &FactV1) -> Result<String, ValidationError> {
object_id(&json!({"type": "fact", "fact": to_value(fact)?}))
}
pub fn page_tree_id(
page: &PageV1,
document: &ResearchContextDocumentV1,
) -> Result<String, ValidationError> {
let mut items = Vec::with_capacity(page.items.len());
for item in &page.items {
items.push(match item {
PageItemV1::Fact { id } => {
let fact = document
.facts
.iter()
.find(|fact| &fact.id == id)
.ok_or_else(|| ValidationError::new("a page shows a missing fact"))?;
json!({"kind": "fact", "blob": fact_blob_id(fact)?})
}
PageItemV1::Prose { text } => json!({"kind": "prose", "text": text}),
});
}
object_id(&json!({"type": "page", "id": page.id, "title": page.title, "items": items}))
}
pub fn document_tree_id(document: &ResearchContextDocumentV1) -> Result<String, ValidationError> {
let mut facts = document
.facts
.iter()
.map(fact_blob_id)
.collect::<Result<Vec<_>, _>>()?;
facts.sort();
let mut relations: Vec<_> = document
.relations
.iter()
.map(|r| (r.from.as_str(), r.to.as_str(), r.kind.as_str()))
.collect();
relations.sort_unstable();
let relations: Vec<Value> = relations
.into_iter()
.map(|(from, to, kind)| json!({"from": from, "to": to, "type": kind}))
.collect();
let mut gaps = document.open_gaps.clone();
gaps.sort();
let mut pages = document
.pages
.iter()
.map(|page| page_tree_id(page, document))
.collect::<Result<Vec<_>, _>>()?;
pages.sort();
object_id(&json!({
"type": "tree", "format": document.format, "facts": facts,
"relations": relations, "open_gaps": gaps, "pages": pages,
}))
}
pub fn revision_commit_id(
revision: u64,
tree: &str,
parent: Option<&str>,
merged_proposals: &[u64],
rolls_back: Option<u64>,
) -> Result<String, ValidationError> {
let mut merged = merged_proposals.to_vec();
merged.sort_unstable();
object_id(&json!({
"type": "commit", "revision": revision, "tree": tree, "parent": parent,
"merged_proposals": merged, "rolls_back": rolls_back,
}))
}
pub fn import_slug(heading: &str) -> String {
let mut slug = String::new();
for ch in heading.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
if slug.len() > 64 {
slug.truncate(64);
if let Some(cut) = slug.rfind('-').filter(|cut| *cut > 0) {
slug.truncate(cut);
}
}
let slug = slug.trim_end_matches('-').to_owned();
if slug.is_empty() {
"section".into()
} else {
slug
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs_are_stable_and_bounded() {
assert_eq!(
import_slug("## Retry budget (payments)"),
"retry-budget-payments"
);
assert_eq!(import_slug("Ünïcode — only"), "n-code-only");
assert_eq!(import_slug("!!!"), "section");
assert!(import_slug(&"word ".repeat(30)).len() <= 64);
assert!(!import_slug(&"word ".repeat(30)).ends_with('-'));
}
#[test]
fn document_order_does_not_change_the_tree() {
let fact = |id: &str| FactV1 {
id: id.into(),
statement: format!("statement {id}"),
kind: "decision".into(),
applies_to: vec![],
sources: vec![],
from_contributions: vec![],
anchors: vec![],
};
let a = ResearchContextDocumentV1 {
facts: vec![fact("a"), fact("b")],
..Default::default()
};
let b = ResearchContextDocumentV1 {
facts: vec![fact("b"), fact("a")],
..Default::default()
};
assert_eq!(document_tree_id(&a).unwrap(), document_tree_id(&b).unwrap());
}
}