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;
#[cfg(unix)]
use color_print::cformat;
use std::sync::{Mutex, OnceLock};
#[cfg(not(unix))]
use worktrunk::git::WorktrunkError;
#[cfg(not(unix))]
use worktrunk::shell_exec::Cmd;
use worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR;
#[cfg(unix)]
use worktrunk::shell_exec::ShellConfig;
pub use worktrunk::styling::set_verbosity;
static OUTPUT_STATE: OnceLock<Mutex<OutputState>> = OnceLock::new();
#[derive(Default)]
struct OutputState {
directive_file: Option<PathBuf>,
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 directive_file = std::env::var(DIRECTIVE_FILE_ENV_VAR)
.ok()
.filter(|s| !s.trim().is_empty())
.map(PathBuf::from);
let symlink_mapping = SymlinkMapping::compute();
Mutex::new(OutputState {
directive_file,
target_dir: None,
symlink_mapping,
cwd_removed: false,
})
})
}
fn has_directive_file() -> bool {
state()
.lock()
.expect("OUTPUT_STATE lock poisoned")
.directive_file
.is_some()
}
fn write_directive(directive: &str) -> io::Result<()> {
let path = {
let guard = state().lock().expect("OUTPUT_STATE lock poisoned");
guard.directive_file.clone()
};
let Some(path) = path else {
return Ok(());
};
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "{}", directive)?;
file.flush()
}
pub fn change_directory(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
let mut guard = state().lock().expect("OUTPUT_STATE lock poisoned");
guard.target_dir = Some(path.to_path_buf());
if guard.directive_file.is_some() {
drop(guard);
let directive_path = to_logical_path(path);
let path_str = directive_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('\'', "'\\''")
};
write_directive(&format!("cd '{}'", escaped))?;
}
Ok(())
}
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 (has_directive, target_dir) = {
let guard = state().lock().expect("OUTPUT_STATE lock poisoned");
(guard.directive_file.is_some(), guard.target_dir.clone())
};
if has_directive {
write_directive(&command)?;
Ok(())
} else {
execute_command(command, target_dir.as_deref())
}
}
#[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 !has_directive_file() {
return Ok(());
}
let mut stderr = io::stderr();
write!(stderr, "{}", anstyle::Reset)?;
stderr.flush()
}
pub fn is_shell_integration_active() -> bool {
has_directive_file()
}
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> {
if is_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(&elsewhere);
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(&cwd);
assert!(
result.is_none(),
"Should return None when destination is cwd (no shell integration)"
);
}
#[test]
fn test_lazy_init_does_not_panic() {
let _ = has_directive_file();
}
#[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_shell_script_format() {
let path = PathBuf::from("/test/path");
let path_str = path.to_string_lossy();
let escaped = path_str.replace('\'', "'\\''");
let cd_cmd = format!("cd '{}'", escaped);
assert_eq!(cd_cmd, "cd '/test/path'");
}
#[test]
fn test_path_with_single_quotes() {
let path = PathBuf::from("/test/it's/path");
let path_str = path.to_string_lossy();
let escaped = path_str.replace('\'', "'\\''");
let cd_cmd = format!("cd '{}'", escaped);
assert_eq!(cd_cmd, "cd '/test/it'\\''s/path'");
}
#[test]
fn test_path_with_spaces() {
let path = PathBuf::from("/test/my path/here");
let path_str = path.to_string_lossy();
let escaped = path_str.replace('\'', "'\\''");
let cd_cmd = format!("cd '{}'", escaped);
assert_eq!(cd_cmd, "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', "\\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', "\\x1b"));
}
}