1use std::{
2 ffi::OsStr,
3 fs,
4 path::{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 {
88 let expanded = expand_home(path);
89 if expanded.is_absolute() {
90 expanded
91 } else {
92 match std::env::current_dir() {
93 Ok(cwd) => cwd.join(&expanded),
94 Err(_) => expanded,
95 }
96 }
97}
98
99pub fn registered_config_path(project: &Project) -> Result<PathBuf, ConfigError> {
105 if expand_home(project.config()).is_relative() {
106 return Err(ConfigError::RelativeProjectConfig {
107 name: project.name().clone(),
108 path: project.config().clone(),
109 });
110 }
111 Ok(absolutize(project.config()))
112}
113
114pub fn normalize(path: &Path) -> PathBuf {
117 let absolute = absolutize(path);
118 absolute.canonicalize().unwrap_or(absolute)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn expand_home_rewrites_a_tilde_prefix() {
127 let home = BaseDirs::new().unwrap().home_dir().to_path_buf();
128 assert_eq!(
129 expand_home(Path::new("~/Projects/muster.yml")),
130 home.join("Projects/muster.yml")
131 );
132 assert_eq!(
133 expand_home(Path::new("/etc/muster.yml")),
134 PathBuf::from("/etc/muster.yml")
135 );
136 }
137
138 #[test]
139 fn relative_and_absolute_paths_to_one_file_normalize_equal() {
140 let absolute = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
143 assert_eq!(normalize(Path::new("Cargo.toml")), normalize(&absolute));
144 }
145
146 #[cfg(unix)]
147 #[test]
150 fn absolutize_preserves_a_symlink_path() {
151 use std::os::unix::fs::symlink;
152
153 let dir = std::env::temp_dir().join(format!("muster-path-link-{}", std::process::id()));
154 let target = dir.join("target.yml");
155 let link = dir.join("muster.yml");
156 fs::create_dir_all(&dir).unwrap();
157 fs::write(&target, "").unwrap();
158 symlink(&target, &link).unwrap();
159
160 assert_eq!(absolutize(&link), link);
161 assert_eq!(normalize(&link), target.canonicalize().unwrap());
162
163 fs::remove_dir_all(dir).unwrap();
164 }
165
166 #[test]
167 fn complete_dir_suggests_matching_subdirectories() {
168 let dir = std::env::temp_dir().join(format!("muster-complete-{}", std::process::id()));
169 for sub in ["prism", "proto", "other"] {
170 fs::create_dir_all(dir.join(sub)).unwrap();
171 }
172 fs::write(dir.join("prfile"), "").unwrap();
173
174 let base = dir.display();
175 let got = FsPathCompleter::complete(&format!("{base}/pr"));
176
177 assert_eq!(
178 got,
179 vec![format!("{base}/prism"), format!("{base}/proto")],
180 "only matching directories, prefixed as typed, sorted"
181 );
182
183 let _ = fs::remove_dir_all(&dir);
184 }
185
186 #[test]
187 fn complete_dir_browses_inside_an_existing_directory() {
188 let dir = std::env::temp_dir().join(format!("muster-browse-{}", std::process::id()));
189 for sub in ["alpha", "beta"] {
190 fs::create_dir_all(dir.join(sub)).unwrap();
191 }
192
193 let base = dir.display();
196 let got = FsPathCompleter::complete(&base.to_string());
197
198 assert_eq!(got, vec![format!("{base}/alpha"), format!("{base}/beta")]);
199
200 let _ = fs::remove_dir_all(&dir);
201 }
202
203 #[cfg(unix)]
204 #[test]
206 fn candidate_rejects_non_utf8_names() {
207 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
208
209 let raw = OsStr::from_bytes(b"val\xffid");
210
211 assert_eq!(FsPathCompleter::candidate(raw, "/tmp/", "val"), None);
212 }
213}