1use serde::{Deserialize, Serialize};
33
34pub const RAIL_VERSION: u32 = 1;
38
39const MAX_FOLDERS: usize = 64;
43const MAX_FOLDER_NAME: usize = 48;
44
45#[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 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(tag = "at", rename_all = "kebab-case")]
91pub enum RailDrop {
92 Before { anchor: RailAnchor },
94 End,
96 Combine { with_key: String },
98 IntoFolder {
100 folder_id: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 before_key: Option<String>,
103 },
104}
105
106#[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 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 pub fn is_editable(&self) -> bool {
156 self.v <= RAIL_VERSION
157 }
158
159 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 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 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 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 pub fn remove_key(&mut self, key: &str) {
256 self.detach(key);
257 self.normalize();
258 }
259
260 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 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 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 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 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 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 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 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 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 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
444pub 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
453fn 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 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 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}