1use std::collections::BTreeMap;
8
9use serde_json::{Map, Value};
10
11use crate::fold::{self, DocEvent};
12use crate::state::{self, Drift, NoteCovers, Verdict};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct LogEvent {
17 pub seq: u64,
18 pub event_id: String,
19 pub op: String,
20 pub target: Option<String>,
21 pub node_type: Option<String>,
22 pub body: Option<Value>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq)]
28pub struct DocNode {
29 pub id: String,
30 pub node_type: String,
31 pub fields: Map<String, Value>,
32 pub retracted: bool,
33 pub head: String,
35 pub added_seq: u64,
37}
38
39impl DocNode {
40 pub fn str(&self, field: &str) -> Option<&str> {
41 self.fields.get(field).and_then(Value::as_str)
42 }
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct SectionView {
48 pub id: String,
49 pub order: u32,
50 pub body: String,
51 pub head: String,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct NoteView {
57 pub id: String,
58 pub about: String,
59 pub body: String,
60 pub open: bool,
61 pub covers: Vec<Drift>,
62 pub head: String,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct PageView {
68 pub page: DocNode,
69 pub sections: Vec<SectionView>,
70 pub notes: Vec<NoteView>,
71 pub slug_conflict: bool,
72 pub state: Verdict,
73}
74
75impl PageView {
76 pub fn slug(&self) -> &str {
77 self.page.str("slug").unwrap_or_default()
78 }
79 pub fn title(&self) -> &str {
80 self.page.str("title").unwrap_or_default()
81 }
82 pub fn markdown(&self) -> String {
84 let mut out = format!("# {}\n", self.title());
85 for s in &self.sections {
86 out.push_str(&s.body);
87 }
88 out
89 }
90}
91
92#[derive(Debug, Clone, Default, PartialEq)]
94pub struct DocSpace {
95 pub pages: BTreeMap<String, PageView>,
97 pub by_slug: BTreeMap<String, String>,
99 pub links: BTreeMap<String, Vec<(String, String)>>,
101}
102
103impl DocSpace {
104 pub fn page_by_slug(&self, slug: &str) -> Option<&PageView> {
105 self.by_slug.get(slug).and_then(|id| self.pages.get(id))
106 }
107}
108
109const DOC_TYPES: [&str; 3] = ["page", "section", "note"];
110const HEAD_OPS: [&str; 4] = ["node.add", "field.set", "retract", "restore"];
111
112pub fn assemble(events: &[LogEvent]) -> DocSpace {
114 let mut ordered: Vec<&LogEvent> = events.iter().collect();
115 ordered.sort_by_key(|e| e.seq);
116 let (nodes, doc_events) = fold_nodes(&ordered);
117 let m = fold::fold(&doc_events);
118
119 let mut sections: BTreeMap<&str, Vec<SectionView>> = BTreeMap::new();
120 for (sec, page) in &m.section_page {
121 let (Some(page), Some(n)) = (page, nodes.get(sec)) else {
122 continue;
123 };
124 if n.retracted {
125 continue;
126 }
127 sections.entry(page).or_default().push(SectionView {
128 id: n.id.clone(),
129 order: n.fields.get("order").and_then(Value::as_u64).unwrap_or(0) as u32,
130 body: n.str("body").unwrap_or_default().to_owned(),
131 head: n.head.clone(),
132 });
133 }
134 let page_of = |about: &str| -> Option<String> {
135 let n = nodes.get(about).filter(|n| !n.retracted)?;
136 match n.node_type.as_str() {
137 "page" => Some(about.to_owned()),
138 "section" => m.section_page.get(about).cloned().flatten(),
139 _ => None,
140 }
141 };
142 let mut notes: BTreeMap<String, Vec<NoteView>> = BTreeMap::new();
143 for (note, about) in &m.note_about {
144 let (Some(about), Some(n)) = (about, nodes.get(note)) else {
145 continue;
146 };
147 if n.retracted {
148 continue;
149 }
150 if let Some(page) = page_of(about) {
151 notes.entry(page).or_default().push(NoteView {
152 id: n.id.clone(),
153 about: about.clone(),
154 body: n.str("body").unwrap_or_default().to_owned(),
155 open: n.str("status") != Some("resolved"),
156 covers: drift_list(n.fields.get("covers")),
157 head: n.head.clone(),
158 });
159 }
160 }
161
162 let mut space = DocSpace::default();
163 for n in nodes
164 .values()
165 .filter(|n| n.node_type == "page" && !n.retracted)
166 {
167 let mut secs = sections.remove(n.id.as_str()).unwrap_or_default();
168 secs.sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id)));
169 let page_notes = notes.remove(&n.id).unwrap_or_default();
170 let state = page_state(n, &page_notes);
171 space.pages.insert(
172 n.id.clone(),
173 PageView {
174 page: n.clone(),
175 sections: secs,
176 notes: page_notes,
177 slug_conflict: m.slug_conflict.contains(&n.id),
178 state,
179 },
180 );
181 }
182 space.by_slug = m.slug_owner;
183 space.links = links_of(&ordered);
184 space
185}
186
187fn links_of(ordered: &[&LogEvent]) -> BTreeMap<String, Vec<(String, String)>> {
189 let mut edges: BTreeMap<&str, (&str, &str, bool)> = BTreeMap::new();
190 for e in ordered {
191 let Some(target) = e.target.as_deref() else {
192 continue;
193 };
194 let get = |k: &str| {
195 e.body
196 .as_ref()
197 .and_then(|b| b.get(k))
198 .and_then(Value::as_str)
199 };
200 match e.op.as_str() {
201 "edge.add" if get("kind") == Some("links_to") => {
202 if let (Some(from), Some(to)) = (get("from"), get("to")) {
203 edges.entry(target).or_insert((from, to, true));
204 }
205 }
206 "retract" | "restore" => {
207 if let Some(edge) = edges.get_mut(target) {
208 edge.2 = e.op == "restore";
209 }
210 }
211 _ => {}
212 }
213 }
214 let mut out: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
215 for (id, (from, to, live)) in edges {
216 if live {
217 out.entry(from.to_owned())
218 .or_default()
219 .push((id.to_owned(), to.to_owned()));
220 }
221 }
222 out
223}
224
225fn fold_nodes(ordered: &[&LogEvent]) -> (BTreeMap<String, DocNode>, Vec<DocEvent>) {
228 let mut nodes: BTreeMap<String, DocNode> = BTreeMap::new();
229 let mut doc_events = Vec::new();
230 for e in ordered {
231 let Some(target) = e.target.as_deref() else {
232 continue;
233 };
234 let body = e.body.as_ref();
235 match e.op.as_str() {
236 "node.add" => {
237 let Some(t) = e.node_type.as_deref().filter(|t| DOC_TYPES.contains(t)) else {
238 continue;
239 };
240 let mut fields = body.and_then(Value::as_object).cloned().unwrap_or_default();
241 fields.remove("body_nonce");
242 doc_events.push(DocEvent::NodeAdd {
243 seq: e.seq,
244 node: target.to_owned(),
245 node_type: t.to_owned(),
246 slug: fields
247 .get("slug")
248 .and_then(Value::as_str)
249 .map(str::to_owned),
250 });
251 nodes.entry(target.to_owned()).or_insert(DocNode {
252 id: target.to_owned(),
253 node_type: t.to_owned(),
254 fields,
255 added_seq: e.seq,
256 ..DocNode::default()
257 });
258 }
259 "field.set" => {
260 let Some(n) = nodes.get_mut(target) else {
261 continue;
262 };
263 if let Some(field) = body.and_then(|b| b.get("field")).and_then(Value::as_str) {
264 let value = body
265 .and_then(|b| b.get("value"))
266 .cloned()
267 .unwrap_or(Value::Null);
268 if field == "slug" {
269 if let Some(s) = value.as_str() {
270 doc_events.push(DocEvent::SlugSet {
271 seq: e.seq,
272 node: target.to_owned(),
273 slug: s.to_owned(),
274 });
275 }
276 }
277 n.fields.insert(field.to_owned(), value);
278 }
279 }
280 "edge.add" => {
281 let get = |k: &str| {
282 body.and_then(|b| b.get(k))
283 .and_then(Value::as_str)
284 .unwrap_or_default()
285 .to_owned()
286 };
287 let kind = get("kind");
288 if kind == "part_of" || kind == "about" {
289 doc_events.push(DocEvent::EdgeAdd {
290 seq: e.seq,
291 edge: target.to_owned(),
292 kind,
293 from: get("from"),
294 to: get("to"),
295 });
296 }
297 }
298 "retract" | "restore" => {
299 let retract = e.op == "retract";
300 doc_events.push(if retract {
301 DocEvent::Retract {
302 seq: e.seq,
303 target: target.to_owned(),
304 }
305 } else {
306 DocEvent::Restore {
307 seq: e.seq,
308 target: target.to_owned(),
309 }
310 });
311 if let Some(n) = nodes.get_mut(target) {
312 n.retracted = retract;
313 }
314 }
315 _ => {}
316 }
317 if HEAD_OPS.contains(&e.op.as_str()) {
318 if let Some(n) = nodes.get_mut(target) {
319 n.head.clone_from(&e.event_id);
320 }
321 }
322 }
323 (nodes, doc_events)
324}
325
326fn drift_list(v: Option<&Value>) -> Vec<Drift> {
327 v.and_then(Value::as_array)
328 .map(|a| {
329 a.iter()
330 .filter_map(|d| serde_json::from_value(d.clone()).ok())
331 .collect()
332 })
333 .unwrap_or_default()
334}
335
336fn page_state(page: &DocNode, notes: &[NoteView]) -> Verdict {
338 let derived: Vec<&str> = page
339 .fields
340 .get("anchors")
341 .and_then(Value::as_array)
342 .into_iter()
343 .flatten()
344 .filter(|a| a.get("role").and_then(Value::as_str).unwrap_or("derived") == "derived")
345 .filter_map(|a| a.get("ref").and_then(Value::as_str))
346 .collect();
347 let drift = drift_list(page.fields.get("drift"));
348 let covers: Vec<NoteCovers> = notes
349 .iter()
350 .map(|n| NoteCovers {
351 open: n.open,
352 covers: n.covers.clone(),
353 })
354 .collect();
355 state::of(page.str("anchor_state"), &derived, &drift, &covers)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use serde_json::json;
362
363 fn ev(seq: u64, op: &str, target: &str, node_type: Option<&str>, body: Value) -> LogEvent {
364 LogEvent {
365 seq,
366 event_id: format!("ev{seq}"),
367 op: op.into(),
368 target: Some(target.into()),
369 node_type: node_type.map(Into::into),
370 body: Some(body),
371 }
372 }
373
374 fn log() -> Vec<LogEvent> {
375 vec![
376 ev(
377 1,
378 "node.add",
379 "n_p",
380 Some("page"),
381 json!({"slug": "guide/start", "title": "Start", "type": "guide", "status": "draft",
382 "anchors": [{"ref": "file:a.rs", "digest": "d1", "commit": "c"}, {"ref": "file:b.md", "digest": "d2", "commit": "c", "role": "cites"}]}),
383 ),
384 ev(
385 3,
386 "node.add",
387 "n_s2",
388 Some("section"),
389 json!({"body": "## Two\nb\n", "order": 2048}),
390 ),
391 ev(
392 2,
393 "node.add",
394 "n_s1",
395 Some("section"),
396 json!({"body": "intro\n", "order": 1024}),
397 ),
398 ev(
399 4,
400 "edge.add",
401 "e_1",
402 None,
403 json!({"kind": "part_of", "from": "n_s1", "to": "n_p"}),
404 ),
405 ev(
406 5,
407 "edge.add",
408 "e_2",
409 None,
410 json!({"kind": "part_of", "from": "n_s2", "to": "n_p"}),
411 ),
412 ev(
413 6,
414 "field.set",
415 "n_s1",
416 None,
417 json!({"field": "body", "value": "Intro.\n"}),
418 ),
419 ev(
420 7,
421 "node.add",
422 "n_n",
423 Some("note"),
424 json!({"body": "a.rs moved", "status": "open", "covers": [{"ref": "file:a.rs", "digest": "d9"}]}),
425 ),
426 ev(
427 8,
428 "edge.add",
429 "e_3",
430 None,
431 json!({"kind": "about", "from": "n_n", "to": "n_s2"}),
432 ),
433 ev(
434 9,
435 "field.set",
436 "n_p",
437 None,
438 json!({"field": "anchor_state", "value": "to_verify"}),
439 ),
440 ev(
441 10,
442 "field.set",
443 "n_p",
444 None,
445 json!({"field": "drift", "value": [{"ref": "file:a.rs", "observed": "d9"}]}),
446 ),
447 ev(
448 11,
449 "node.add",
450 "n_k",
451 Some("fact"),
452 json!({"statement": "not a doc"}),
453 ),
454 ]
455 }
456
457 #[test]
458 fn assembles_pages_in_order_with_heads_and_notes() {
459 let space = assemble(&log());
460 assert_eq!(space.pages.len(), 1);
461 let p = space.page_by_slug("guide/start").expect("page");
462 assert_eq!(p.markdown(), "# Start\nIntro.\n## Two\nb\n");
463 assert_eq!(p.sections[0].head, "ev6", "a field.set moves the head");
464 assert_eq!(p.page.head, "ev10");
465 assert_eq!(p.notes.len(), 1);
466 assert_eq!(
467 p.notes[0].about, "n_s2",
468 "a note about a section belongs to its page"
469 );
470 assert_eq!(p.state.state, crate::state::DocState::Noted);
471 }
472
473 #[test]
474 fn a_retracted_section_or_resolved_note_changes_the_view() {
475 let mut events = log();
476 events.push(ev(12, "retract", "n_s2", None, json!({"reason": "gone"})));
477 events.push(ev(
478 13,
479 "field.set",
480 "n_n",
481 None,
482 json!({"field": "status", "value": "resolved"}),
483 ));
484 let space = assemble(&events);
485 let p = space.page_by_slug("guide/start").expect("page");
486 assert_eq!(p.markdown(), "# Start\nIntro.\n");
487 assert!(p.notes.is_empty(), "the note's section is gone");
488 assert_eq!(p.state.state, crate::state::DocState::Stale);
489 }
490}