1use std::path::{Path, PathBuf};
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5#[non_exhaustive]
6pub enum PlaceOrigin {
7 User,
9 Code,
11}
12
13impl PlaceOrigin {
14 fn as_compact_char(self) -> char {
15 match self {
16 PlaceOrigin::User => 'u',
17 PlaceOrigin::Code => 'c',
18 }
19 }
20
21 fn from_compact_char(ch: char) -> Option<Self> {
22 match ch {
23 'u' => Some(PlaceOrigin::User),
24 'c' => Some(PlaceOrigin::Code),
25 _ => None,
26 }
27 }
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32#[non_exhaustive]
33pub struct Place {
34 pub label: String,
36 pub path: PathBuf,
38 pub origin: PlaceOrigin,
40 pub separator_thickness: Option<u32>,
44}
45
46impl Place {
47 pub fn new(label: impl Into<String>, path: PathBuf, origin: PlaceOrigin) -> Self {
49 Self {
50 label: label.into(),
51 path,
52 origin,
53 separator_thickness: None,
54 }
55 }
56
57 pub fn user(label: impl Into<String>, path: PathBuf) -> Self {
59 Self::new(label, path, PlaceOrigin::User)
60 }
61
62 pub fn code(label: impl Into<String>, path: PathBuf) -> Self {
64 Self::new(label, path, PlaceOrigin::Code)
65 }
66
67 pub fn separator(thickness: u32) -> Self {
69 Self {
70 label: String::new(),
71 path: PathBuf::new(),
72 origin: PlaceOrigin::User,
73 separator_thickness: Some(thickness.max(1)),
74 }
75 }
76
77 pub fn is_separator(&self) -> bool {
79 self.separator_thickness.is_some()
80 }
81}
82
83#[derive(Clone, Debug, Default, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct PlaceGroup {
87 pub label: String,
89 pub display_order: usize,
91 pub default_opened: bool,
93 pub places: Vec<Place>,
95}
96
97impl PlaceGroup {
98 pub fn new(label: impl Into<String>) -> Self {
100 Self {
101 label: label.into(),
102 display_order: 1000,
103 default_opened: false,
104 places: Vec::new(),
105 }
106 }
107}
108
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct PlacesSerializeOptions {
113 pub include_code_places: bool,
115}
116
117#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct PlacesMergeOptions {
121 pub overwrite_group_metadata: bool,
124}
125
126#[derive(Clone, Debug)]
131#[non_exhaustive]
132pub struct Places {
133 pub groups: Vec<PlaceGroup>,
135}
136
137impl Places {
138 pub const SYSTEM_GROUP: &'static str = "System";
140 pub const BOOKMARKS_GROUP: &'static str = "Bookmarks";
142
143 pub fn new() -> Self {
145 let mut p = Self { groups: Vec::new() };
146 p.ensure_default_groups();
147 p.refresh_system_places();
148 p
149 }
150
151 pub fn is_empty(&self) -> bool {
153 self.groups.iter().all(|g| g.places.is_empty())
154 }
155
156 pub fn ensure_default_groups(&mut self) {
158 self.ensure_group(Self::SYSTEM_GROUP);
159 self.ensure_group(Self::BOOKMARKS_GROUP);
160
161 if let Some(g) = self
162 .groups
163 .iter_mut()
164 .find(|g| g.label == Self::SYSTEM_GROUP)
165 {
166 g.display_order = 0;
167 g.default_opened = true;
168 }
169 if let Some(g) = self
170 .groups
171 .iter_mut()
172 .find(|g| g.label == Self::BOOKMARKS_GROUP)
173 {
174 g.display_order = 10;
175 g.default_opened = true;
176 }
177 }
178
179 pub fn refresh_system_places(&mut self) {
184 let group = self.ensure_group_mut(Self::SYSTEM_GROUP);
185 group.places.clear();
186
187 if let Some(home) = home_dir() {
188 group.places.push(Place::code("Home", home));
189 }
190
191 group.places.push(Place::code(
192 "Root",
193 PathBuf::from(std::path::MAIN_SEPARATOR.to_string()),
194 ));
195
196 #[cfg(target_os = "windows")]
197 {
198 for d in windows_drives() {
199 group.places.push(Place::code(d.clone(), PathBuf::from(d)));
200 }
201 }
202 }
203
204 pub fn add_place(&mut self, group_label: impl Into<String>, place: Place) {
206 let group_label = group_label.into();
207 let group = self.ensure_group_mut(&group_label);
208 if !place.is_separator() && group.places.iter().any(|p| p.path == place.path) {
209 return;
210 }
211 group.places.push(place);
212 }
213
214 pub fn add_place_separator(&mut self, group_label: impl Into<String>, thickness: u32) {
216 self.add_place(group_label, Place::separator(thickness));
217 }
218
219 pub fn add_bookmark(&mut self, label: impl Into<String>, path: PathBuf) {
221 self.add_place(Self::BOOKMARKS_GROUP, Place::user(label, path));
222 }
223
224 pub fn add_bookmark_path(&mut self, path: PathBuf) {
226 let label = default_label_for_path(&path);
227 self.add_bookmark(label, path);
228 }
229
230 pub fn remove_place_path(&mut self, group_label: &str, path: &Path) -> bool {
232 let Some(g) = self.groups.iter_mut().find(|g| g.label == group_label) else {
233 return false;
234 };
235 let Some(i) = g.places.iter().position(|p| p.path == path) else {
236 return false;
237 };
238 g.places.remove(i);
239 true
240 }
241
242 pub fn add_group(&mut self, label: impl Into<String>) -> bool {
245 let label = label.into();
246 if self.groups.iter().any(|g| g.label == label) {
247 return false;
248 }
249 let mut g = PlaceGroup::new(label);
250 let max_order = self
251 .groups
252 .iter()
253 .filter(|g| g.label != Self::SYSTEM_GROUP)
254 .map(|g| g.display_order)
255 .max()
256 .unwrap_or(100);
257 g.display_order = max_order.saturating_add(1);
258 self.groups.push(g);
259 true
260 }
261
262 pub fn remove_group(&mut self, label: &str) -> bool {
265 let Some(i) = self.groups.iter().position(|g| g.label == label) else {
266 return false;
267 };
268 self.groups.remove(i);
269 true
270 }
271
272 pub fn rename_group(&mut self, from: &str, to: impl Into<String>) -> bool {
275 let to = to.into();
276 if self.groups.iter().any(|g| g.label == to) {
277 return false;
278 }
279 let Some(g) = self.groups.iter_mut().find(|g| g.label == from) else {
280 return false;
281 };
282 g.label = to;
283 true
284 }
285
286 pub fn edit_place_by_path(
290 &mut self,
291 group_label: &str,
292 from_path: &Path,
293 new_label: impl Into<String>,
294 new_path: PathBuf,
295 ) -> bool {
296 let Some(g) = self.groups.iter_mut().find(|g| g.label == group_label) else {
297 return false;
298 };
299 let Some(i) = g.places.iter().position(|p| p.path == from_path) else {
300 return false;
301 };
302 g.places[i].label = new_label.into();
303 g.places[i].path = new_path;
304 true
305 }
306
307 pub fn serialize_compact(&self, opts: PlacesSerializeOptions) -> String {
317 let mut out = String::new();
318 out.push_str("v1\n");
319
320 let mut groups = self.groups.clone();
321 groups.retain(|g| g.label != Self::SYSTEM_GROUP);
322 groups.sort_by(|a, b| {
323 a.display_order
324 .cmp(&b.display_order)
325 .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
326 });
327
328 for g in &groups {
329 out.push_str("g\t");
330 out.push_str(&escape_field(&g.label));
331 out.push('\t');
332 out.push_str(&g.display_order.to_string());
333 out.push('\t');
334 out.push_str(if g.default_opened { "1" } else { "0" });
335 out.push('\n');
336
337 for p in &g.places {
338 if let Some(thickness) = p.separator_thickness {
339 out.push_str("s\t");
340 out.push_str(&escape_field(&g.label));
341 out.push('\t');
342 out.push_str(&thickness.to_string());
343 out.push('\n');
344 continue;
345 }
346 if !opts.include_code_places && p.origin == PlaceOrigin::Code {
347 continue;
348 }
349 out.push_str("p\t");
350 out.push_str(&escape_field(&g.label));
351 out.push('\t');
352 out.push(p.origin.as_compact_char());
353 out.push('\t');
354 out.push_str(&escape_field(&p.label));
355 out.push('\t');
356 out.push_str(&escape_field(&p.path.display().to_string()));
357 out.push('\n');
358 }
359 }
360 out
361 }
362
363 pub fn deserialize_compact(input: &str) -> Result<Self, PlacesDeserializeError> {
366 let mut places = Places { groups: Vec::new() };
367 let mut version_ok = false;
368
369 for (line_idx, raw_line) in input.lines().enumerate() {
370 let line_no = line_idx + 1;
371 let line = raw_line.trim_end_matches('\r');
372 if line.trim().is_empty() {
373 continue;
374 }
375
376 if !version_ok {
377 if line == "v1" {
378 version_ok = true;
379 continue;
380 }
381 return Err(PlacesDeserializeError {
382 line: line_no,
383 message: "missing or unsupported version token".into(),
384 });
385 }
386
387 let (kind, rest) = line
388 .split_once('\t')
389 .ok_or_else(|| PlacesDeserializeError {
390 line: line_no,
391 message: "missing kind field".into(),
392 })?;
393
394 match kind {
395 "g" => {
396 let (raw_group, rest) =
397 rest.split_once('\t')
398 .ok_or_else(|| PlacesDeserializeError {
399 line: line_no,
400 message: "missing group field".into(),
401 })?;
402 let (raw_order, raw_opened) =
403 rest.split_once('\t')
404 .ok_or_else(|| PlacesDeserializeError {
405 line: line_no,
406 message: "missing group metadata fields".into(),
407 })?;
408 let group_label =
409 unescape_field(raw_group).map_err(|msg| PlacesDeserializeError {
410 line: line_no,
411 message: format!("group: {msg}"),
412 })?;
413 if group_label == Places::SYSTEM_GROUP {
414 continue;
415 }
416 let order = raw_order
417 .parse::<usize>()
418 .map_err(|_| PlacesDeserializeError {
419 line: line_no,
420 message: "invalid group order field".into(),
421 })?;
422 let opened = match raw_opened {
423 "0" => false,
424 "1" => true,
425 _ => {
426 return Err(PlacesDeserializeError {
427 line: line_no,
428 message: "invalid group opened field".into(),
429 });
430 }
431 };
432 let group = places.ensure_group_mut(&group_label);
433 group.display_order = order;
434 group.default_opened = opened;
435 }
436 "p" => {
437 let (raw_group, rest) =
438 rest.split_once('\t')
439 .ok_or_else(|| PlacesDeserializeError {
440 line: line_no,
441 message: "missing group field".into(),
442 })?;
443 let (raw_origin, rest) =
444 rest.split_once('\t')
445 .ok_or_else(|| PlacesDeserializeError {
446 line: line_no,
447 message: "missing origin field".into(),
448 })?;
449 let (raw_label, raw_path) =
450 rest.split_once('\t')
451 .ok_or_else(|| PlacesDeserializeError {
452 line: line_no,
453 message: "missing label/path fields".into(),
454 })?;
455
456 let group_label =
457 unescape_field(raw_group).map_err(|msg| PlacesDeserializeError {
458 line: line_no,
459 message: format!("group: {msg}"),
460 })?;
461 if group_label == Places::SYSTEM_GROUP {
462 continue;
463 }
464 let origin_ch =
465 raw_origin
466 .chars()
467 .next()
468 .ok_or_else(|| PlacesDeserializeError {
469 line: line_no,
470 message: "empty origin field".into(),
471 })?;
472 let origin = PlaceOrigin::from_compact_char(origin_ch).ok_or_else(|| {
473 PlacesDeserializeError {
474 line: line_no,
475 message: "invalid origin field".into(),
476 }
477 })?;
478 let label =
479 unescape_field(raw_label).map_err(|msg| PlacesDeserializeError {
480 line: line_no,
481 message: format!("label: {msg}"),
482 })?;
483 let path_s =
484 unescape_field(raw_path).map_err(|msg| PlacesDeserializeError {
485 line: line_no,
486 message: format!("path: {msg}"),
487 })?;
488
489 let path = PathBuf::from(path_s);
490 if path.as_os_str().is_empty() {
491 continue;
492 }
493 let label = if label.trim().is_empty() {
494 default_label_for_path(&path)
495 } else {
496 label
497 };
498 places.add_place(group_label, Place::new(label, path, origin));
499 }
500 "s" => {
501 let (raw_group, raw_thickness) =
502 rest.split_once('\t')
503 .ok_or_else(|| PlacesDeserializeError {
504 line: line_no,
505 message: "missing group/thickness fields".into(),
506 })?;
507 let group_label =
508 unescape_field(raw_group).map_err(|msg| PlacesDeserializeError {
509 line: line_no,
510 message: format!("group: {msg}"),
511 })?;
512 if group_label == Places::SYSTEM_GROUP {
513 continue;
514 }
515 let thickness =
516 raw_thickness
517 .parse::<u32>()
518 .map_err(|_| PlacesDeserializeError {
519 line: line_no,
520 message: "invalid separator thickness field".into(),
521 })?;
522 places.add_place_separator(group_label, thickness);
523 }
524 other => {
525 return Err(PlacesDeserializeError {
526 line: line_no,
527 message: format!("unknown kind `{other}`"),
528 });
529 }
530 }
531 }
532
533 if !version_ok {
534 return Err(PlacesDeserializeError {
535 line: 1,
536 message: "missing or unsupported version token".into(),
537 });
538 }
539
540 places.ensure_default_groups();
543 places.refresh_system_places();
544 Ok(places)
545 }
546
547 pub fn merge_from(&mut self, other: Places, opts: PlacesMergeOptions) {
553 for g in other.groups {
554 if g.label == Self::SYSTEM_GROUP {
555 continue;
556 }
557
558 let label = g.label.clone();
559 let dst = self.ensure_group_mut(&label);
560 if opts.overwrite_group_metadata {
561 dst.display_order = g.display_order;
562 dst.default_opened = g.default_opened;
563 }
564 for place in g.places {
565 self.add_place(label.clone(), place);
566 }
567 }
568 }
569
570 fn ensure_group(&mut self, label: &str) {
571 if self.groups.iter().any(|g| g.label == label) {
572 return;
573 }
574 self.groups.push(PlaceGroup::new(label));
575 }
576
577 fn ensure_group_mut(&mut self, label: &str) -> &mut PlaceGroup {
578 if !self.groups.iter().any(|g| g.label == label) {
579 self.groups.push(PlaceGroup::new(label));
580 }
581 self.groups
582 .iter_mut()
583 .find(|g| g.label == label)
584 .expect("group exists")
585 }
586}
587
588impl Default for Places {
589 fn default() -> Self {
590 Places::new()
591 }
592}
593
594fn home_dir() -> Option<PathBuf> {
595 std::env::var_os("HOME")
596 .map(PathBuf::from)
597 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
598}
599
600#[cfg(target_os = "windows")]
601fn windows_drives() -> Vec<String> {
602 let mut v = Vec::new();
603 for c in b'A'..=b'Z' {
604 let s = format!("{}:\\", c as char);
605 if Path::new(&s).exists() {
606 v.push(s);
607 }
608 }
609 v
610}
611
612fn default_label_for_path(path: &Path) -> String {
613 path.file_name()
614 .and_then(|s| s.to_str())
615 .filter(|s| !s.is_empty())
616 .map(|s| s.to_string())
617 .unwrap_or_else(|| path.display().to_string())
618}
619
620fn escape_field(s: &str) -> String {
621 let mut out = String::with_capacity(s.len());
622 for ch in s.chars() {
623 match ch {
624 '\\' => out.push_str("\\\\"),
625 '\t' => out.push_str("\\t"),
626 '\n' => out.push_str("\\n"),
627 '\r' => out.push_str("\\r"),
628 _ => out.push(ch),
629 }
630 }
631 out
632}
633
634fn unescape_field(s: &str) -> Result<String, &'static str> {
635 let mut out = String::with_capacity(s.len());
636 let mut chars = s.chars();
637 while let Some(ch) = chars.next() {
638 if ch != '\\' {
639 out.push(ch);
640 continue;
641 }
642 let Some(esc) = chars.next() else {
643 return Err("dangling escape");
644 };
645 match esc {
646 '\\' => out.push('\\'),
647 't' => out.push('\t'),
648 'n' => out.push('\n'),
649 'r' => out.push('\r'),
650 _ => return Err("unknown escape"),
651 }
652 }
653 Ok(out)
654}
655
656#[derive(Clone, Debug, PartialEq, Eq)]
658pub struct PlacesDeserializeError {
659 pub line: usize,
661 pub message: String,
663}
664
665impl std::fmt::Display for PlacesDeserializeError {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 write!(
668 f,
669 "places deserialize error at line {}: {}",
670 self.line, self.message
671 )
672 }
673}
674
675impl std::error::Error for PlacesDeserializeError {}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn add_bookmark_dedupes_by_path() {
683 let mut p = Places::new();
684 p.add_bookmark("A", PathBuf::from("x"));
685 p.add_bookmark("B", PathBuf::from("x"));
686 let g = p
687 .groups
688 .iter()
689 .find(|g| g.label == Places::BOOKMARKS_GROUP)
690 .unwrap();
691 assert_eq!(g.places.len(), 1);
692 assert_eq!(g.places[0].label, "A");
693 }
694
695 #[test]
696 fn remove_bookmark_by_path() {
697 let mut p = Places::new();
698 p.add_bookmark("A", PathBuf::from("x"));
699 assert!(p.remove_place_path(Places::BOOKMARKS_GROUP, Path::new("x")));
700 assert!(!p.remove_place_path(Places::BOOKMARKS_GROUP, Path::new("x")));
701 }
702
703 #[test]
704 fn compact_roundtrip_escapes_fields() {
705 let mut p = Places::new();
706 p.groups.clear();
707 p.add_place("G\t1", Place::user("a\tb", PathBuf::from("C:\\x\\y")));
708 p.add_place("G\t2", Place::code("line\nbreak", PathBuf::from("/tmp/z")));
709 p.add_place_separator("G\t2", 2);
710 let s = p.serialize_compact(PlacesSerializeOptions {
711 include_code_places: true,
712 });
713
714 let p2 = Places::deserialize_compact(&s).unwrap();
715 let g1 = p2.groups.iter().find(|g| g.label == "G\t1").unwrap();
716 assert_eq!(g1.places[0].label, "a\tb");
717 let g2 = p2.groups.iter().find(|g| g.label == "G\t2").unwrap();
718 assert_eq!(g2.places[0].label, "line\nbreak");
719 assert!(g2.places.iter().any(|p| p.is_separator()));
720 }
721
722 #[test]
723 fn compact_parse_rejects_missing_separator() {
724 let err = Places::deserialize_compact("abc").unwrap_err();
725 assert_eq!(err.line, 1);
726 }
727
728 #[test]
729 fn compact_roundtrip_preserves_group_metadata() {
730 let mut p = Places::new();
731 p.groups.clear();
732 p.add_group("G1");
733 p.add_group("G2");
734 p.ensure_default_groups();
735 let g1 = p.groups.iter_mut().find(|g| g.label == "G1").unwrap();
736 g1.display_order = 42;
737 g1.default_opened = true;
738
739 let s = p.serialize_compact(PlacesSerializeOptions {
740 include_code_places: false,
741 });
742 let p2 = Places::deserialize_compact(&s).unwrap();
743 let g1 = p2.groups.iter().find(|g| g.label == "G1").unwrap();
744 assert_eq!(g1.display_order, 42);
745 assert!(g1.default_opened);
746 }
747
748 #[test]
749 fn group_add_rename_remove_roundtrip() {
750 let mut p = Places::new();
751 assert!(p.add_group("MyGroup"));
752 assert!(!p.add_group("MyGroup"));
753
754 assert!(p.rename_group("MyGroup", "MyGroup2"));
755 assert!(!p.rename_group("MyGroup2", Places::SYSTEM_GROUP));
756 assert!(!p.rename_group("Missing", "X"));
757
758 assert!(p.remove_group("MyGroup2"));
759 assert!(!p.remove_group("MyGroup2"));
760 }
761
762 #[test]
763 fn edit_place_by_path_updates_label_and_path() {
764 let mut p = Places::new();
765 p.groups.clear();
766 p.add_place("G", Place::user("A", PathBuf::from("/tmp/a")));
767 assert!(p.edit_place_by_path("G", Path::new("/tmp/a"), "B", PathBuf::from("/tmp/b")));
768 let g = p.groups.iter().find(|g| g.label == "G").unwrap();
769 assert_eq!(g.places.len(), 1);
770 assert_eq!(g.places[0].label, "B");
771 assert_eq!(g.places[0].path, PathBuf::from("/tmp/b"));
772 assert!(!p.edit_place_by_path(
773 "G",
774 Path::new("/tmp/missing"),
775 "C",
776 PathBuf::from("/tmp/c")
777 ));
778 }
779}