use std::{
ffi::OsStr,
fs,
path::{MAIN_SEPARATOR, Path, PathBuf, is_separator},
};
use directories::BaseDirs;
use crate::domain::{config::ConfigError, port::PathCompleter, project::Project};
const HOME_PREFIX: &str = "~";
const MAX_COMPLETIONS: usize = 8;
#[derive(Default)]
pub struct FsPathCompleter;
impl FsPathCompleter {
fn complete(partial: &str) -> Vec<String> {
let (typed_dir, prefix): (String, String) =
if !partial.ends_with(is_separator) && expand_home(Path::new(partial)).is_dir() {
(format!("{partial}{MAIN_SEPARATOR}"), String::new())
} else {
match partial.rfind(is_separator) {
Some(index) => (
partial[..=index].to_string(),
partial[index + 1..].to_string(),
),
None => (String::new(), partial.to_string()),
}
};
let read_dir = if typed_dir.is_empty() {
PathBuf::from(".")
} else {
expand_home(Path::new(&typed_dir))
};
let Ok(entries) = fs::read_dir(&read_dir) else {
return Vec::new();
};
let mut matches: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| Self::candidate(&entry.file_name(), &typed_dir, &prefix))
.collect();
matches.sort();
matches.truncate(MAX_COMPLETIONS);
matches
}
fn candidate(raw: &OsStr, typed_dir: &str, prefix: &str) -> Option<String> {
let name = raw.to_str()?;
let hidden = name.starts_with('.') && !prefix.starts_with('.');
(name.starts_with(prefix) && name != prefix && !hidden)
.then(|| format!("{typed_dir}{name}"))
}
}
impl PathCompleter for FsPathCompleter {
fn complete_dir(&self, partial: &str) -> Vec<String> {
Self::complete(partial)
}
}
pub fn expand_home(path: &Path) -> PathBuf {
match path.strip_prefix(HOME_PREFIX) {
Ok(tail) => match BaseDirs::new() {
Some(dirs) => dirs.home_dir().join(tail),
None => path.to_path_buf(),
},
Err(_) => path.to_path_buf(),
}
}
pub fn absolutize(path: &Path) -> PathBuf {
let expanded = expand_home(path);
if expanded.is_absolute() {
expanded
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(&expanded),
Err(_) => expanded,
}
}
}
pub fn registered_config_path(project: &Project) -> Result<PathBuf, ConfigError> {
if expand_home(project.config()).is_relative() {
return Err(ConfigError::RelativeProjectConfig {
name: project.name().clone(),
path: project.config().clone(),
});
}
Ok(absolutize(project.config()))
}
pub fn normalize(path: &Path) -> PathBuf {
let absolute = absolutize(path);
absolute.canonicalize().unwrap_or(absolute)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_home_rewrites_a_tilde_prefix() {
let home = BaseDirs::new().unwrap().home_dir().to_path_buf();
assert_eq!(
expand_home(Path::new("~/Projects/muster.yml")),
home.join("Projects/muster.yml")
);
assert_eq!(
expand_home(Path::new("/etc/muster.yml")),
PathBuf::from("/etc/muster.yml")
);
}
#[test]
fn relative_and_absolute_paths_to_one_file_normalize_equal() {
let absolute = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
assert_eq!(normalize(Path::new("Cargo.toml")), normalize(&absolute));
}
#[cfg(unix)]
#[test]
fn absolutize_preserves_a_symlink_path() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("muster-path-link-{}", std::process::id()));
let target = dir.join("target.yml");
let link = dir.join("muster.yml");
fs::create_dir_all(&dir).unwrap();
fs::write(&target, "").unwrap();
symlink(&target, &link).unwrap();
assert_eq!(absolutize(&link), link);
assert_eq!(normalize(&link), target.canonicalize().unwrap());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn complete_dir_suggests_matching_subdirectories() {
let dir = std::env::temp_dir().join(format!("muster-complete-{}", std::process::id()));
for sub in ["prism", "proto", "other"] {
fs::create_dir_all(dir.join(sub)).unwrap();
}
fs::write(dir.join("prfile"), "").unwrap();
let base = dir.display();
let got = FsPathCompleter::complete(&format!("{base}/pr"));
assert_eq!(
got,
vec![format!("{base}/prism"), format!("{base}/proto")],
"only matching directories, prefixed as typed, sorted"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn complete_dir_browses_inside_an_existing_directory() {
let dir = std::env::temp_dir().join(format!("muster-browse-{}", std::process::id()));
for sub in ["alpha", "beta"] {
fs::create_dir_all(dir.join(sub)).unwrap();
}
let base = dir.display();
let got = FsPathCompleter::complete(&base.to_string());
assert_eq!(got, vec![format!("{base}/alpha"), format!("{base}/beta")]);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn candidate_rejects_non_utf8_names() {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
let raw = OsStr::from_bytes(b"val\xffid");
assert_eq!(FsPathCompleter::candidate(raw, "/tmp/", "val"), None);
}
}