use std::fs::OpenOptions;
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use color_print::cformat;
use std::sync::{Mutex, OnceLock};
#[cfg(not(unix))]
use worktrunk::git::WorktrunkError;
#[cfg(not(unix))]
use worktrunk::shell_exec::Cmd;
#[cfg(unix)]
use worktrunk::shell_exec::ShellConfig;
use worktrunk::shell_exec::{
DIRECTIVE_CD_FILE_ENV_VAR, DIRECTIVE_EXEC_FILE_ENV_VAR, DIRECTIVE_FILE_ENV_VAR,
};
use worktrunk::styling::{hint_message, warning_message};
pub const EXEC_SCRUB_ISSUE_URL: &str = "https://github.com/max-sixty/worktrunk/issues/2101";
pub use worktrunk::styling::set_verbosity;
static OUTPUT_STATE: OnceLock<Mutex<OutputState>> = OnceLock::new();
#[derive(Debug, Clone, Default)]
enum DirectiveMode {
#[default]
Interactive,
NewProtocol {
cd_file: PathBuf,
exec_file: Option<PathBuf>,
},
Legacy { file: PathBuf },
}
#[derive(Default)]
struct OutputState {
mode: DirectiveMode,
target_dir: Option<PathBuf>,
symlink_mapping: Option<SymlinkMapping>,
cwd_removed: bool,
}
#[derive(Debug, Clone)]
struct SymlinkMapping {
canonical_prefix: PathBuf,
logical_prefix: PathBuf,
}
impl SymlinkMapping {
fn compute() -> Option<Self> {
let logical_cwd = PathBuf::from(std::env::var("PWD").ok()?);
let canonical_cwd = std::env::current_dir().ok()?;
let canonical_of_pwd = dunce::canonicalize(&logical_cwd).ok();
Self::from_paths(&logical_cwd, &canonical_cwd, canonical_of_pwd.as_deref())
}
fn from_paths(
logical_cwd: &Path,
canonical_cwd: &Path,
canonical_of_logical: Option<&Path>,
) -> Option<Self> {
if logical_cwd == canonical_cwd {
return None;
}
if canonical_of_logical != Some(canonical_cwd) {
return None;
}
let logical_components: Vec<_> = logical_cwd.components().collect();
let canonical_components: Vec<_> = canonical_cwd.components().collect();
let common_suffix_len = logical_components
.iter()
.rev()
.zip(canonical_components.iter().rev())
.take_while(|(l, c)| l == c)
.count();
if common_suffix_len == 0 {
return None;
}
let logical_prefix: PathBuf = logical_components
[..logical_components.len() - common_suffix_len]
.iter()
.collect();
let canonical_prefix: PathBuf = canonical_components
[..canonical_components.len() - common_suffix_len]
.iter()
.collect();
Some(SymlinkMapping {
canonical_prefix,
logical_prefix,
})
}
fn to_logical_path(&self, canonical_path: &Path) -> Option<PathBuf> {
let remainder = canonical_path.strip_prefix(&self.canonical_prefix).ok()?;
Some(self.logical_prefix.join(remainder))
}
}
pub fn to_logical_path(path: &Path) -> PathBuf {
let guard = state().lock().expect("OUTPUT_STATE lock poisoned");
let Some(mapping) = &guard.symlink_mapping else {
return path.to_path_buf();
};
mapping
.to_logical_path(path)
.filter(|translated| dunce::canonicalize(translated).ok() == dunce::canonicalize(path).ok())
.unwrap_or_else(|| path.to_path_buf())
}
fn state() -> &'static Mutex<OutputState> {
OUTPUT_STATE.get_or_init(|| {
let mode = compute_directive_mode();
let symlink_mapping = SymlinkMapping::compute();
Mutex::new(OutputState {
mode,
target_dir: None,
symlink_mapping,
cwd_removed: false,
})
})
}
fn read_env_path(var: &str) -> Option<PathBuf> {
std::env::var(var)
.ok()
.filter(|s| !s.trim().is_empty())
.map(PathBuf::from)
}
fn compute_directive_mode() -> DirectiveMode {
let cd = read_env_path(DIRECTIVE_CD_FILE_ENV_VAR);
let exec = read_env_path(DIRECTIVE_EXEC_FILE_ENV_VAR);
let legacy = read_env_path(DIRECTIVE_FILE_ENV_VAR);
match cd {
Some(cd_file) => DirectiveMode::NewProtocol {
cd_file,
exec_file: exec,
},
None => match legacy {
Some(file) => DirectiveMode::Legacy { file },
None => DirectiveMode::Interactive,
},
}
}
fn warn_exec_scrubbed_once(command: &str) {
static WARNED: OnceLock<()> = OnceLock::new();
if WARNED.set(()).is_err() {
return;
}
eprintln!(
"{}",
warning_message(cformat!(
"<bold>--execute</> disabled inside alias/hook bodies for safety; skipping <bold>{command}</>"
))
);
eprintln!(
"{}",
hint_message(cformat!(
"This is extremely conservative; comment at <underline>{EXEC_SCRUB_ISSUE_URL}</> if this affects you"
))
);
}
fn append_line(path: &Path, line: &str) -> io::Result<()> {
let mut file = OpenOptions::new().append(true).open(path)?;
writeln!(file, "{}", line)?;
file.flush()
}
fn write_cd_path(file: &Path, path: &Path) -> io::Result<()> {
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(file)?;
f.write_all(path.as_os_str().to_string_lossy().as_bytes())?;
f.write_all(b"\n")?;
f.flush()
}
fn escape_legacy_cd(path: &Path) -> String {
let path_str = path.to_string_lossy();
let is_powershell = std::env::var("WORKTRUNK_SHELL")
.map(|v| v.eq_ignore_ascii_case("powershell"))
.unwrap_or(false);
let escaped = if is_powershell {
path_str.replace('\'', "''")
} else {
path_str.replace('\'', r"'\''")
};
format!("cd '{}'", escaped)
}
pub fn change_directory(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
let mode = {
let mut guard = state().lock().expect("OUTPUT_STATE lock poisoned");
guard.target_dir = Some(path.to_path_buf());
guard.mode.clone()
};
match mode {
DirectiveMode::Interactive => Ok(()),
DirectiveMode::NewProtocol { cd_file, .. } => {
let directive_path = to_logical_path(path);
write_cd_path(&cd_file, &directive_path)
}
DirectiveMode::Legacy { file } => {
let directive_path = to_logical_path(path);
append_line(&file, &escape_legacy_cd(&directive_path))
}
}
}
pub fn mark_cwd_removed() {
state()
.lock()
.expect("OUTPUT_STATE lock poisoned")
.cwd_removed = true;
}
pub fn was_cwd_removed() -> bool {
state()
.lock()
.expect("OUTPUT_STATE lock poisoned")
.cwd_removed
}
pub fn execute(command: impl Into<String>) -> anyhow::Result<()> {
let command = command.into();
let (mode, target_dir) = {
let guard = state().lock().expect("OUTPUT_STATE lock poisoned");
(guard.mode.clone(), guard.target_dir.clone())
};
match mode {
DirectiveMode::Interactive => execute_command(command, target_dir.as_deref()),
DirectiveMode::NewProtocol {
exec_file: Some(file),
..
} => {
append_line(&file, &command)?;
Ok(())
}
DirectiveMode::NewProtocol {
exec_file: None, ..
} => {
warn_exec_scrubbed_once(&command);
Ok(())
}
DirectiveMode::Legacy { file } => {
append_line(&file, &command)?;
Ok(())
}
}
}
pub fn exec_would_be_refused() -> bool {
let guard = state().lock().expect("OUTPUT_STATE lock poisoned");
matches!(
guard.mode,
DirectiveMode::NewProtocol {
exec_file: None,
..
}
)
}
#[cfg(unix)]
fn execute_command(command: String, target_dir: Option<&Path>) -> anyhow::Result<()> {
let exec_dir = target_dir.unwrap_or_else(|| Path::new("."));
let shell = ShellConfig::get()?;
let mut cmd = shell.command(&command);
let err = cmd
.current_dir(exec_dir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.exec();
Err(anyhow::anyhow!(cformat!(
"Failed to exec <bold>{}</> with {}: {}",
command,
shell.name,
err
)))
}
#[cfg(not(unix))]
fn execute_command(command: String, target_dir: Option<&Path>) -> anyhow::Result<()> {
let mut cmd = Cmd::shell(&command).stdin(Stdio::inherit());
if let Some(dir) = target_dir {
cmd = cmd.current_dir(dir);
}
if let Err(err) = cmd.stream() {
if let Some(WorktrunkError::ChildProcessExited { code, .. }) =
err.downcast_ref::<WorktrunkError>()
{
std::process::exit(*code);
}
return Err(err);
}
Ok(())
}
pub fn terminate_output() -> io::Result<()> {
if !is_shell_integration_active() {
return Ok(());
}
let mut stderr = io::stderr();
write!(stderr, "{}", anstyle::Reset)?;
stderr.flush()
}
pub fn is_shell_integration_active() -> bool {
!matches!(
state().lock().expect("OUTPUT_STATE lock poisoned").mode,
DirectiveMode::Interactive
)
}
pub fn compute_hooks_display_path<'a>(
hooks_run_at: &'a std::path::Path,
user_location: &std::path::Path,
) -> Option<&'a std::path::Path> {
let same_location = match (
dunce::canonicalize(hooks_run_at),
dunce::canonicalize(user_location),
) {
(Ok(h), Ok(u)) => h == u,
_ => hooks_run_at == user_location,
};
if same_location {
None
} else {
Some(hooks_run_at)
}
}
pub fn pre_hook_display_path(hooks_run_at: &std::path::Path) -> Option<&std::path::Path> {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(_) => {
return Some(hooks_run_at);
}
};
compute_hooks_display_path(hooks_run_at, &cwd)
}
pub fn post_hook_display_path(destination: &std::path::Path) -> Option<&std::path::Path> {
post_hook_display_path_with(destination, is_shell_integration_active())
}
fn post_hook_display_path_with(
destination: &std::path::Path,
shell_integration_active: bool,
) -> Option<&std::path::Path> {
if shell_integration_active {
None } else {
pre_hook_display_path(destination)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_hooks_display_path_same_location() {
let path = PathBuf::from("/repo/worktree");
let result = compute_hooks_display_path(&path, &path);
assert!(result.is_none(), "Should return None when paths match");
}
#[test]
fn test_compute_hooks_display_path_different_location() {
let hooks_run_at = PathBuf::from("/repo/feature");
let user_location = PathBuf::from("/repo/main");
let result = compute_hooks_display_path(&hooks_run_at, &user_location);
assert_eq!(result, Some(hooks_run_at.as_path()));
}
#[test]
fn test_pre_hook_display_path_at_cwd() {
let cwd = std::env::current_dir().unwrap();
let result = pre_hook_display_path(&cwd);
assert!(result.is_none(), "Should return None when hooks run at cwd");
}
#[test]
fn test_pre_hook_display_path_elsewhere() {
let elsewhere = PathBuf::from("/some/other/path");
let result = pre_hook_display_path(&elsewhere);
assert_eq!(
result,
Some(elsewhere.as_path()),
"Should return path when hooks run elsewhere"
);
}
#[test]
fn test_post_hook_display_path_no_shell_integration() {
let elsewhere = PathBuf::from("/some/destination");
let result = post_hook_display_path_with(&elsewhere, false);
let cwd = std::env::current_dir().unwrap();
if cwd == elsewhere {
assert!(result.is_none());
} else {
assert_eq!(result, Some(elsewhere.as_path()));
}
}
#[test]
fn test_post_hook_display_path_at_cwd_no_shell_integration() {
let cwd = std::env::current_dir().unwrap();
let result = post_hook_display_path_with(&cwd, false);
assert!(
result.is_none(),
"Should return None when destination is cwd (no shell integration)"
);
}
#[test]
fn test_post_hook_display_path_with_shell_integration() {
let elsewhere = PathBuf::from("/some/destination");
let result = post_hook_display_path_with(&elsewhere, true);
assert!(result.is_none());
}
#[test]
fn test_lazy_init_does_not_panic() {
let _ = is_shell_integration_active();
}
#[test]
fn test_cwd_removed_flag() {
mark_cwd_removed();
assert!(was_cwd_removed());
}
#[test]
fn test_spawned_thread_uses_correct_state() {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = is_shell_integration_active();
tx.send(()).unwrap();
})
.join()
.unwrap();
rx.recv().unwrap();
}
#[test]
fn test_escape_legacy_cd_simple_path() {
let result = escape_legacy_cd(Path::new("/test/path"));
assert_eq!(result, "cd '/test/path'");
}
#[test]
fn test_escape_legacy_cd_single_quotes() {
let result = escape_legacy_cd(Path::new("/test/it's/path"));
assert_eq!(result, r"cd '/test/it'\''s/path'");
}
#[test]
fn test_escape_legacy_cd_spaces() {
let result = escape_legacy_cd(Path::new("/test/my path/here"));
assert_eq!(result, "cd '/test/my path/here'");
}
#[test]
fn test_success_preserves_anstyle() {
use anstyle::{AnsiColor, Color, Style};
let bold = Style::new().bold();
let cyan = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
let styled = format!("{cyan}Styled{cyan:#} {bold}message{bold:#}");
assert!(
styled.contains('\x1b'),
"Styled message should contain ANSI escape codes"
);
}
#[test]
fn test_color_reset_on_empty_style() {
use anstyle::Style;
let empty_style = Style::new();
let output = format!("{:#}", empty_style);
assert_eq!(
output, "",
"BUG: Empty style reset produces empty string, not ANSI reset"
);
}
#[test]
fn test_proper_reset_with_anstyle_reset() {
use anstyle::Reset;
let output = format!("{}", Reset);
assert!(
output.starts_with('\x1b'),
"Reset should produce ANSI escape code, got: {:?}",
output
);
}
#[test]
fn test_symlink_mapping_to_logical_path() {
let mapping = SymlinkMapping {
canonical_prefix: PathBuf::from("/mnt/wsl"),
logical_prefix: PathBuf::from("/"),
};
let result = mapping.to_logical_path(Path::new("/mnt/wsl/workspace/project.feature"));
assert_eq!(result, Some(PathBuf::from("/workspace/project.feature")));
}
#[test]
fn test_symlink_mapping_preserves_deep_paths() {
let mapping = SymlinkMapping {
canonical_prefix: PathBuf::from("/mnt/wsl"),
logical_prefix: PathBuf::from("/"),
};
let result = mapping.to_logical_path(Path::new("/mnt/wsl/a/b/c/d"));
assert_eq!(result, Some(PathBuf::from("/a/b/c/d")));
}
#[test]
fn test_symlink_mapping_no_match() {
let mapping = SymlinkMapping {
canonical_prefix: PathBuf::from("/mnt/wsl"),
logical_prefix: PathBuf::from("/"),
};
let result = mapping.to_logical_path(Path::new("/other/path"));
assert_eq!(result, None);
}
#[test]
fn test_symlink_mapping_macos_private_var() {
let mapping = SymlinkMapping {
canonical_prefix: PathBuf::from("/private"),
logical_prefix: PathBuf::from("/"),
};
let result = mapping.to_logical_path(Path::new("/private/var/folders/project.feature"));
assert_eq!(result, Some(PathBuf::from("/var/folders/project.feature")));
}
#[test]
fn test_symlink_mapping_equal_length_prefixes() {
let mapping = SymlinkMapping {
canonical_prefix: PathBuf::from("/real/path"),
logical_prefix: PathBuf::from("/link/path"),
};
let result = mapping.to_logical_path(Path::new("/real/path/workspace/project"));
assert_eq!(result, Some(PathBuf::from("/link/path/workspace/project")));
}
#[test]
fn test_from_paths_no_symlink() {
let result = SymlinkMapping::from_paths(
Path::new("/workspace/project"),
Path::new("/workspace/project"),
Some(Path::new("/workspace/project")),
);
assert!(result.is_none());
}
#[test]
fn test_from_paths_stale_pwd() {
let result = SymlinkMapping::from_paths(
Path::new("/old/link/project"),
Path::new("/real/project"),
Some(Path::new("/different/project")),
);
assert!(result.is_none());
}
#[test]
fn test_from_paths_canonicalize_failed() {
let result = SymlinkMapping::from_paths(
Path::new("/link/project"),
Path::new("/real/project"),
None,
);
assert!(result.is_none());
}
#[test]
fn test_from_paths_no_common_suffix() {
let result = SymlinkMapping::from_paths(
Path::new("/link/alpha"),
Path::new("/real/beta"),
Some(Path::new("/real/beta")),
);
assert!(result.is_none());
}
#[test]
fn test_from_paths_wsl_style_symlink() {
let result = SymlinkMapping::from_paths(
Path::new("/workspace/project"),
Path::new("/mnt/wsl/workspace/project"),
Some(Path::new("/mnt/wsl/workspace/project")),
);
let mapping = result.expect("should produce mapping");
assert_eq!(mapping.logical_prefix, PathBuf::from("/"));
assert_eq!(mapping.canonical_prefix, PathBuf::from("/mnt/wsl"));
}
#[test]
fn test_from_paths_macos_private_var() {
let result = SymlinkMapping::from_paths(
Path::new("/var/folders/xx/tmp"),
Path::new("/private/var/folders/xx/tmp"),
Some(Path::new("/private/var/folders/xx/tmp")),
);
let mapping = result.expect("should produce mapping");
assert_eq!(mapping.logical_prefix, PathBuf::from("/"));
assert_eq!(mapping.canonical_prefix, PathBuf::from("/private"));
}
#[test]
fn test_from_paths_equal_depth_prefixes() {
let result = SymlinkMapping::from_paths(
Path::new("/link/path/project"),
Path::new("/real/path/project"),
Some(Path::new("/real/path/project")),
);
let mapping = result.expect("should produce mapping");
assert_eq!(mapping.logical_prefix, PathBuf::from("/link"));
assert_eq!(mapping.canonical_prefix, PathBuf::from("/real"));
}
#[test]
fn test_nested_style_resets_leak_color() {
use anstyle::{AnsiColor, Color, Style};
let warning = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
let bold = Style::new().bold();
let bad_output = format!("{warning}Text with {bold}nested{bold:#} styles{warning:#}");
std::println!(
"Nested reset output: {}",
bad_output.replace('\x1b', r"\x1b")
);
let warning_bold = warning.bold();
let good_output =
format!("{warning}Text with {warning_bold}composed{warning_bold:#} styles{warning:#}");
std::println!("Composed output: {}", good_output.replace('\x1b', r"\x1b"));
}
}