Skip to main content

kcode_kweb_context/
lib.rs

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}