1use std::{
2 ffi::OsStr,
3 fs,
4 path::{Component, MAIN_SEPARATOR, Path, PathBuf, is_separator},
5};
6
7use directories::BaseDirs;
8
9use crate::domain::{config::ConfigError, port::PathCompleter, project::Project};
10
11const HOME_PREFIX: &str = "~";
13const MAX_COMPLETIONS: usize = 8;
15
16#[derive(Default)]
18pub struct FsPathCompleter;
19
20impl FsPathCompleter {
21 fn complete(partial: &str) -> Vec<String> {
23 let (typed_dir, prefix): (String, String) =
28 if !partial.ends_with(is_separator) && expand_home(Path::new(partial)).is_dir() {
29 (format!("{partial}{MAIN_SEPARATOR}"), String::new())
30 } else {
31 match partial.rfind(is_separator) {
32 Some(index) => (
33 partial[..=index].to_string(),
34 partial[index + 1..].to_string(),
35 ),
36 None => (String::new(), partial.to_string()),
37 }
38 };
39 let read_dir = if typed_dir.is_empty() {
40 PathBuf::from(".")
41 } else {
42 expand_home(Path::new(&typed_dir))
43 };
44 let Ok(entries) = fs::read_dir(&read_dir) else {
45 return Vec::new();
46 };
47 let mut matches: Vec<String> = entries
48 .filter_map(Result::ok)
49 .filter(|entry| entry.path().is_dir())
50 .filter_map(|entry| Self::candidate(&entry.file_name(), &typed_dir, &prefix))
51 .collect();
52 matches.sort();
53 matches.truncate(MAX_COMPLETIONS);
54 matches
55 }
56
57 fn candidate(raw: &OsStr, typed_dir: &str, prefix: &str) -> Option<String> {
60 let name = raw.to_str()?;
61 let hidden = name.starts_with('.') && !prefix.starts_with('.');
62 (name.starts_with(prefix) && name != prefix && !hidden)
63 .then(|| format!("{typed_dir}{name}"))
64 }
65}
66
67impl PathCompleter for FsPathCompleter {
68 fn complete_dir(&self, partial: &str) -> Vec<String> {
69 Self::complete(partial)
70 }
71}
72
73pub fn expand_home(path: &Path) -> PathBuf {
76 match path.strip_prefix(HOME_PREFIX) {
77 Ok(tail) => match BaseDirs::new() {
78 Some(dirs) => dirs.home_dir().join(tail),
79 None => path.to_path_buf(),
80 },
81 Err(_) => path.to_path_buf(),
82 }
83}
84
85pub fn absolutize(path: &Path) -> PathBuf {
91 let expanded = expand_home(path);
92 let absolute = if expanded.is_absolute() {
93 expanded
94 } else {
95 match std::env::current_dir() {
96 Ok(cwd) => cwd.join(&expanded),
97 Err(_) => expanded,
98 }
99 };
100 normalize_lexically(&absolute)
101}
102
103fn normalize_lexically(path: &Path) -> PathBuf {
111 let mut normalized = PathBuf::new();
112 for component in path.components() {
113 match component {
114 Component::CurDir => {},
115 Component::ParentDir => {
116 let last_is_normal = normalized
117 .components()
118 .next_back()
119 .is_some_and(|last| matches!(last, Component::Normal(_)));
120 let last_is_parent = normalized
121 .components()
122 .next_back()
123 .is_some_and(|last| matches!(last, Component::ParentDir));
124 if last_is_normal {
125 match parent_step(&normalized) {
126 ParentStep::Lexical => {
127 normalized.pop();
128 },
129 ParentStep::Resolved(parent) => normalized = parent,
130 ParentStep::Preserved => normalized.push(component.as_os_str()),
131 }
132 } else if last_is_parent || !normalized.has_root() {
133 normalized.push(component.as_os_str());
134 }
135 },
136 other => normalized.push(other.as_os_str()),
137 }
138 }
139 normalized
140}
141
142enum ParentStep {
144 Lexical,
146 Resolved(PathBuf),
148 Preserved,
152}
153
154fn parent_step(prefix: &Path) -> ParentStep {
160 let Ok(meta) = fs::symlink_metadata(prefix) else {
161 return ParentStep::Preserved;
162 };
163 if meta.file_type().is_symlink() {
164 return match prefix.canonicalize() {
165 Ok(target) if target.is_dir() => match target.parent() {
166 Some(parent) => ParentStep::Resolved(parent.to_path_buf()),
167 None => ParentStep::Resolved(target),
169 },
170 _ => ParentStep::Preserved,
171 };
172 }
173 if meta.is_dir() {
174 ParentStep::Lexical
175 } else {
176 ParentStep::Preserved
177 }
178}
179
180pub fn registered_config_path(project: &Project) -> Result<PathBuf, ConfigError> {
186 if expand_home(project.config()).is_relative() {
187 return Err(ConfigError::RelativeProjectConfig {
188 name: project.name().clone(),
189 path: project.config().clone(),
190 });
191 }
192 Ok(absolutize(project.config()))
193}
194
195pub fn normalize(path: &Path) -> PathBuf {
198 let absolute = absolutize(path);
199 absolute.canonicalize().unwrap_or(absolute)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn expand_home_rewrites_a_tilde_prefix() {
208 let home = BaseDirs::new().unwrap().home_dir().to_path_buf();
209 assert_eq!(
210 expand_home(Path::new("~/Projects/muster.yml")),
211 home.join("Projects/muster.yml")
212 );
213 assert_eq!(
214 expand_home(Path::new("/etc/muster.yml")),
215 PathBuf::from("/etc/muster.yml")
216 );
217 }
218
219 #[test]
220 fn relative_and_absolute_paths_to_one_file_normalize_equal() {
221 let absolute = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
224 assert_eq!(normalize(Path::new("Cargo.toml")), normalize(&absolute));
225 }
226
227 #[cfg(unix)]
228 #[test]
231 fn absolutize_preserves_a_symlink_path() {
232 use std::os::unix::fs::symlink;
233
234 let dir = std::env::temp_dir().join(format!("muster-path-link-{}", std::process::id()));
235 let target = dir.join("target.yml");
236 let link = dir.join("muster.yml");
237 fs::create_dir_all(&dir).unwrap();
238 fs::write(&target, "").unwrap();
239 symlink(&target, &link).unwrap();
240
241 assert_eq!(absolutize(&link), link);
242 assert_eq!(normalize(&link), target.canonicalize().unwrap());
243
244 fs::remove_dir_all(dir).unwrap();
245 }
246
247 #[test]
248 fn complete_dir_suggests_matching_subdirectories() {
249 let dir = std::env::temp_dir().join(format!("muster-complete-{}", std::process::id()));
250 for sub in ["prism", "proto", "other"] {
251 fs::create_dir_all(dir.join(sub)).unwrap();
252 }
253 fs::write(dir.join("prfile"), "").unwrap();
254
255 let base = dir.display();
256 let got = FsPathCompleter::complete(&format!("{base}/pr"));
257
258 assert_eq!(
259 got,
260 vec![format!("{base}/prism"), format!("{base}/proto")],
261 "only matching directories, prefixed as typed, sorted"
262 );
263
264 let _ = fs::remove_dir_all(&dir);
265 }
266
267 #[test]
268 fn complete_dir_browses_inside_an_existing_directory() {
269 let dir = std::env::temp_dir().join(format!("muster-browse-{}", std::process::id()));
270 for sub in ["alpha", "beta"] {
271 fs::create_dir_all(dir.join(sub)).unwrap();
272 }
273
274 let base = dir.display();
277 let got = FsPathCompleter::complete(&base.to_string());
278
279 assert_eq!(got, vec![format!("{base}/alpha"), format!("{base}/beta")]);
280
281 let _ = fs::remove_dir_all(&dir);
282 }
283
284 #[cfg(unix)]
285 #[test]
287 fn candidate_rejects_non_utf8_names() {
288 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
289
290 let raw = OsStr::from_bytes(b"val\xffid");
291
292 assert_eq!(FsPathCompleter::candidate(raw, "/tmp/", "val"), None);
293 }
294
295 #[test]
297 fn absolutize_normalizes_dot_components() {
298 assert_eq!(
299 absolutize(Path::new("/tmp/./proj/./muster.yml")),
300 PathBuf::from("/tmp/proj/muster.yml")
301 );
302 assert_eq!(
303 absolutize(Path::new("/../etc/passwd")),
304 PathBuf::from("/etc/passwd"),
305 "excess parent components at the root are dropped"
306 );
307 let real = std::env::temp_dir().join(format!("muster-abs-dot-{}", std::process::id()));
308 let sub = real.join("sub");
309 fs::create_dir_all(&sub).unwrap();
310 assert_eq!(
311 absolutize(&sub.join("../muster.yml")),
312 real.join("muster.yml"),
313 "an existing directory pops"
314 );
315 fs::remove_dir_all(real).unwrap();
316 }
317
318 #[test]
322 fn missing_components_preserve_their_traversal() {
323 assert_eq!(
324 normalize_lexically(Path::new("/a/missing/../b")),
325 PathBuf::from("/a/missing/../b")
326 );
327 assert_eq!(
328 normalize_lexically(Path::new("../x/./y")),
329 PathBuf::from("../x/y"),
330 "leading parent components and dot removal are untouched"
331 );
332 assert_eq!(
333 normalize_lexically(Path::new("../../x")),
334 PathBuf::from("../../x")
335 );
336 }
337
338 #[cfg(unix)]
340 const LOCKED_MODE: u32 = 0o000;
341 #[cfg(unix)]
343 const OPEN_MODE: u32 = 0o755;
344
345 #[cfg(unix)]
346 #[test]
349 fn traversal_in_an_unsearchable_directory_is_preserved() {
350 use std::os::unix::fs::PermissionsExt;
351
352 let base = std::env::temp_dir().join(format!("muster-denied-{}", std::process::id()));
353 let locked = base.join("locked");
354 let inner = locked.join("inner");
355 fs::create_dir_all(&inner).unwrap();
356 fs::set_permissions(&locked, fs::Permissions::from_mode(LOCKED_MODE)).unwrap();
357 let probe = inner.join("x/../muster.yml");
358 let normalized = normalize_lexically(&probe);
359
360 fs::set_permissions(&locked, fs::Permissions::from_mode(OPEN_MODE)).unwrap();
361 assert_eq!(normalized, probe, "the traversal survives untouched");
364 fs::remove_dir_all(&base).unwrap();
365 }
366
367 #[test]
370 fn parent_of_a_regular_file_is_preserved() {
371 let base = std::env::temp_dir().join(format!("muster-file-dotdot-{}", std::process::id()));
372 let blocker = base.join("blocker");
373 fs::create_dir_all(&base).unwrap();
374 fs::write(&blocker, "").unwrap();
375
376 assert_eq!(
377 normalize_lexically(&blocker.join("../muster.yml")),
378 blocker.join("../muster.yml"),
379 "the invalid traversal survives for the OS to reject"
380 );
381 fs::remove_dir_all(base).unwrap();
382 }
383
384 #[test]
386 fn parent_of_a_real_directory_pops() {
387 let base = std::env::temp_dir().join(format!("muster-dir-dotdot-{}", std::process::id()));
388 let sub = base.join("sub");
389 fs::create_dir_all(&sub).unwrap();
390
391 assert_eq!(
392 normalize_lexically(&sub.join("../muster.yml")),
393 base.join("muster.yml")
394 );
395 fs::remove_dir_all(base).unwrap();
396 }
397
398 #[cfg(unix)]
399 #[test]
402 fn parent_of_a_symlink_resolves_through_the_filesystem() {
403 use std::os::unix::fs::symlink;
404
405 let base = std::env::temp_dir().join(format!("muster-link-dotdot-{}", std::process::id()));
406 let real_sub = base.join("real/sub");
407 let link = base.join("link");
408 fs::create_dir_all(&real_sub).unwrap();
409 symlink(&real_sub, &link).unwrap();
410
411 let resolved = normalize_lexically(&link.join("../muster.yml"));
412
413 assert_eq!(
414 resolved,
415 base.join("real").canonicalize().unwrap().join("muster.yml"),
416 "link/.. lands beside the link target, matching the OS"
417 );
418 fs::remove_dir_all(base).unwrap();
419 }
420}