Skip to main content

vector_core/
rail_layout.rs

1//! The community rail's arrangement: an ordered list of communities and the
2//! folders that group them, synced across the account's own devices.
3//!
4//! On the wire it is a [`crate::synced_prefs::Pref::Rail`] document — a
5//! self-encrypted kind 30078 with the d tag `vector/rail` — deliberately
6//! separate from the Community List, which carries join material. A drag is the
7//! most frequent write the UI makes, and the list that holds keys is the last
8//! document that should absorb that traffic: it is fragmented against a byte
9//! ceiling, and it has already been wedged once by growing past one. Here a
10//! failed write costs a session of default ordering and nothing else.
11//!
12//! ```text
13//!   kind:    30078 (APPLICATION_SPECIFIC)
14//!   tags:    ["d", "vector/rail"]
15//!   content: nip44(self, {"v":1,"nodes":[
16//!              {"type":"item","key":"<community-id>"},
17//!              {"type":"folder","id":"…","name":"Work","hue":210,"keys":["…","…"]}
18//!            ]})
19//! ```
20//!
21//! **Keys are opaque and never dropped.** A community this device has not
22//! synced yet keeps its place through every read and republish; rendering
23//! simply skips what it cannot resolve. Dropping unknown keys would let the
24//! device that knows least flatten everyone else's folders. The single
25//! exception is [`RailLayout::remove_key`], called when the user LEAVES a
26//! community: without it a later rejoin would silently reappear inside its old
27//! folder at its old index.
28//!
29//! Every operation here is pure, so the arrangement can be tested without a
30//! relay and the UI stays a view over it.
31
32use serde::{Deserialize, Serialize};
33
34/// The version this build writes. A document from the future is read, rendered
35/// and republished untouched, but never EDITED: an older build cannot know what
36//  a newer node type means, and rewriting the list would drop it.
37pub const RAIL_VERSION: u32 = 1;
38
39/// Folders per account, and communities per folder. High enough that nobody
40/// sane meets them, low enough that a malformed document cannot make the rail
41/// unrenderable.
42const MAX_FOLDERS: usize = 64;
43const MAX_FOLDER_NAME: usize = 48;
44
45/// One row of the rail: a community on its own, or a folder holding several.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "type", rename_all = "lowercase")]
48pub enum RailNode {
49    Item {
50        key: String,
51    },
52    Folder {
53        id: String,
54        #[serde(default)]
55        name: String,
56        /// Degrees on the colour wheel, 0-359. Absent means the neutral grey
57        /// a folder nobody coloured wears.
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        hue: Option<u16>,
60        #[serde(default)]
61        keys: Vec<String>,
62    },
63}
64
65impl RailNode {
66    pub fn item(key: impl Into<String>) -> Self {
67        RailNode::Item { key: key.into() }
68    }
69}
70
71/// What a drag picked up.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(tag = "kind", rename_all = "lowercase")]
74pub enum RailDrag {
75    Item { key: String },
76    Folder { id: String },
77}
78
79/// A top-level row, named so a drop can say "before this one" without caring
80/// whether it is a community or a folder.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "kind", rename_all = "lowercase")]
83pub enum RailAnchor {
84    Item { key: String },
85    Folder { id: String },
86}
87
88/// Where a drag let go.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(tag = "at", rename_all = "kebab-case")]
91pub enum RailDrop {
92    /// Insert at the top level, above this row.
93    Before { anchor: RailAnchor },
94    /// Append at the bottom of the top level.
95    End,
96    /// Dropped onto a loose community: the two become a new folder.
97    Combine { with_key: String },
98    /// Into an existing folder, above `before_key` or at its end.
99    IntoFolder {
100        folder_id: String,
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        before_key: Option<String>,
103    },
104}
105
106/// The stored arrangement.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct RailLayout {
109    #[serde(default = "default_version")]
110    pub v: u32,
111    #[serde(default)]
112    pub nodes: Vec<RailNode>,
113}
114
115fn default_version() -> u32 {
116    RAIL_VERSION
117}
118
119impl Default for RailLayout {
120    fn default() -> Self {
121        Self { v: RAIL_VERSION, nodes: Vec::new() }
122    }
123}
124
125impl RailLayout {
126    /// Tolerant parse, node by node: a malformed or future node is skipped
127    /// rather than costing the whole arrangement. One bad entry must not wedge
128    /// a rail the user can otherwise still use.
129    pub fn from_json(s: &str) -> Self {
130        #[derive(Deserialize)]
131        struct Raw {
132            #[serde(default = "default_version")]
133            v: u32,
134            #[serde(default)]
135            nodes: Vec<serde_json::Value>,
136        }
137        let Ok(raw) = serde_json::from_str::<Raw>(s) else { return Self::default() };
138        Self {
139            v: raw.v,
140            nodes: raw
141                .nodes
142                .into_iter()
143                .filter_map(|n| serde_json::from_value::<RailNode>(n).ok())
144                .collect(),
145        }
146    }
147
148    pub fn to_json(&self) -> String {
149        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"nodes\":[]}".to_string())
150    }
151
152    /// Whether this build may rewrite the document. A newer version can hold
153    /// node types this one drops on parse, so editing would publish a lossy
154    /// copy over the device that understands it.
155    pub fn is_editable(&self) -> bool {
156        self.v <= RAIL_VERSION
157    }
158
159    /// Every community key in visual order, folders flattened in place.
160    pub fn flatten(&self) -> Vec<String> {
161        let mut out = Vec::new();
162        for node in &self.nodes {
163            match node {
164                RailNode::Item { key } => out.push(key.clone()),
165                RailNode::Folder { keys, .. } => out.extend(keys.iter().cloned()),
166            }
167        }
168        out
169    }
170
171    pub fn contains(&self, key: &str) -> bool {
172        self.nodes.iter().any(|node| match node {
173            RailNode::Item { key: k } => k == key,
174            RailNode::Folder { keys, .. } => keys.iter().any(|k| k == key),
175        })
176    }
177
178    fn folder_mut(&mut self, folder_id: &str) -> Option<&mut RailNode> {
179        self.nodes.iter_mut().find(|node| matches!(node, RailNode::Folder { id, .. } if id == folder_id))
180    }
181
182    /// One key appears once, a folder id appears once, and a folder holding a
183    /// single community becomes that community again — a folder of one is a
184    /// box with nothing to group, and dragging the second-to-last member out is
185    /// how you say you are done with it.
186    pub fn normalize(&mut self) {
187        let mut seen_keys = std::collections::HashSet::new();
188        let mut seen_folders = std::collections::HashSet::new();
189        let mut out = Vec::with_capacity(self.nodes.len());
190        for node in std::mem::take(&mut self.nodes) {
191            match node {
192                RailNode::Item { key } => {
193                    if seen_keys.insert(key.clone()) {
194                        out.push(RailNode::Item { key });
195                    }
196                }
197                RailNode::Folder { id, name, hue, keys } => {
198                    if !seen_folders.insert(id.clone()) {
199                        continue;
200                    }
201                    let keys: Vec<String> =
202                        keys.into_iter().filter(|k| seen_keys.insert(k.clone())).collect();
203                    match keys.len() {
204                        0 => continue,
205                        1 => out.push(RailNode::Item { key: keys.into_iter().next().unwrap() }),
206                        _ => out.push(RailNode::Folder {
207                            id,
208                            name: truncate_name(&name),
209                            hue: hue.map(|h| h % 360),
210                            keys,
211                        }),
212                    }
213                }
214            }
215        }
216        self.nodes = out;
217    }
218
219    /// The arrangement as it should paint: the stored one, plus any community
220    /// the user has joined but never arranged, appended in the order the caller
221    /// discovered them.
222    ///
223    /// Nothing is written here. A user who has never dragged anything stores no
224    /// document at all, and their rail still orders itself.
225    pub fn merged_with(&self, live_keys: &[String]) -> RailLayout {
226        let mut out = self.clone();
227        out.normalize();
228        let mut known: std::collections::HashSet<String> = out.flatten().into_iter().collect();
229        for key in live_keys {
230            if known.insert(key.clone()) {
231                out.nodes.push(RailNode::item(key.clone()));
232            }
233        }
234        out
235    }
236
237    /// Lift a key out of wherever it sits, without normalizing — the first half
238    /// of every move.
239    fn detach(&mut self, key: &str) {
240        self.nodes.retain_mut(|node| match node {
241            RailNode::Item { key: k } => k.as_str() != key,
242            RailNode::Folder { keys, .. } => {
243                keys.retain(|k| k != key);
244                true
245            }
246        });
247    }
248
249    /// Drop a community from the arrangement for good.
250    ///
251    /// Destructive on purpose, unlike the render-time skip: the layout keeps
252    /// keys it cannot resolve forever, so without this a user who left a
253    /// community and rejoined it later would find it back inside its old
254    /// folder.
255    pub fn remove_key(&mut self, key: &str) {
256        self.detach(key);
257        self.normalize();
258    }
259
260    /// Position of a top-level row.
261    fn index_of(&self, anchor: &RailAnchor) -> Option<usize> {
262        self.nodes.iter().position(|node| match (node, anchor) {
263            (RailNode::Item { key }, RailAnchor::Item { key: want }) => key == want,
264            (RailNode::Folder { id, .. }, RailAnchor::Folder { id: want }) => id == want,
265            _ => false,
266        })
267    }
268
269    /// Apply a completed drag. `new_folder_id` supplies the id for a folder
270    /// born from a combine drop, taken as a parameter so the whole thing stays
271    /// pure and testable.
272    pub fn apply_drop(
273        &mut self,
274        source: &RailDrag,
275        target: &RailDrop,
276        new_folder_id: &str,
277    ) -> Result<(), String> {
278        if !self.is_editable() {
279            return Err("This rail was arranged by a newer version of Vector".to_string());
280        }
281        match source {
282            RailDrag::Folder { id } => self.move_folder(id, target),
283            RailDrag::Item { key } => self.move_item(key, target, new_folder_id),
284        }?;
285        self.normalize();
286        Ok(())
287    }
288
289    /// A folder only ever moves among the top-level rows: nesting a folder in a
290    /// folder is a hierarchy nobody asked the rail to have.
291    fn move_folder(&mut self, folder_id: &str, target: &RailDrop) -> Result<(), String> {
292        let from = self
293            .index_of(&RailAnchor::Folder { id: folder_id.to_string() })
294            .ok_or("No such folder")?;
295        let insert_at = match target {
296            RailDrop::End => self.nodes.len(),
297            RailDrop::Before { anchor } => self.index_of(anchor).ok_or("No such row")?,
298            // A folder dropped onto a community or into another folder lands
299            // above it rather than inside.
300            RailDrop::Combine { with_key } => self
301                .index_of(&RailAnchor::Item { key: with_key.clone() })
302                .ok_or("No such community")?,
303            RailDrop::IntoFolder { folder_id: onto, .. } => self
304                .index_of(&RailAnchor::Folder { id: onto.clone() })
305                .ok_or("No such folder")?,
306        };
307        let node = self.nodes.remove(from);
308        let insert_at = if insert_at > from { insert_at - 1 } else { insert_at };
309        self.nodes.insert(insert_at.min(self.nodes.len()), node);
310        Ok(())
311    }
312
313    fn move_item(&mut self, key: &str, target: &RailDrop, new_folder_id: &str) -> Result<(), String> {
314        match target {
315            RailDrop::Combine { with_key } => {
316                if with_key == key {
317                    return Ok(());
318                }
319                let folders = self
320                    .nodes
321                    .iter()
322                    .filter(|n| matches!(n, RailNode::Folder { .. }))
323                    .count();
324                if folders >= MAX_FOLDERS {
325                    return Err(format!("A rail holds at most {MAX_FOLDERS} folders"));
326                }
327                let at = self
328                    .index_of(&RailAnchor::Item { key: with_key.clone() })
329                    .ok_or("No such community")?;
330                // Read the id out before detaching: dragging one of the pair
331                // onto the other must not lose the target's own position.
332                self.detach(key);
333                let at = self
334                    .index_of(&RailAnchor::Item { key: with_key.clone() })
335                    .unwrap_or(at.min(self.nodes.len()));
336                self.nodes[at] = RailNode::Folder {
337                    id: new_folder_id.to_string(),
338                    name: String::new(),
339                    hue: None,
340                    keys: vec![with_key.clone(), key.to_string()],
341                };
342                Ok(())
343            }
344            RailDrop::IntoFolder { folder_id, before_key } => {
345                self.folder_mut(folder_id).ok_or("No such folder")?;
346                self.detach(key);
347                let Some(RailNode::Folder { keys, .. }) = self.folder_mut(folder_id) else {
348                    return Err("No such folder".to_string());
349                };
350                let at = before_key
351                    .as_ref()
352                    .and_then(|b| keys.iter().position(|k| k == b))
353                    .unwrap_or(keys.len());
354                keys.insert(at, key.to_string());
355                Ok(())
356            }
357            RailDrop::End => {
358                self.detach(key);
359                self.nodes.push(RailNode::item(key));
360                Ok(())
361            }
362            RailDrop::Before { anchor } => {
363                // Resolved AFTER the detach, because lifting the key out can
364                // dissolve a folder above the anchor and shift every index.
365                self.detach(key);
366                let at = self.index_of(anchor).unwrap_or(self.nodes.len());
367                self.nodes.insert(at, RailNode::item(key));
368                Ok(())
369            }
370        }
371    }
372
373    /// Name a folder. An empty name is allowed and means "unnamed" — the rail
374    /// shows the avatars alone, exactly as it does before anyone names one.
375    pub fn rename_folder(&mut self, folder_id: &str, name: &str) -> Result<(), String> {
376        if !self.is_editable() {
377            return Err("This rail was arranged by a newer version of Vector".to_string());
378        }
379        let Some(RailNode::Folder { name: slot, .. }) = self.folder_mut(folder_id) else {
380            return Err("No such folder".to_string());
381        };
382        *slot = truncate_name(name);
383        Ok(())
384    }
385
386    /// Colour a folder, or clear it back to the default grey with `None`.
387    pub fn set_folder_hue(&mut self, folder_id: &str, hue: Option<u16>) -> Result<(), String> {
388        if !self.is_editable() {
389            return Err("This rail was arranged by a newer version of Vector".to_string());
390        }
391        let Some(RailNode::Folder { hue: slot, .. }) = self.folder_mut(folder_id) else {
392            return Err("No such folder".to_string());
393        };
394        *slot = hue.map(|h| h % 360);
395        Ok(())
396    }
397
398    /// Unpack a folder, leaving its communities loose and in place.
399    pub fn dissolve_folder(&mut self, folder_id: &str) -> Result<(), String> {
400        if !self.is_editable() {
401            return Err("This rail was arranged by a newer version of Vector".to_string());
402        }
403        let at = self
404            .index_of(&RailAnchor::Folder { id: folder_id.to_string() })
405            .ok_or("No such folder")?;
406        let RailNode::Folder { keys, .. } = self.nodes.remove(at) else {
407            return Err("No such folder".to_string());
408        };
409        for (offset, key) in keys.into_iter().enumerate() {
410            self.nodes.insert(at + offset, RailNode::item(key));
411        }
412        self.normalize();
413        Ok(())
414    }
415
416    /// Give every folder born without one a stable id. Ids are minted by the
417    /// caller so the pure ops stay free of randomness.
418    pub fn assign_folder_ids(&mut self, mut next_id: impl FnMut() -> String) {
419        let mut taken: std::collections::HashSet<String> = self
420            .nodes
421            .iter()
422            .filter_map(|n| match n {
423                RailNode::Folder { id, .. } if !id.is_empty() => Some(id.clone()),
424                _ => None,
425            })
426            .collect();
427        for node in self.nodes.iter_mut() {
428            if let RailNode::Folder { id, .. } = node {
429                if !id.is_empty() {
430                    continue;
431                }
432                loop {
433                    let candidate = next_id();
434                    if taken.insert(candidate.clone()) {
435                        *id = candidate;
436                        break;
437                    }
438                }
439            }
440        }
441    }
442}
443
444/// A fresh folder id. Short and random rather than sequential: two devices can
445/// each make a folder before either has seen the other's, and an index would
446/// have them collide into one.
447pub fn new_folder_id() -> String {
448    use rand::Rng;
449    let bytes: [u8; 6] = rand::thread_rng().gen();
450    crate::simd::hex::bytes_to_hex_string(&bytes)
451}
452
453/// Folder names ride in a document with a byte ceiling and are read at a glance
454/// anyway, so a pasted essay is cut rather than refused.
455fn truncate_name(name: &str) -> String {
456    let trimmed = name.trim();
457    if trimmed.chars().count() <= MAX_FOLDER_NAME {
458        return trimmed.to_string();
459    }
460    trimmed.chars().take(MAX_FOLDER_NAME).collect()
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn layout(nodes: Vec<RailNode>) -> RailLayout {
468        RailLayout { v: RAIL_VERSION, nodes }
469    }
470
471    fn folder(id: &str, keys: &[&str]) -> RailNode {
472        RailNode::Folder {
473            id: id.to_string(),
474            name: String::new(),
475            hue: None,
476            keys: keys.iter().map(|k| k.to_string()).collect(),
477        }
478    }
479
480    #[test]
481    fn a_never_arranged_rail_still_orders_itself() {
482        let stored = RailLayout::default();
483        let live = vec!["a".to_string(), "b".to_string()];
484        assert_eq!(stored.merged_with(&live).flatten(), live);
485        assert!(stored.nodes.is_empty(), "merging paints, it does not write");
486    }
487
488    #[test]
489    fn a_new_community_lands_at_the_end_rather_than_the_top() {
490        let stored = layout(vec![RailNode::item("b"), RailNode::item("a")]);
491        let merged = stored.merged_with(&["a".to_string(), "b".to_string(), "c".to_string()]);
492        assert_eq!(merged.flatten(), vec!["b", "a", "c"]);
493    }
494
495    #[test]
496    fn keys_this_device_cannot_resolve_keep_their_place() {
497        // The whole point of the opaque-key rule: a device mid-sync must not be
498        // able to flatten the arrangement it has not finished reading.
499        let stored = layout(vec![RailNode::item("unsynced"), folder("f1", &["a", "b"])]);
500        let merged = stored.merged_with(&["a".to_string(), "b".to_string()]);
501        assert_eq!(merged.flatten(), vec!["unsynced", "a", "b"]);
502    }
503
504    #[test]
505    fn leaving_a_community_forgets_where_it_sat() {
506        let mut l = layout(vec![folder("f1", &["a", "b", "c"]), RailNode::item("d")]);
507        l.remove_key("b");
508        assert_eq!(l.flatten(), vec!["a", "c", "d"]);
509        // And rejoining puts it at the end, not back in the folder.
510        assert_eq!(l.merged_with(&["a".into(), "b".into(), "c".into(), "d".into()]).flatten(), vec!["a", "c", "d", "b"]);
511    }
512
513    #[test]
514    fn a_folder_emptied_to_one_becomes_that_community_again() {
515        let mut l = layout(vec![folder("f1", &["a", "b"]), RailNode::item("c")]);
516        l.apply_drop(&RailDrag::Item { key: "b".into() }, &RailDrop::End, "new").unwrap();
517        assert_eq!(l.flatten(), vec!["a", "c", "b"]);
518        assert!(
519            !l.nodes.iter().any(|n| matches!(n, RailNode::Folder { .. })),
520            "a folder of one has nothing left to group"
521        );
522    }
523
524    #[test]
525    fn dropping_one_community_onto_another_makes_a_folder_in_its_place() {
526        let mut l = layout(vec![RailNode::item("a"), RailNode::item("b"), RailNode::item("c")]);
527        l.apply_drop(&RailDrag::Item { key: "c".into() }, &RailDrop::Combine { with_key: "a".into() }, "f1")
528            .unwrap();
529        l.assign_folder_ids(|| "f1".to_string());
530        assert_eq!(l.flatten(), vec!["a", "c", "b"]);
531        match &l.nodes[0] {
532            RailNode::Folder { keys, id, .. } => {
533                assert_eq!(keys, &vec!["a".to_string(), "c".to_string()]);
534                assert_eq!(id, "f1");
535            }
536            other => panic!("expected a folder at the target's position, got {other:?}"),
537        }
538    }
539
540    #[test]
541    fn a_drop_before_a_row_below_the_source_does_not_overshoot() {
542        let mut l = layout(vec![RailNode::item("a"), RailNode::item("b"), RailNode::item("c")]);
543        l.apply_drop(
544            &RailDrag::Item { key: "a".into() },
545            &RailDrop::Before { anchor: RailAnchor::Item { key: "c".into() } },
546            "f1",
547        )
548        .unwrap();
549        assert_eq!(l.flatten(), vec!["b", "a", "c"]);
550    }
551
552    #[test]
553    fn a_folder_moves_among_rows_without_nesting() {
554        let mut l = layout(vec![RailNode::item("a"), folder("f1", &["b", "c"]), RailNode::item("d")]);
555        l.apply_drop(
556            &RailDrag::Folder { id: "f1".into() },
557            &RailDrop::Before { anchor: RailAnchor::Item { key: "a".into() } },
558            "new",
559        )
560        .unwrap();
561        assert_eq!(l.flatten(), vec!["b", "c", "a", "d"]);
562        assert_eq!(l.nodes.len(), 3, "a folder dropped on a row sits beside it, never inside");
563    }
564
565    #[test]
566    fn dropping_into_a_folder_respects_the_position_it_was_dropped_at() {
567        let mut l = layout(vec![folder("f1", &["a", "b"]), RailNode::item("c")]);
568        l.apply_drop(
569            &RailDrag::Item { key: "c".into() },
570            &RailDrop::IntoFolder { folder_id: "f1".into(), before_key: Some("b".into()) },
571            "new",
572        )
573        .unwrap();
574        assert_eq!(l.flatten(), vec!["a", "c", "b"]);
575    }
576
577    #[test]
578    fn dissolving_leaves_the_communities_where_the_folder_was() {
579        let mut l = layout(vec![RailNode::item("x"), folder("f1", &["a", "b"]), RailNode::item("y")]);
580        l.dissolve_folder("f1").unwrap();
581        assert_eq!(l.flatten(), vec!["x", "a", "b", "y"]);
582    }
583
584    #[test]
585    fn a_duplicate_key_resolves_to_its_first_position() {
586        let mut l = layout(vec![RailNode::item("a"), folder("f1", &["a", "b", "c"])]);
587        l.normalize();
588        assert_eq!(l.flatten(), vec!["a", "b", "c"], "a key belongs in exactly one place");
589    }
590
591    #[test]
592    fn malformed_nodes_are_skipped_rather_than_costing_the_arrangement() {
593        let json = r#"{"v":1,"nodes":[{"type":"item","key":"a"},{"type":"wormhole"},{"type":"item","key":"b"}]}"#;
594        assert_eq!(RailLayout::from_json(json).flatten(), vec!["a", "b"]);
595        assert!(RailLayout::from_json("not json").nodes.is_empty());
596    }
597
598    #[test]
599    fn a_document_from_the_future_is_rendered_but_never_rewritten() {
600        let mut l = RailLayout { v: RAIL_VERSION + 1, nodes: vec![RailNode::item("a")] };
601        assert!(!l.is_editable());
602        assert_eq!(l.flatten(), vec!["a"], "it still paints");
603        assert!(l.apply_drop(&RailDrag::Item { key: "a".into() }, &RailDrop::End, "f").is_err());
604        assert!(l.rename_folder("f1", "Work").is_err());
605    }
606
607    #[test]
608    fn a_round_trip_through_json_preserves_names_and_hues() {
609        let l = layout(vec![RailNode::Folder {
610            id: "f1".into(),
611            name: "Work".into(),
612            hue: Some(210),
613            keys: vec!["a".into(), "b".into()],
614        }]);
615        let back = RailLayout::from_json(&l.to_json());
616        assert_eq!(back, l);
617    }
618
619    #[test]
620    fn an_absent_hue_stays_absent_on_the_wire() {
621        let l = layout(vec![folder("f1", &["a", "b"])]);
622        assert!(!l.to_json().contains("hue"), "the default colour is not a stored value");
623    }
624
625    #[test]
626    fn folder_ids_are_minted_only_for_folders_that_lack_one() {
627        let mut l = layout(vec![folder("keep", &["a", "b"]), folder("", &["c", "d"])]);
628        let mut n = 0;
629        l.assign_folder_ids(|| {
630            n += 1;
631            format!("minted{n}")
632        });
633        let ids: Vec<_> = l
634            .nodes
635            .iter()
636            .filter_map(|node| match node {
637                RailNode::Folder { id, .. } => Some(id.as_str()),
638                _ => None,
639            })
640            .collect();
641        assert_eq!(ids, vec!["keep", "minted1"]);
642    }
643
644    #[test]
645    fn a_long_folder_name_is_cut_rather_than_refused() {
646        let mut l = layout(vec![folder("f1", &["a", "b"])]);
647        l.rename_folder("f1", &"x".repeat(200)).unwrap();
648        match &l.nodes[0] {
649            RailNode::Folder { name, .. } => assert_eq!(name.chars().count(), MAX_FOLDER_NAME),
650            other => panic!("expected a folder, got {other:?}"),
651        }
652    }
653}