use path_slash::PathExt as _;
use shell_escape::unix::escape;
use std::borrow::Cow;
use std::path::Path;
use sanitize_filename::{Options as SanitizeOptions, sanitize_with_options};
use crate::config::short_hash;
#[cfg(windows)]
use crate::shell_exec::{Cmd, ShellConfig};
#[cfg(windows)]
use std::path::PathBuf;
#[cfg(windows)]
pub fn to_posix_path(path: &str) -> String {
let Ok(shell) = ShellConfig::get() else {
return path.to_string();
};
let Some(cygpath) = find_cygpath_from_shell(shell) else {
return path.to_string();
};
let Ok(output) = Cmd::new(cygpath.to_string_lossy()).args(["-u", path]).run() else {
return path.to_string();
};
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
path.to_string()
}
}
#[cfg(not(windows))]
pub fn to_posix_path(path: &str) -> String {
path.to_string()
}
#[cfg(windows)]
fn find_cygpath_from_shell(shell: &crate::shell_exec::ShellConfig) -> Option<PathBuf> {
if !shell.is_posix {
return None;
}
let shell_dir = shell.executable.parent()?;
let cygpath = shell_dir.join("cygpath.exe");
if cygpath.exists() {
return Some(cygpath);
}
let cygpath = shell_dir
.parent()?
.join("usr")
.join("bin")
.join("cygpath.exe");
if cygpath.exists() {
return Some(cygpath);
}
None
}
pub use home::home_dir;
fn needs_shell_escaping(s: &str) -> bool {
!matches!(escape(Cow::Borrowed(s)), Cow::Borrowed(_))
}
pub fn format_path_for_display(path: &Path) -> String {
if let Some(home) = home_dir()
&& let Ok(stripped) = path.strip_prefix(&home)
{
if stripped.as_os_str().is_empty() {
return "~".to_string();
}
let rest = stripped.to_slash_lossy();
if !needs_shell_escaping(&rest) {
return format!("~/{rest}");
}
}
let original = path.to_slash_lossy();
match escape(Cow::Borrowed(&original)) {
Cow::Borrowed(_) => original.into_owned(),
Cow::Owned(escaped) => escaped,
}
}
pub fn sanitize_for_filename(value: &str) -> String {
let mut result = sanitize_with_options(
value,
SanitizeOptions {
windows: true,
truncate: false,
replacement: "-",
},
);
if result.is_empty() {
result = "_empty".to_string();
}
if !result.ends_with('-') {
result.push('-');
}
result.push_str(&short_hash(value));
result
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{format_path_for_display, home_dir, sanitize_for_filename, to_posix_path};
#[test]
fn shortens_path_under_home() {
let Some(home) = home_dir() else {
return;
};
let path = home.join("projects").join("wt");
let formatted = format_path_for_display(&path);
assert!(
formatted.starts_with("~"),
"Expected tilde prefix, got {formatted}"
);
assert!(
formatted.contains("projects"),
"Expected child components to remain in output"
);
assert!(
formatted.ends_with("wt"),
"Expected leaf component to remain in output"
);
}
#[test]
fn shows_home_as_tilde() {
let Some(home) = home_dir() else {
return;
};
let formatted = format_path_for_display(&home);
assert_eq!(formatted, "~");
}
#[test]
fn leaves_non_home_paths_unchanged() {
let path = PathBuf::from("/tmp/worktrunk-non-home-path");
let formatted = format_path_for_display(&path);
assert_eq!(formatted, path.display().to_string());
}
#[test]
fn to_posix_path_leaves_unix_paths_unchanged() {
assert_eq!(to_posix_path("/tmp/test/repo"), "/tmp/test/repo");
assert_eq!(to_posix_path("relative/path"), "relative/path");
}
#[test]
#[cfg(windows)]
fn to_posix_path_converts_windows_drive_letter() {
let result = to_posix_path(r"C:\Users\test");
assert!(
result.starts_with("/c/"),
"Expected /c/ prefix, got: {result}"
);
assert!(
result.contains("Users"),
"Expected Users in path, got: {result}"
);
}
#[test]
#[cfg(windows)]
fn to_posix_path_handles_verbatim_paths() {
let result = to_posix_path(r"\\?\C:\Users\test");
assert!(
result.contains("/c/") || result.contains("Users"),
"Expected converted path, got: {result}"
);
}
#[test]
fn test_home_dir_returns_valid_path() {
if let Some(home) = home_dir() {
assert!(home.is_absolute(), "Home directory should be absolute");
assert!(home.components().count() > 0, "Home should have components");
}
}
#[test]
fn test_format_path_outside_home() {
let path = PathBuf::from("/definitely/not/under/home/dir");
let result = format_path_for_display(&path);
assert_eq!(result, "/definitely/not/under/home/dir");
}
#[test]
#[cfg(not(windows))]
fn test_to_posix_path_on_unix() {
assert_eq!(to_posix_path("/some/path"), "/some/path");
assert_eq!(to_posix_path("relative"), "relative");
assert_eq!(to_posix_path(""), "");
}
#[test]
fn test_sanitize_for_filename_replaces_invalid_chars() {
assert!(sanitize_for_filename("foo/bar").starts_with("foo-bar-"));
assert!(sanitize_for_filename("name:with?chars").starts_with("name-with-chars-"));
}
#[test]
fn test_sanitize_for_filename_trims_trailing_dots_and_spaces() {
assert!(sanitize_for_filename("file. ").starts_with("file-"));
assert!(sanitize_for_filename("file...").starts_with("file-"));
}
#[test]
fn test_sanitize_for_filename_handles_reserved_names() {
let con = sanitize_for_filename("CON");
let com1 = sanitize_for_filename("com1");
assert!(
!con.is_empty() && con.len() > 3,
"CON should produce valid filename: {con}"
);
assert!(
!com1.is_empty() && com1.len() > 3,
"com1 should produce valid filename: {com1}"
);
}
#[test]
fn test_sanitize_for_filename_handles_empty() {
assert!(sanitize_for_filename("").starts_with("_empty-"));
}
#[test]
fn test_sanitize_for_filename_avoids_collisions() {
let a = sanitize_for_filename("origin/feature");
let b = sanitize_for_filename("origin-feature");
assert_ne!(a, b, "collision: {a} == {b}");
assert!(a.starts_with("origin-feature-"));
assert!(b.starts_with("origin-feature-"));
}
#[test]
#[cfg(unix)]
fn format_path_for_display_escaping() {
use insta::assert_snapshot;
let Some(home) = home_dir() else {
return;
};
let mut lines = Vec::new();
for path_str in [
"/tmp/repo",
"/tmp/my repo",
"/tmp/file;rm -rf",
"/tmp/test'quote",
] {
let path = PathBuf::from(path_str);
lines.push(format!(
"{} => {}",
path_str,
format_path_for_display(&path)
));
}
let home_cases = [
"workspace/repo", "my workspace/repo", "project's/repo", ];
for suffix in home_cases {
let path = home.join(suffix);
let result = format_path_for_display(&path);
let display = if result.starts_with('\'') {
"QUOTED_ABSOLUTE".to_string()
} else {
result
};
lines.push(format!("$HOME/{} => {}", suffix, display));
}
lines.push(format!("$HOME => {}", format_path_for_display(&home)));
assert_snapshot!(lines.join("\n"), @r"
/tmp/repo => /tmp/repo
/tmp/my repo => '/tmp/my repo'
/tmp/file;rm -rf => '/tmp/file;rm -rf'
/tmp/test'quote => '/tmp/test'\''quote'
$HOME/workspace/repo => ~/workspace/repo
$HOME/my workspace/repo => QUOTED_ABSOLUTE
$HOME/project's/repo => QUOTED_ABSOLUTE
$HOME => ~
");
}
}