Skip to main content

qcode/backup/
place.rs

1//! The paths a person names inside `Work/`: a folder to leave out of the backup, a file to
2//! bring back.
3//!
4//! They come from the workspace file and from the file tree, and they end up in two places that
5//! read them in their own way: git's `info/exclude`, where `*`, `[` or a leading `!` would mean
6//! something, and a git command line. So each one is held to what it is meant to be — a path
7//! inside the workspace — before it goes anywhere, and is written out so git reads it as the path
8//! it is and nothing more.
9
10use std::fmt;
11
12/// A path inside `Work/`, checked, with `/` between its parts and no `/` at either end.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Place(String);
15
16/// Why a path is not a place inside the workspace.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PlaceProblem {
19    /// Nothing is named.
20    Empty,
21    /// It starts at the root of a drive or of the machine rather than inside the workspace.
22    Absolute,
23    /// A `.` or `..` part: the one could name the whole workspace, the other leaves it.
24    Outside,
25    /// A line break or a NUL, which no exclude file and no command line would read as one path.
26    LineBreak,
27}
28
29/// A path that was refused, with why.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BadPlace {
32    /// The path as it was given.
33    pub path: String,
34    /// What is wrong with it.
35    pub problem: PlaceProblem,
36}
37
38impl Place {
39    /// `path` as a place inside the workspace, when it is one.
40    ///
41    /// A `/` at the end is dropped, because the file tree names a folder either way; `\` is not
42    /// read as a separator, because on the machines whose file names may hold one it is a
43    /// letter.
44    ///
45    /// # Errors
46    ///
47    /// The path and why it is not a place inside the workspace.
48    pub fn new(path: &str) -> Result<Self, BadPlace> {
49        let refuse = |problem| Err(BadPlace { path: path.to_owned(), problem });
50        if path.contains(['\n', '\r', '\0']) {
51            return refuse(PlaceProblem::LineBreak);
52        }
53        if path.starts_with(['/', '\\']) || is_drive(path) {
54            return refuse(PlaceProblem::Absolute);
55        }
56        let trimmed = path.trim_end_matches('/');
57        if trimmed.is_empty() {
58            return refuse(PlaceProblem::Empty);
59        }
60        let mut parts = Vec::new();
61        for part in trimmed.split('/') {
62            match part {
63                // `a//b` is `a/b` to every file system QCode runs on.
64                "" => {}
65                "." | ".." => return refuse(PlaceProblem::Outside),
66                part => parts.push(part),
67            }
68        }
69        Ok(Self(parts.join("/")))
70    }
71
72    /// The path, as git and the file tree name it.
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77
78    /// The line of an exclude file that leaves this place out and nothing else.
79    ///
80    /// The leading `/` ties it to the top of the workspace, so `data` leaves out `data/` and not
81    /// every folder called `data` further down; every character git would read as a pattern is
82    /// escaped, and so is every space, because git drops the ones at the end of a line.
83    fn exclude_line(&self) -> String {
84        let mut line = String::from("/");
85        for char in self.0.chars() {
86            if matches!(char, '\\' | '*' | '?' | '[' | ']' | ' ') {
87                line.push('\\');
88            }
89            line.push(char);
90        }
91        line
92    }
93}
94
95impl fmt::Display for Place {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(&self.0)
98    }
99}
100
101/// Whether `path` starts with a Windows drive, `C:`.
102fn is_drive(path: &str) -> bool {
103    let mut chars = path.chars();
104    chars.next().is_some_and(|first| first.is_ascii_alphabetic()) && chars.next() == Some(':')
105}
106
107/// Every entry of a skip list as a place inside the workspace.
108///
109/// # Errors
110///
111/// The first entry that is not one. A skip list with a bad entry is not written at all: a list
112/// that was half applied would back up a folder the person asked to leave out and say nothing.
113pub fn places(skip: &[String]) -> Result<Vec<Place>, BadPlace> {
114    skip.iter().map(|path| Place::new(path)).collect()
115}
116
117/// The whole of `info/exclude` for `skip`: one line per place, each ending in a line break.
118#[must_use]
119pub fn exclude_file(skip: &[Place]) -> String {
120    skip.iter().map(|place| place.exclude_line() + "\n").collect()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::{Place, PlaceProblem, exclude_file, places};
126
127    fn problem(path: &str) -> PlaceProblem {
128        Place::new(path).expect_err("refused").problem
129    }
130
131    #[test]
132    fn a_path_inside_the_workspace_is_kept_as_it_is_named() {
133        assert_eq!(Place::new("data").expect("a folder").as_str(), "data");
134        assert_eq!(Place::new("out/big/").expect("a folder").as_str(), "out/big");
135        assert_eq!(Place::new("out//big").expect("a folder").as_str(), "out/big");
136        assert_eq!(Place::new(".cache").expect("a hidden folder").as_str(), ".cache");
137        assert_eq!(Place::new("a..b").expect("dots inside a name").as_str(), "a..b");
138    }
139
140    #[test]
141    fn a_path_that_leaves_the_workspace_is_refused() {
142        assert_eq!(problem(""), PlaceProblem::Empty);
143        assert_eq!(problem("/"), PlaceProblem::Absolute);
144        assert_eq!(problem("/etc"), PlaceProblem::Absolute);
145        assert_eq!(problem("\\share"), PlaceProblem::Absolute);
146        assert_eq!(problem("C:"), PlaceProblem::Absolute);
147        assert_eq!(problem(".."), PlaceProblem::Outside);
148        assert_eq!(problem("data/../.."), PlaceProblem::Outside);
149        assert_eq!(problem("."), PlaceProblem::Outside);
150        assert_eq!(problem("data\n/etc"), PlaceProblem::LineBreak);
151        assert_eq!(problem("data\r"), PlaceProblem::LineBreak);
152        assert_eq!(problem("data\0"), PlaceProblem::LineBreak);
153    }
154
155    #[test]
156    fn one_bad_entry_refuses_the_whole_list() {
157        let skip = ["data".to_owned(), "../home".to_owned()];
158        let bad = places(&skip).expect_err("one entry leaves the workspace");
159        assert_eq!(bad.path, "../home");
160        assert_eq!(bad.problem, PlaceProblem::Outside);
161    }
162
163    #[test]
164    fn the_exclude_file_ties_each_place_to_the_top_and_reads_no_pattern_into_it() {
165        let skip = places(&["data".to_owned(), "out/big".to_owned(), "!odd [1]*? \\ ".to_owned()]).expect("valid");
166        assert_eq!(exclude_file(&skip), "/data\n/out/big\n/!odd\\ \\[1\\]\\*\\?\\ \\\\\\ \n");
167        assert_eq!(exclude_file(&[]), "");
168    }
169}