Skip to main content

dear_file_browser/
places.rs

1use std::path::{Path, PathBuf};
2
3/// Place entry origin.
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5#[non_exhaustive]
6pub enum PlaceOrigin {
7    /// Added by the application/user and intended to be persisted.
8    User,
9    /// Added by the library/application code (e.g. system drives).
10    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/// A single place entry shown in the left "Places" pane.
31#[derive(Clone, Debug, PartialEq, Eq)]
32#[non_exhaustive]
33pub struct Place {
34    /// Display name shown in UI.
35    pub label: String,
36    /// Target directory path.
37    pub path: PathBuf,
38    /// Origin of the entry (user vs code).
39    pub origin: PlaceOrigin,
40    /// Optional UI separator thickness (in pixels).
41    ///
42    /// When set, this item is treated as a non-interactive separator instead of a navigable place.
43    pub separator_thickness: Option<u32>,
44}
45
46impl Place {
47    /// Creates a new place entry.
48    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    /// Convenience constructor for a user-defined place.
58    pub fn user(label: impl Into<String>, path: PathBuf) -> Self {
59        Self::new(label, path, PlaceOrigin::User)
60    }
61
62    /// Convenience constructor for a code-defined place.
63    pub fn code(label: impl Into<String>, path: PathBuf) -> Self {
64        Self::new(label, path, PlaceOrigin::Code)
65    }
66
67    /// Creates a non-interactive separator row.
68    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    /// Returns whether this item is a separator row.
78    pub fn is_separator(&self) -> bool {
79        self.separator_thickness.is_some()
80    }
81}
82
83/// A group of places (e.g. "System", "Bookmarks").
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct PlaceGroup {
87    /// Group title shown in UI.
88    pub label: String,
89    /// Display order used by UI (lower comes first). Ties are resolved by label.
90    pub display_order: usize,
91    /// Whether this group should be expanded by default.
92    pub default_opened: bool,
93    /// Places in this group.
94    pub places: Vec<Place>,
95}
96
97impl PlaceGroup {
98    /// Creates a new group.
99    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/// Options for serializing places.
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct PlacesSerializeOptions {
113    /// Whether to include code-defined places.
114    pub include_code_places: bool,
115}
116
117/// Options for merging places.
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct PlacesMergeOptions {
121    /// If `true`, imported group metadata (`display_order`, `default_opened`) overwrites the
122    /// destination group's metadata when labels match.
123    pub overwrite_group_metadata: bool,
124}
125
126/// Storage for user-defined and code-defined places.
127///
128/// This is intentionally dependency-free (no serde). The compact persistence
129/// format is designed to be stable and forward-compatible.
130#[derive(Clone, Debug)]
131#[non_exhaustive]
132pub struct Places {
133    /// Places groups shown in UI.
134    pub groups: Vec<PlaceGroup>,
135}
136
137impl Places {
138    /// Default system group label.
139    pub const SYSTEM_GROUP: &'static str = "System";
140    /// Default bookmarks group label.
141    pub const BOOKMARKS_GROUP: &'static str = "Bookmarks";
142
143    /// Creates a places store with default groups and system entries.
144    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    /// Returns `true` if there are no places at all.
152    pub fn is_empty(&self) -> bool {
153        self.groups.iter().all(|g| g.places.is_empty())
154    }
155
156    /// Ensures the default groups exist.
157    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    /// Rebuilds the system places group (home/root/drives).
180    ///
181    /// This is a best-effort operation and may produce different results across
182    /// platforms.
183    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    /// Adds a place to a group if its path isn't already present in that group.
205    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    /// Adds a separator row to a group.
215    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    /// Adds a bookmark (user place) into the default bookmarks group.
220    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    /// Adds a bookmark using a default label derived from the path.
225    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    /// Removes a place by exact path match from the given group.
231    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    /// Adds a group if it does not exist.
243    /// Returns `true` if the group was added.
244    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    /// Removes a group by exact label match.
263    /// Returns `true` if a group was removed.
264    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    /// Renames a group by exact label match.
273    /// Returns `true` if the group was found and renamed.
274    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    /// Edits a place identified by its current path within a group.
287    ///
288    /// Returns `true` if a place was found and updated.
289    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    /// Serializes places into a compact, line-based format.
308    ///
309    /// Format (v1):
310    /// - First non-empty line: `v1`
311    /// - Group header: `g<TAB>group<TAB>order<TAB>opened`
312    /// - Place entry: `p<TAB>group<TAB>origin<TAB>label<TAB>path`
313    /// - Separator: `s<TAB>group<TAB>thickness`
314    ///
315    /// All string fields are escaped and separated by tabs.
316    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    /// Deserializes places from the compact format produced by
364    /// [`Places::serialize_compact`].
365    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        // Always ensure System + Bookmarks groups exist, and refresh System to match the
541        // current machine (drives, home, etc.).
542        places.ensure_default_groups();
543        places.refresh_system_places();
544        Ok(places)
545    }
546
547    /// Merges another places store into `self`.
548    ///
549    /// - Groups are merged by label.
550    /// - Places are merged via [`Places::add_place`] (dedupes by path; separators are always added).
551    /// - The system group is never merged (it is machine-specific).
552    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/// Error returned by [`Places::deserialize_compact`].
657#[derive(Clone, Debug, PartialEq, Eq)]
658pub struct PlacesDeserializeError {
659    /// 1-based line number where the error happened.
660    pub line: usize,
661    /// Human-readable error message.
662    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}