Skip to main content

muster/adapter/
path.rs

1use std::{
2    ffi::OsStr,
3    fs,
4    path::{MAIN_SEPARATOR, Path, PathBuf, is_separator},
5};
6
7use directories::BaseDirs;
8
9use crate::domain::port::PathCompleter;
10
11/// Leading path component expanded to the user's home directory.
12const HOME_PREFIX: &str = "~";
13/// Maximum directory suggestions offered at once.
14const MAX_COMPLETIONS: usize = 8;
15
16/// A [`PathCompleter`] that lists directories on the local filesystem.
17#[derive(Default)]
18pub struct FsPathCompleter;
19
20impl FsPathCompleter {
21    /// Returns directory completions for `partial` from the local filesystem.
22    fn complete(partial: &str) -> Vec<String> {
23        // When `partial` is itself an existing directory (and not already ending
24        // in a separator), browse inside it; otherwise complete the last segment
25        // within its parent. `is_separator` accepts both `/` and the platform
26        // separator, so Windows-style paths split correctly too.
27        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    /// Converts one directory name to a completion, rejecting non-UTF-8 names
58    /// because a lossy string would identify a different filesystem path.
59    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
73/// Expands a leading `~` to the user's home directory; any other path is left
74/// unchanged.
75pub 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
85/// Normalizes a config path for identity comparison: expands `~`, resolves it
86/// against the current directory when relative, and canonicalizes it when the
87/// file exists (falling back to the absolute lexical path when it does not). Two
88/// paths that name the same file, whether relative, absolute, or `~`-prefixed,
89/// normalize to the same value.
90pub fn normalize(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    absolute.canonicalize().unwrap_or(absolute)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn expand_home_rewrites_a_tilde_prefix() {
109        let home = BaseDirs::new().unwrap().home_dir().to_path_buf();
110        assert_eq!(
111            expand_home(Path::new("~/Projects/muster.yml")),
112            home.join("Projects/muster.yml")
113        );
114        assert_eq!(
115            expand_home(Path::new("/etc/muster.yml")),
116            PathBuf::from("/etc/muster.yml")
117        );
118    }
119
120    #[test]
121    fn relative_and_absolute_paths_to_one_file_normalize_equal() {
122        // `cargo test` runs with the crate root as the working directory, so the
123        // relative name and its absolute form resolve to the same file.
124        let absolute = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
125        assert_eq!(normalize(Path::new("Cargo.toml")), normalize(&absolute));
126    }
127
128    #[test]
129    fn complete_dir_suggests_matching_subdirectories() {
130        let dir = std::env::temp_dir().join(format!("muster-complete-{}", std::process::id()));
131        for sub in ["prism", "proto", "other"] {
132            fs::create_dir_all(dir.join(sub)).unwrap();
133        }
134        fs::write(dir.join("prfile"), "").unwrap();
135
136        let base = dir.display();
137        let got = FsPathCompleter::complete(&format!("{base}/pr"));
138
139        assert_eq!(
140            got,
141            vec![format!("{base}/prism"), format!("{base}/proto")],
142            "only matching directories, prefixed as typed, sorted"
143        );
144
145        let _ = fs::remove_dir_all(&dir);
146    }
147
148    #[test]
149    fn complete_dir_browses_inside_an_existing_directory() {
150        let dir = std::env::temp_dir().join(format!("muster-browse-{}", std::process::id()));
151        for sub in ["alpha", "beta"] {
152            fs::create_dir_all(dir.join(sub)).unwrap();
153        }
154
155        // The path is an existing directory with no trailing slash: list its
156        // children, not its siblings.
157        let base = dir.display();
158        let got = FsPathCompleter::complete(&base.to_string());
159
160        assert_eq!(got, vec![format!("{base}/alpha"), format!("{base}/beta")]);
161
162        let _ = fs::remove_dir_all(&dir);
163    }
164
165    #[cfg(unix)]
166    /// Non-UTF-8 names are rejected without asking the filesystem to create one.
167    #[test]
168    fn candidate_rejects_non_utf8_names() {
169        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
170
171        let raw = OsStr::from_bytes(b"val\xffid");
172
173        assert_eq!(FsPathCompleter::candidate(raw, "/tmp/", "val"), None);
174    }
175}