1#![forbid(unsafe_code)]
2
3use std::{
4 collections::{BTreeMap, HashMap, HashSet},
5 error, fmt,
6};
7
8use kcode_kweb_db::NodeId;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12pub type Result<T> = std::result::Result<T, Error>;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct Error {
16 message: String,
17}
18
19impl Error {
20 fn new(message: impl Into<String>) -> Self {
21 Self {
22 message: message.into(),
23 }
24 }
25}
26
27impl fmt::Display for Error {
28 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29 formatter.write_str(&self.message)
30 }
31}
32
33impl error::Error for Error {}
34
35#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Connection {
38 pub id: String,
39 pub short_name: String,
40 pub short_description: String,
41}
42
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct Node {
46 pub id: String,
47 pub short_name: String,
48 pub short_description: String,
49 pub long_description: String,
50 pub owner: String,
51 #[serde(default)]
52 pub fixed_connections: Vec<Connection>,
53 #[serde(default)]
54 pub recent_connections: Vec<Connection>,
55 #[serde(default)]
56 pub objects: Vec<String>,
57 #[serde(default)]
58 pub last_modified_by: String,
59 #[serde(default)]
60 pub last_modified_at: Option<String>,
61}
62
63impl Node {
64 pub fn from_kweb_value(value: &Value) -> Result<Self> {
65 let id = required_string(value, "id")?;
66 canonical_node_id(&id)?;
67 let owner = value
68 .get("owner_node_id")
69 .or_else(|| value.get("owner_root_node_id"))
70 .and_then(Value::as_str)
71 .unwrap_or("unowned")
72 .to_owned();
73 if !matches!(owner.as_str(), "self" | "unowned") {
74 canonical_node_id(&owner)?;
75 }
76 let summaries = value
77 .get("connection_summaries")
78 .and_then(Value::as_array)
79 .into_iter()
80 .flatten()
81 .filter_map(|summary| Some((summary.get("id")?.as_str()?.to_owned(), summary)))
82 .collect::<HashMap<_, _>>();
83 let fixed_connections = connections(
84 value.get("fixed_connections"),
85 &summaries,
86 "fixed connection",
87 )?;
88 let recent_connections = connections(
89 value.get("recent_connections"),
90 &summaries,
91 "recent connection",
92 )?;
93 let objects = string_ids(value.get("objects"), "object")?;
94 Ok(Self {
95 id,
96 short_name: optional_string(value, "short_name"),
97 short_description: optional_string(value, "short_description"),
98 long_description: optional_string(value, "long_description"),
99 owner,
100 fixed_connections,
101 recent_connections,
102 objects,
103 last_modified_by: optional_string(value, "last_modified_by"),
104 last_modified_at: value
105 .get("last_modified_at")
106 .and_then(Value::as_str)
107 .map(str::to_owned),
108 })
109 }
110
111 pub fn draft(&self) -> NodeDraft {
112 NodeDraft {
113 short_name: self.short_name.clone(),
114 short_description: self.short_description.clone(),
115 long_description: self.long_description.clone(),
116 owner: self.owner.clone(),
117 fixed_connections: self
118 .fixed_connections
119 .iter()
120 .map(|connection| connection.id.clone())
121 .collect(),
122 recent_connections: self
123 .recent_connections
124 .iter()
125 .map(|connection| connection.id.clone())
126 .collect(),
127 objects: self.objects.clone(),
128 }
129 }
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct NodeDraft {
135 pub short_name: String,
136 pub short_description: String,
137 pub long_description: String,
138 pub owner: String,
139 #[serde(default)]
140 pub fixed_connections: Vec<String>,
141 #[serde(default)]
142 pub recent_connections: Vec<String>,
143 #[serde(default)]
144 pub objects: Vec<String>,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct StagedCreate {
149 pub pending_id: String,
150 pub data: NodeDraft,
151}
152
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum BoxKind {
155 Loaded,
156 Fixed,
157 Staged,
158 Recent,
159}
160
161impl BoxKind {
162 pub const fn name(self) -> &'static str {
163 match self {
164 Self::Loaded => "Kweb loaded node",
165 Self::Fixed => "Kweb fixed connection",
166 Self::Staged => "Kweb staged node",
167 Self::Recent => "Kweb recent connections",
168 }
169 }
170
171 pub const fn metadata_name(self) -> &'static str {
172 match self {
173 Self::Loaded => "loaded",
174 Self::Fixed => "fixed",
175 Self::Staged => "staged",
176 Self::Recent => "recent",
177 }
178 }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct BoxSpec {
183 pub logical_slot: String,
184 pub kind: BoxKind,
185 pub text: String,
186 pub stored_node: Option<Node>,
187 pub staged_node: Option<NodeDraft>,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
191#[serde(rename_all = "camelCase")]
192pub struct LoadReport {
193 pub requested_id: String,
194 pub newly_loaded: bool,
195 pub promoted_from_fixed: bool,
196 pub new_fixed_ids: Vec<String>,
197}
198
199#[derive(Clone, Debug)]
200pub struct Context {
201 root_node_ids: Vec<String>,
202 loaded_node_ids: Vec<String>,
203 fixed_node_ids: Vec<String>,
204 nodes_by_id: BTreeMap<String, Node>,
205}
206
207impl Context {
208 pub fn new(root_node_ids: Vec<String>) -> Result<Self> {
209 if root_node_ids.is_empty() {
210 return Err(Error::new("Kweb context requires at least one root node"));
211 }
212 let mut seen = HashSet::new();
213 for id in &root_node_ids {
214 canonical_node_id(id)?;
215 if !seen.insert(id.clone()) {
216 return Err(Error::new("Kweb root node IDs must be distinct"));
217 }
218 }
219 Ok(Self {
220 root_node_ids,
221 loaded_node_ids: Vec::new(),
222 fixed_node_ids: Vec::new(),
223 nodes_by_id: BTreeMap::new(),
224 })
225 }
226
227 pub fn root_node_ids(&self) -> &[String] {
228 &self.root_node_ids
229 }
230
231 pub fn loaded_node_ids(&self) -> &[String] {
232 &self.loaded_node_ids
233 }
234
235 pub fn fixed_node_ids(&self) -> &[String] {
236 &self.fixed_node_ids
237 }
238
239 pub fn full_node_ids(&self) -> Vec<&str> {
240 self.loaded_node_ids
241 .iter()
242 .chain(&self.fixed_node_ids)
243 .map(String::as_str)
244 .collect()
245 }
246
247 pub fn contains_full_node(&self, id: &str) -> bool {
248 self.loaded_node_ids.iter().any(|candidate| candidate == id)
249 || self.fixed_node_ids.iter().any(|candidate| candidate == id)
250 }
251
252 pub fn node(&self, id: &str) -> Option<&Node> {
253 self.nodes_by_id.get(id)
254 }
255
256 pub fn apply_load(&mut self, requested: Node, fixed: Vec<Node>) -> Result<LoadReport> {
257 let requested_id = requested.id.clone();
258 let was_loaded = self
259 .loaded_node_ids
260 .iter()
261 .any(|candidate| candidate == &requested_id);
262 let was_fixed = self
263 .fixed_node_ids
264 .iter()
265 .any(|candidate| candidate == &requested_id);
266 let previous_full = self
267 .full_node_ids()
268 .into_iter()
269 .map(str::to_owned)
270 .collect::<HashSet<_>>();
271 let expected_fixed = requested
272 .fixed_connections
273 .iter()
274 .map(|connection| connection.id.as_str())
275 .filter(|id| *id != requested_id)
276 .collect::<HashSet<_>>();
277 let provided_fixed = fixed
278 .iter()
279 .map(|node| node.id.as_str())
280 .collect::<HashSet<_>>();
281 if expected_fixed != provided_fixed {
282 return Err(Error::new(format!(
283 "load for {requested_id} did not provide exactly its fixed connections"
284 )));
285 }
286 self.nodes_by_id.insert(requested_id.clone(), requested);
287 for node in fixed {
288 self.nodes_by_id.insert(node.id.clone(), node);
289 }
290 if !was_loaded {
291 self.loaded_node_ids.push(requested_id.clone());
292 }
293 self.rebuild_fixed();
294 let new_fixed_ids = self
295 .fixed_node_ids
296 .iter()
297 .filter(|id| !previous_full.contains(*id))
298 .cloned()
299 .collect();
300 Ok(LoadReport {
301 requested_id,
302 newly_loaded: !was_loaded && !was_fixed,
303 promoted_from_fixed: !was_loaded && was_fixed,
304 new_fixed_ids,
305 })
306 }
307
308 pub fn refresh(&mut self, nodes: impl IntoIterator<Item = Node>) -> Result<()> {
309 for node in nodes {
310 if !self.contains_full_node(&node.id) {
311 return Err(Error::new(format!(
312 "cannot refresh unloaded Kweb node {}",
313 node.id
314 )));
315 }
316 self.nodes_by_id.insert(node.id.clone(), node);
317 }
318 self.rebuild_fixed();
319 Ok(())
320 }
321
322 pub fn restore(
323 &mut self,
324 nodes: impl IntoIterator<Item = Node>,
325 directly_loaded: Vec<String>,
326 ) -> Result<()> {
327 self.nodes_by_id.clear();
328 for node in nodes {
329 self.nodes_by_id.insert(node.id.clone(), node);
330 }
331 let mut seen = HashSet::new();
332 self.loaded_node_ids = directly_loaded
333 .into_iter()
334 .filter(|id| self.nodes_by_id.contains_key(id) && seen.insert(id.clone()))
335 .collect();
336 if !self.nodes_by_id.is_empty() && self.loaded_node_ids.is_empty() {
337 return Err(Error::new(
338 "restored Kweb context contains nodes but no loaded node",
339 ));
340 }
341 self.rebuild_fixed();
342 Ok(())
343 }
344
345 pub fn box_specs(
346 &self,
347 updates: &BTreeMap<String, NodeDraft>,
348 creates: &[StagedCreate],
349 ) -> Result<Vec<BoxSpec>> {
350 for id in updates.keys() {
351 if !self.contains_full_node(id) {
352 return Err(Error::new(format!(
353 "staged update targets unloaded Kweb node {id}"
354 )));
355 }
356 }
357 let mut specs = Vec::new();
358 for id in &self.loaded_node_ids {
359 specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
360 }
361 for id in &self.fixed_node_ids {
362 specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
363 }
364 for create in creates {
365 specs.push(BoxSpec {
366 logical_slot: create.pending_id.clone(),
367 kind: BoxKind::Staged,
368 text: format_node(&create.pending_id, &create.data),
369 stored_node: None,
370 staged_node: Some(create.data.clone()),
371 });
372 }
373 specs.push(BoxSpec {
374 logical_slot: "recent-connections".into(),
375 kind: BoxKind::Recent,
376 text: self.format_recent_connections(updates, creates)?,
377 stored_node: None,
378 staged_node: None,
379 });
380 Ok(specs)
381 }
382
383 fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
384 let node = self
385 .nodes_by_id
386 .get(id)
387 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
388 let data = update.cloned().unwrap_or_else(|| node.draft());
389 Ok(BoxSpec {
390 logical_slot: id.to_owned(),
391 kind,
392 text: format_node(id, &data),
393 stored_node: Some(node.clone()),
394 staged_node: update.cloned(),
395 })
396 }
397
398 fn format_recent_connections(
399 &self,
400 updates: &BTreeMap<String, NodeDraft>,
401 creates: &[StagedCreate],
402 ) -> Result<String> {
403 let creates_by_id = creates
404 .iter()
405 .map(|create| (create.pending_id.as_str(), &create.data))
406 .collect::<HashMap<_, _>>();
407 let mut summaries = HashMap::new();
408 for node in self.nodes_by_id.values() {
409 summaries.insert(
410 node.id.as_str(),
411 (node.short_name.as_str(), node.short_description.as_str()),
412 );
413 for connection in node
414 .fixed_connections
415 .iter()
416 .chain(&node.recent_connections)
417 {
418 summaries.entry(connection.id.as_str()).or_insert((
419 connection.short_name.as_str(),
420 connection.short_description.as_str(),
421 ));
422 }
423 }
424 let mut recent_ids = Vec::new();
425 let mut seen = HashSet::new();
426 for id in self.full_node_ids() {
427 let node = self
428 .nodes_by_id
429 .get(id)
430 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
431 let recent = updates
432 .get(id)
433 .map(|draft| draft.recent_connections.as_slice())
434 .unwrap_or_else(|| &[]);
435 if updates.contains_key(id) {
436 for connection_id in recent {
437 if seen.insert(connection_id.clone()) {
438 recent_ids.push(connection_id.clone());
439 }
440 }
441 } else {
442 for connection in &node.recent_connections {
443 if seen.insert(connection.id.clone()) {
444 recent_ids.push(connection.id.clone());
445 }
446 }
447 }
448 }
449 for create in creates {
450 for connection_id in &create.data.recent_connections {
451 if seen.insert(connection_id.clone()) {
452 recent_ids.push(connection_id.clone());
453 }
454 }
455 }
456 let mut lines = vec!["Recent connections".to_owned()];
457 for id in recent_ids {
458 let staged_summary = updates
459 .get(&id)
460 .or_else(|| creates_by_id.get(id.as_str()).copied())
461 .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
462 let (name, description) = staged_summary
463 .or_else(|| summaries.get(id.as_str()).copied())
464 .ok_or_else(|| {
465 Error::new(format!(
466 "recent connection {id} must resolve to a nonempty short name and short description"
467 ))
468 })?;
469 if name.trim().is_empty() || description.trim().is_empty() {
470 return Err(Error::new(format!(
471 "recent connection {id} must resolve to a nonempty short name and short description"
472 )));
473 }
474 lines.push(format!("{id} · {name}: {description}"));
475 }
476 if lines.len() == 1 {
477 lines.push("None.".into());
478 }
479 Ok(lines.join("\n"))
480 }
481
482 fn rebuild_fixed(&mut self) {
483 let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
484 let mut seen = loaded.clone();
485 let mut fixed = Vec::new();
486 for id in &self.loaded_node_ids {
487 let Some(node) = self.nodes_by_id.get(id) else {
488 continue;
489 };
490 for connection in &node.fixed_connections {
491 if self.nodes_by_id.contains_key(&connection.id)
492 && seen.insert(connection.id.clone())
493 {
494 fixed.push(connection.id.clone());
495 }
496 }
497 }
498 self.fixed_node_ids = fixed;
499 self.nodes_by_id
500 .retain(|id, _| loaded.contains(id) || seen.contains(id));
501 }
502}
503
504fn connections(
505 value: Option<&Value>,
506 summaries: &HashMap<String, &Value>,
507 label: &str,
508) -> Result<Vec<Connection>> {
509 let mut result = Vec::new();
510 let mut seen = HashSet::new();
511 for entry in value.and_then(Value::as_array).into_iter().flatten() {
512 let id = entry
513 .as_str()
514 .or_else(|| entry.get("id").and_then(Value::as_str))
515 .ok_or_else(|| Error::new(format!("{label} has no node ID")))?
516 .to_owned();
517 canonical_node_id(&id)?;
518 if !seen.insert(id.clone()) {
519 continue;
520 }
521 let summary = summaries.get(&id).copied();
522 result.push(Connection {
523 id,
524 short_name: entry
525 .get("short_name")
526 .and_then(Value::as_str)
527 .or_else(|| summary.and_then(|value| value.get("short_name")?.as_str()))
528 .unwrap_or_default()
529 .to_owned(),
530 short_description: entry
531 .get("short_description")
532 .and_then(Value::as_str)
533 .or_else(|| summary.and_then(|value| value.get("short_description")?.as_str()))
534 .unwrap_or_default()
535 .to_owned(),
536 });
537 }
538 Ok(result)
539}
540
541fn string_ids(value: Option<&Value>, label: &str) -> Result<Vec<String>> {
542 let mut result = Vec::new();
543 let mut seen = HashSet::new();
544 for entry in value.and_then(Value::as_array).into_iter().flatten() {
545 let id = entry
546 .as_str()
547 .ok_or_else(|| Error::new(format!("{label} ID must be a string")))?
548 .to_owned();
549 if seen.insert(id.clone()) {
550 result.push(id);
551 }
552 }
553 Ok(result)
554}
555
556fn canonical_node_id(value: &str) -> Result<()> {
557 value
558 .parse::<NodeId>()
559 .map(|_| ())
560 .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
561}
562
563fn required_string(value: &Value, key: &str) -> Result<String> {
564 value
565 .get(key)
566 .and_then(Value::as_str)
567 .map(str::to_owned)
568 .ok_or_else(|| Error::new(format!("Kweb node has no string {key}")))
569}
570
571fn optional_string(value: &Value, key: &str) -> String {
572 value
573 .get(key)
574 .and_then(Value::as_str)
575 .unwrap_or_default()
576 .to_owned()
577}
578
579fn format_node(identifier: &str, node: &NodeDraft) -> String {
580 [
581 format!("Node ID: {identifier}"),
582 format!("Node name: {}", fallback(&node.short_name)),
583 format!("Node summary: {}", fallback(&node.short_description)),
584 format!("Node owner ID: {}", fallback(&node.owner)),
585 "Node long description:".into(),
586 indent(&node.long_description),
587 format!(
588 "Fixed connection IDs: {}",
589 list_or_none(&node.fixed_connections)
590 ),
591 format!(
592 "Recent connection IDs: {}",
593 list_or_none(&node.recent_connections)
594 ),
595 ]
596 .join("\n")
597}
598
599fn indent(value: &str) -> String {
600 fallback(value)
601 .lines()
602 .map(|line| format!(" {line}"))
603 .collect::<Vec<_>>()
604 .join("\n")
605}
606
607fn fallback(value: &str) -> &str {
608 if value.trim().is_empty() {
609 "(none)"
610 } else {
611 value
612 }
613}
614
615fn list_or_none(values: &[String]) -> String {
616 if values.is_empty() {
617 "none".into()
618 } else {
619 values.join(", ")
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use serde_json::json;
627
628 fn id(index: u8) -> String {
629 NodeId::from_bytes([0, 0, 0, 0, 0, index])
630 .unwrap()
631 .to_string()
632 }
633
634 fn connection(index: u8) -> Connection {
635 Connection {
636 id: id(index),
637 short_name: format!("Node {index}"),
638 short_description: format!("Summary {index}"),
639 }
640 }
641
642 fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
643 Node {
644 id: id(index),
645 short_name: format!("Node {index}"),
646 short_description: format!("Summary {index}"),
647 long_description: format!("Long description {index}"),
648 owner: id(1),
649 fixed_connections: fixed.iter().copied().map(connection).collect(),
650 recent_connections: recent.iter().copied().map(connection).collect(),
651 objects: vec![],
652 last_modified_by: "test-model-high".into(),
653 last_modified_at: Some("2026-07-28T00:00:00Z".into()),
654 }
655 }
656
657 fn draft(index: u8, recent: &[u8]) -> NodeDraft {
658 NodeDraft {
659 short_name: format!("Node {index}"),
660 short_description: format!("Summary {index}"),
661 long_description: format!("Long description {index}"),
662 owner: id(1),
663 fixed_connections: Vec::new(),
664 recent_connections: recent.iter().map(|value| id(*value)).collect(),
665 objects: Vec::new(),
666 }
667 }
668
669 #[test]
670 fn parses_the_kweb_wire_shape_into_typed_connections() {
671 let parsed = Node::from_kweb_value(&json!({
672 "id": id(1),
673 "owner_node_id": id(1),
674 "short_name": "Root",
675 "short_description": "Root summary",
676 "long_description": "Root details",
677 "fixed_connections": [id(2)],
678 "recent_connections": [id(3), id(3)],
679 "objects": [],
680 "connection_summaries": [
681 {"id":id(2),"short_name":"Fixed","short_description":"Fixed summary"},
682 {"id":id(3),"short_name":"Recent","short_description":"Recent summary"}
683 ]
684 }))
685 .unwrap();
686 assert_eq!(
687 parsed.fixed_connections,
688 vec![Connection {
689 id: id(2),
690 short_name: "Fixed".into(),
691 short_description: "Fixed summary".into(),
692 }]
693 );
694 assert_eq!(parsed.recent_connections.len(), 1);
695 assert_eq!(parsed.recent_connections[0].short_name, "Recent");
696 }
697
698 #[test]
699 fn loaded_nodes_take_precedence_over_the_fixed_role() {
700 let mut context = Context::new(vec![id(1)]).unwrap();
701 context
702 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
703 .unwrap();
704 let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
705 assert!(report.promoted_from_fixed);
706 assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
707 assert!(context.fixed_node_ids().is_empty());
708 assert_eq!(
709 context
710 .box_specs(&BTreeMap::new(), &[])
711 .unwrap()
712 .iter()
713 .map(|spec| spec.kind)
714 .collect::<Vec<_>>(),
715 vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Recent]
716 );
717 }
718
719 #[test]
720 fn full_node_kinds_share_one_body_format_without_active_connections() {
721 let mut context = Context::new(vec![id(1)]).unwrap();
722 context
723 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
724 .unwrap();
725 let create = StagedCreate {
726 pending_id: "pending:1".into(),
727 data: draft(3, &[]),
728 };
729 let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
730 assert_eq!(specs[0].kind.name(), "Kweb loaded node");
731 assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
732 assert_eq!(specs[2].kind.name(), "Kweb staged node");
733 for spec in &specs[..3] {
734 assert!(spec.text.contains("Node ID:"));
735 assert!(spec.text.contains("Node name:"));
736 assert!(spec.text.contains("Node owner ID:"));
737 assert!(spec.text.contains("Fixed connection IDs:"));
738 assert!(spec.text.contains("Recent connection IDs:"));
739 assert!(!spec.text.contains("Active"));
740 }
741 assert_eq!(
742 specs[0].text,
743 concat!(
744 "Node ID: AAAAAAAB\n",
745 "Node name: Node 1\n",
746 "Node summary: Summary 1\n",
747 "Node owner ID: AAAAAAAB\n",
748 "Node long description:\n",
749 " Long description 1\n",
750 "Fixed connection IDs: AAAAAAAC\n",
751 "Recent connection IDs: none"
752 )
753 );
754 }
755
756 #[test]
757 fn all_recent_connections_share_one_globally_deduplicated_box() {
758 let mut context = Context::new(vec![id(1)]).unwrap();
759 context
760 .apply_load(node(1, &[2], &[4, 5]), vec![node(2, &[], &[5, 6, 7])])
761 .unwrap();
762 let creates = vec![StagedCreate {
763 pending_id: "pending:1".into(),
764 data: draft(3, &[6, 7]),
765 }];
766 let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
767 let recent = specs
768 .iter()
769 .filter(|spec| spec.kind == BoxKind::Recent)
770 .collect::<Vec<_>>();
771 assert_eq!(recent.len(), 1);
772 assert_eq!(
773 recent[0].text,
774 format!(
775 concat!(
776 "Recent connections\n",
777 "{} · Node 4: Summary 4\n",
778 "{} · Node 5: Summary 5\n",
779 "{} · Node 6: Summary 6\n",
780 "{} · Node 7: Summary 7"
781 ),
782 id(4),
783 id(5),
784 id(6),
785 id(7)
786 )
787 );
788 }
789
790 #[test]
791 fn empty_recent_projection_is_still_one_exact_box() {
792 let mut context = Context::new(vec![id(1)]).unwrap();
793 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
794 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
795 assert_eq!(specs.len(), 2);
796 assert_eq!(specs[1].kind, BoxKind::Recent);
797 assert_eq!(specs[1].text, "Recent connections\nNone.");
798 }
799
800 #[test]
801 fn recent_projection_rejects_missing_name_or_description() {
802 for missing_name in [true, false] {
803 let mut source = node(1, &[], &[2]);
804 if missing_name {
805 source.recent_connections[0].short_name.clear();
806 } else {
807 source.recent_connections[0].short_description.clear();
808 }
809 let mut context = Context::new(vec![id(1)]).unwrap();
810 context.apply_load(source, Vec::new()).unwrap();
811 assert_eq!(
812 context
813 .box_specs(&BTreeMap::new(), &[])
814 .unwrap_err()
815 .to_string(),
816 format!(
817 "recent connection {} must resolve to a nonempty short name and short description",
818 id(2)
819 )
820 );
821 }
822 }
823
824 #[test]
825 fn staged_updates_drive_full_text_and_recent_projection() {
826 let mut context = Context::new(vec![id(1)]).unwrap();
827 context
828 .apply_load(node(1, &[3], &[2]), vec![node(3, &[], &[])])
829 .unwrap();
830 let mut updates = BTreeMap::new();
831 updates.insert(id(1), draft(9, &[3]));
832 let specs = context.box_specs(&updates, &[]).unwrap();
833 assert!(specs[0].text.contains("Node name: Node 9"));
834 assert!(specs[0].staged_node.is_some());
835 assert!(!specs.last().unwrap().text.contains(&id(2)));
836 assert!(specs.last().unwrap().text.contains(&id(3)));
837 }
838}