use crate::error::{PromptError, Result};
use crate::module_trait::{Module, ModuleContext};
use std::path::Path;
pub struct PathModule;
impl Default for PathModule {
fn default() -> Self {
Self::new()
}
}
impl PathModule {
pub fn new() -> Self {
Self
}
}
#[cfg(target_os = "windows")]
fn normalize_separators(value: String) -> String {
value.replace('\\', "/")
}
#[cfg(not(target_os = "windows"))]
fn normalize_separators(value: String) -> String {
value
}
fn first_char(segment: &str) -> Option<char> {
segment.chars().next()
}
fn shorten_segment_to_initial(segment: &str) -> String {
if let Some(rest) = segment.strip_prefix('.')
&& let Some(initial) = first_char(rest)
{
let mut shortened = String::with_capacity(2);
shortened.push('.');
shortened.push(initial);
return shortened;
}
first_char(segment)
.map(|initial| initial.to_string())
.unwrap_or_default()
}
fn is_ascii_vowel(ch: char) -> bool {
matches!(
ch,
'a' | 'e' | 'i' | 'o' | 'u' | 'A' | 'E' | 'I' | 'O' | 'U'
)
}
fn unvowel_body(segment: &str) -> String {
if segment.chars().count() <= 3 {
return segment.to_string();
}
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut shortened = String::with_capacity(segment.len());
shortened.push(first);
for ch in chars {
if !is_ascii_vowel(ch) {
shortened.push(ch);
}
}
shortened
}
fn unvowel_segment(segment: &str) -> String {
if let Some(rest) = segment.strip_prefix('.') {
let mut shortened = String::with_capacity(segment.len());
shortened.push('.');
shortened.push_str(&unvowel_body(rest));
return shortened;
}
unvowel_body(segment)
}
fn transform_relative_path(
path: &str,
preserve_last: bool,
transform: fn(&str) -> String,
) -> String {
let segments: Vec<&str> = path.split('/').collect();
let last_index = segments.len().saturating_sub(1);
let mut rendered = Vec::with_capacity(segments.len());
for (index, segment) in segments.iter().enumerate() {
let transformed =
if segment.is_empty() || *segment == "~" || (preserve_last && index == last_index) {
(*segment).to_string()
} else {
transform(segment)
};
rendered.push(transformed);
}
rendered.join("/")
}
fn normalize_relative_path(current_dir: &Path) -> String {
let current_canon = current_dir
.canonicalize()
.unwrap_or_else(|_| current_dir.to_path_buf());
if let Some(home) = dirs::home_dir() {
let home_canon = home.canonicalize().unwrap_or(home);
if let Ok(stripped) = current_canon.strip_prefix(&home_canon) {
if stripped.as_os_str().is_empty() {
return "~".to_string();
}
let mut result = String::from("~");
result.push(std::path::MAIN_SEPARATOR);
result.push_str(&stripped.to_string_lossy());
return normalize_separators(result);
}
}
normalize_separators(current_dir.to_string_lossy().to_string())
}
impl Module for PathModule {
fn render(&self, format: &str, context: &ModuleContext) -> Result<Option<String>> {
let Some(current_dir) = context.current_dir() else {
return Ok(None);
};
match format {
"" | "relative" | "r" => Ok(Some(normalize_relative_path(current_dir))),
"absolute" | "a" | "f" => Ok(Some(current_dir.to_string_lossy().to_string())),
"initials" | "i" => Ok(Some(transform_relative_path(
&normalize_relative_path(current_dir),
true,
shorten_segment_to_initial,
))),
"unvowel" | "u" => Ok(Some(transform_relative_path(
&normalize_relative_path(current_dir),
false,
unvowel_segment,
))),
"short" | "s" => Ok(current_dir
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.or_else(|| Some(".".to_string()))),
_ => Err(PromptError::InvalidFormat {
module: "path".to_string(),
format: format.to_string(),
valid_formats: "relative, r, absolute, a, f, initials, i, unvowel, u, short, s"
.to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
struct DirGuard {
original: std::path::PathBuf,
}
impl DirGuard {
fn change_to(path: &Path) -> Self {
let original = env::current_dir().expect("current dir");
env::set_current_dir(path).expect("change current dir");
Self { original }
}
}
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.original);
}
}
fn unique_name() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos()
.to_string()
}
#[test]
#[serial]
fn relative_path_inside_home_renders_tilde() {
let module = PathModule::new();
let home = dirs::home_dir().expect("home dir should exist");
let project = home.join(format!("prmt_test_project_{}", unique_name()));
match fs::create_dir_all(&project) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("Skipping test: {}", err);
return;
}
Err(err) => panic!("create project dir: {}", err),
}
let _dir_guard = DirGuard::change_to(&project);
let value = module
.render("", &ModuleContext::default())
.expect("render")
.expect("some");
assert!(
value.starts_with("~/prmt_test_project_"),
"Expected path to start with ~/prmt_test_project_, got: {}",
value
);
let _ = fs::remove_dir_all(&project);
}
#[test]
#[serial]
fn relative_path_with_shared_prefix_is_not_tilde() {
let module = PathModule::new();
let home = dirs::home_dir().expect("home dir should exist");
let unique = unique_name();
let base = home.join(format!("prmt_test_base_{}", unique));
let home_like = base.join("al");
let similar = base.join("alpine");
match fs::create_dir_all(&home_like) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("Skipping test: {}", err);
return;
}
Err(err) => panic!("create home_like: {}", err),
}
match fs::create_dir_all(&similar) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("Skipping test: {}", err);
return;
}
Err(err) => panic!("create similar: {}", err),
}
let _dir_guard = DirGuard::change_to(&similar);
let value = module
.render("", &ModuleContext::default())
.expect("render")
.expect("some");
assert!(
value.starts_with("~/prmt_test_base_"),
"Expected path to start with ~/prmt_test_base_, got: {}",
value
);
assert!(
value.ends_with("/alpine"),
"Expected path to end with /alpine, got: {}",
value
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn initials_preserve_last_segment_and_hidden_prefixes() {
assert_eq!(
transform_relative_path("~/dev/.config/prmt", true, shorten_segment_to_initial),
"~/d/.c/prmt"
);
assert_eq!(
transform_relative_path("/usr/local/bin", true, shorten_segment_to_initial),
"/u/l/bin"
);
assert_eq!(
transform_relative_path("~/.config", true, shorten_segment_to_initial),
"~/.config"
);
}
#[test]
fn unvowel_shortens_all_segments() {
assert_eq!(
transform_relative_path("~/projects/obsidian", false, unvowel_segment),
"~/prjcts/obsdn"
);
assert_eq!(
transform_relative_path("~/.config/nvim", false, unvowel_segment),
"~/.cnfg/nvm"
);
}
#[test]
fn unvowel_leaves_short_segments_unchanged() {
assert_eq!(
transform_relative_path("~/dev/api/prmt", false, unvowel_segment),
"~/dev/api/prmt"
);
assert_eq!(
transform_relative_path("~/.git/config", false, unvowel_segment),
"~/.git/cnfg"
);
}
}