lean_ctx_protocol/hive/
ctxpkg.rs1use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29
30use super::canonical_json;
31use super::knowledge::{FactV1, PageItemV1, PageV1, ResearchContextDocumentV1};
32use crate::ValidationError;
33
34fn object_id(value: &Value) -> Result<String, ValidationError> {
35 let digest = Sha256::digest(canonical_json(value)?);
36 let mut out = String::with_capacity(71);
37 out.push_str("sha256:");
38 for byte in digest {
39 out.push_str(&format!("{byte:02x}"));
40 }
41 Ok(out)
42}
43
44fn to_value<T: serde::Serialize>(value: &T) -> Result<Value, ValidationError> {
45 serde_json::to_value(value).map_err(|error| ValidationError::new(format!("ctxpkg: {error}")))
46}
47
48pub fn fact_blob_id(fact: &FactV1) -> Result<String, ValidationError> {
49 object_id(&json!({"type": "fact", "fact": to_value(fact)?}))
50}
51
52pub fn page_tree_id(
53 page: &PageV1,
54 document: &ResearchContextDocumentV1,
55) -> Result<String, ValidationError> {
56 let mut items = Vec::with_capacity(page.items.len());
57 for item in &page.items {
58 items.push(match item {
59 PageItemV1::Fact { id } => {
60 let fact = document
61 .facts
62 .iter()
63 .find(|fact| &fact.id == id)
64 .ok_or_else(|| ValidationError::new("a page shows a missing fact"))?;
65 json!({"kind": "fact", "blob": fact_blob_id(fact)?})
66 }
67 PageItemV1::Prose { text } => json!({"kind": "prose", "text": text}),
68 });
69 }
70 object_id(&json!({"type": "page", "id": page.id, "title": page.title, "items": items}))
71}
72
73pub fn document_tree_id(document: &ResearchContextDocumentV1) -> Result<String, ValidationError> {
74 let mut facts = document
75 .facts
76 .iter()
77 .map(fact_blob_id)
78 .collect::<Result<Vec<_>, _>>()?;
79 facts.sort();
80 let mut relations: Vec<_> = document
81 .relations
82 .iter()
83 .map(|r| (r.from.as_str(), r.to.as_str(), r.kind.as_str()))
84 .collect();
85 relations.sort_unstable();
86 let relations: Vec<Value> = relations
87 .into_iter()
88 .map(|(from, to, kind)| json!({"from": from, "to": to, "type": kind}))
89 .collect();
90 let mut gaps = document.open_gaps.clone();
91 gaps.sort();
92 let mut pages = document
93 .pages
94 .iter()
95 .map(|page| page_tree_id(page, document))
96 .collect::<Result<Vec<_>, _>>()?;
97 pages.sort();
98 object_id(&json!({
99 "type": "tree", "format": document.format, "facts": facts,
100 "relations": relations, "open_gaps": gaps, "pages": pages,
101 }))
102}
103
104pub fn revision_commit_id(
107 revision: u64,
108 tree: &str,
109 parent: Option<&str>,
110 merged_proposals: &[u64],
111 rolls_back: Option<u64>,
112) -> Result<String, ValidationError> {
113 let mut merged = merged_proposals.to_vec();
114 merged.sort_unstable();
115 object_id(&json!({
116 "type": "commit", "revision": revision, "tree": tree, "parent": parent,
117 "merged_proposals": merged, "rolls_back": rolls_back,
118 }))
119}
120
121pub fn import_slug(heading: &str) -> String {
126 let mut slug = String::new();
127 for ch in heading.chars() {
128 if ch.is_ascii_alphanumeric() {
129 slug.push(ch.to_ascii_lowercase());
130 } else if !slug.is_empty() && !slug.ends_with('-') {
131 slug.push('-');
132 }
133 }
134 if slug.len() > 64 {
135 slug.truncate(64);
136 if let Some(cut) = slug.rfind('-').filter(|cut| *cut > 0) {
137 slug.truncate(cut);
138 }
139 }
140 let slug = slug.trim_end_matches('-').to_owned();
141 if slug.is_empty() {
142 "section".into()
143 } else {
144 slug
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn slugs_are_stable_and_bounded() {
154 assert_eq!(
155 import_slug("## Retry budget (payments)"),
156 "retry-budget-payments"
157 );
158 assert_eq!(import_slug("Ünïcode — only"), "n-code-only");
159 assert_eq!(import_slug("!!!"), "section");
160 assert!(import_slug(&"word ".repeat(30)).len() <= 64);
161 assert!(!import_slug(&"word ".repeat(30)).ends_with('-'));
162 }
163
164 #[test]
165 fn document_order_does_not_change_the_tree() {
166 let fact = |id: &str| FactV1 {
167 id: id.into(),
168 statement: format!("statement {id}"),
169 kind: "decision".into(),
170 applies_to: vec![],
171 sources: vec![],
172 from_contributions: vec![],
173 anchors: vec![],
174 };
175 let a = ResearchContextDocumentV1 {
176 facts: vec![fact("a"), fact("b")],
177 ..Default::default()
178 };
179 let b = ResearchContextDocumentV1 {
180 facts: vec![fact("b"), fact("a")],
181 ..Default::default()
182 };
183 assert_eq!(document_tree_id(&a).unwrap(), document_tree_id(&b).unwrap());
184 }
185}