1use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Place(String);
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PlaceProblem {
19 Empty,
21 Absolute,
23 Outside,
25 LineBreak,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BadPlace {
32 pub path: String,
34 pub problem: PlaceProblem,
36}
37
38impl Place {
39 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 "" => {}
65 "." | ".." => return refuse(PlaceProblem::Outside),
66 part => parts.push(part),
67 }
68 }
69 Ok(Self(parts.join("/")))
70 }
71
72 #[must_use]
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77
78 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
101fn 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
107pub fn places(skip: &[String]) -> Result<Vec<Place>, BadPlace> {
114 skip.iter().map(|path| Place::new(path)).collect()
115}
116
117#[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}