use std::collections::HashSet;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use super::paths::{home_dir_required, powershell_profile_paths};
pub fn is_shell_integration_line(line: &str, cmd: &str) -> bool {
is_shell_integration_line_impl(line, cmd, true)
}
pub fn is_shell_integration_line_for_uninstall(line: &str, cmd: &str) -> bool {
is_shell_integration_line_impl(line, cmd, false)
}
fn is_shell_integration_line_impl(line: &str, cmd: &str, strict: bool) -> bool {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.starts_with("<#") {
return false;
}
has_init_invocation(trimmed, cmd, strict)
}
fn has_init_invocation(line: &str, cmd: &str, strict: bool) -> bool {
if cmd == "git-wt" {
return has_init_pattern_with_prefix_check(line, "git-wt", strict)
|| has_init_pattern_with_prefix_check(line, "git wt", strict);
}
has_init_pattern_with_prefix_check(line, cmd, strict)
}
fn has_init_pattern_with_prefix_check(line: &str, cmd: &str, strict: bool) -> bool {
let patterns = [
format!("{cmd} config shell init"),
format!("{cmd}.exe config shell init"),
];
for init_pattern in &patterns {
let cmd_in_line = if init_pattern.contains(".exe") {
format!("{cmd}.exe")
} else {
cmd.to_string()
};
let mut search_start = 0;
while let Some(pos) = line[search_start..].find(init_pattern.as_str()) {
let absolute_pos = search_start + pos;
if is_valid_command_position(line, absolute_pos, &cmd_in_line) {
let line_lower = line.to_lowercase();
let has_invoke =
line_lower.contains("invoke-expression") || line_lower.contains("iex");
if has_invoke {
if !strict || line_lower.contains("out-string") {
return true;
}
search_start = absolute_pos + 1;
continue;
}
let is_shell_exec = line.contains("eval")
|| line.contains("source")
|| line.contains(". <(") || line.contains(". =(") || line.contains("save");
if is_shell_exec {
return true;
}
}
search_start = absolute_pos + 1;
}
}
false
}
fn is_valid_command_position(line: &str, pos: usize, cmd: &str) -> bool {
if pos == 0 {
return true; }
let before = &line[..pos];
if cmd == "git-wt" || cmd == "git-wt.exe" {
let last_char = before.chars().last().unwrap();
return !last_char.is_alphanumeric() && last_char != '_' && last_char != '-';
}
if before.ends_with("git ") || before.ends_with("git-") {
return false;
}
let last_char = before.chars().last().unwrap();
matches!(last_char, ' ' | '\t' | '$' | '(' | '"' | '\'' | '`' | '/')
}
fn contains_cmd_at_word_boundary(line: &str, cmd: &str) -> bool {
let mut search_start = 0;
while let Some(pos) = line[search_start..].find(cmd) {
let absolute_pos = search_start + pos;
let before_ok = if absolute_pos == 0 {
true
} else {
let prev_char = line[..absolute_pos].chars().last().unwrap();
!prev_char.is_alphanumeric() && prev_char != '_' && prev_char != '-'
};
let after_pos = absolute_pos + cmd.len();
let after_ok = if after_pos >= line.len() {
true
} else {
let next_char = line[after_pos..].chars().next().unwrap();
!next_char.is_alphanumeric() && next_char != '_' && next_char != '-'
};
if before_ok && after_ok {
return true;
}
search_start = absolute_pos + 1;
}
false
}
#[derive(Debug, Clone)]
pub struct DetectedLine {
pub line_number: usize,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct FileDetectionResult {
pub path: PathBuf,
pub matched_lines: Vec<DetectedLine>,
pub unmatched_candidates: Vec<DetectedLine>,
pub bypass_aliases: Vec<BypassAlias>,
}
#[derive(Debug, Clone)]
pub struct BypassAlias {
pub line_number: usize,
pub alias_name: String,
pub target: String,
pub content: String,
}
fn scan_file(path: &std::path::Path, cmd: &str) -> Option<FileDetectionResult> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut matched_lines = Vec::new();
let mut unmatched_candidates = Vec::new();
let mut bypass_aliases = Vec::new();
for (line_number, line) in reader.lines().map_while(Result::ok).enumerate() {
let line_number = line_number + 1; let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if is_shell_integration_line(&line, cmd) {
matched_lines.push(DetectedLine {
line_number,
content: line.clone(),
});
} else if contains_cmd_at_word_boundary(&line, cmd) {
unmatched_candidates.push(DetectedLine {
line_number,
content: line.clone(),
});
}
if let Some(alias) = detect_bypass_alias(trimmed, cmd, line_number) {
bypass_aliases.push(BypassAlias {
content: line.clone(),
..alias
});
}
}
if matched_lines.is_empty() && unmatched_candidates.is_empty() && bypass_aliases.is_empty() {
return None;
}
Some(FileDetectionResult {
path: path.to_path_buf(),
matched_lines,
unmatched_candidates,
bypass_aliases,
})
}
fn detect_bypass_alias(line: &str, cmd: &str, line_number: usize) -> Option<BypassAlias> {
let line = line.trim();
if !line.starts_with("alias ") {
return None;
}
let after_alias = line[6..].trim_start();
let eq_pos = after_alias.find('=')?;
let alias_name = after_alias[..eq_pos].trim();
let target_part = after_alias[eq_pos + 1..].trim();
let target = if let Some(stripped) = target_part.strip_prefix('"') {
let end = stripped.find('"')?;
&stripped[..end]
} else if let Some(stripped) = target_part.strip_prefix('\'') {
let end = stripped.find('\'')?;
&stripped[..end]
} else {
target_part.split_whitespace().next()?
};
let target_lower = target.to_ascii_lowercase();
let is_binary_target =
target.contains('/') || target.contains('\\') || target_lower.ends_with(".exe");
if !is_binary_target {
return None;
}
let filename = target.rsplit(['/', '\\']).next().unwrap_or(target);
let filename_lower = filename.to_ascii_lowercase();
let cmd_lower = cmd.to_ascii_lowercase();
let matches = filename_lower == cmd_lower || filename_lower == format!("{cmd_lower}.exe");
if !matches {
return None;
}
Some(BypassAlias {
line_number,
alias_name: alias_name.to_string(),
target: target.to_string(),
content: String::new(), })
}
pub fn scan_for_detection_details(cmd: &str) -> Result<Vec<FileDetectionResult>, std::io::Error> {
let home = home_dir_required()?;
let mut results = Vec::new();
let mut config_files: Vec<PathBuf> = vec![
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".profile"),
home.join(".zshrc"),
std::env::var("ZDOTDIR")
.map(PathBuf::from)
.unwrap_or_else(|_| home.clone())
.join(".zshrc"),
home.join(".config/fish/functions")
.join(format!("{cmd}.fish")),
home.join(".config/fish/conf.d").join(format!("{cmd}.fish")),
];
config_files.extend(super::config_paths(super::Shell::Nushell, cmd).unwrap_or_default());
config_files.extend(powershell_profile_paths(&home));
let mut seen = HashSet::new();
for path in config_files {
if !seen.insert(path.clone()) || !path.exists() {
continue;
}
if let Some(result) = scan_file(&path, cmd) {
results.push(result);
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::basic_eval(r#"eval "$(wt config shell init bash)""#)]
#[case::with_command(r#"eval "$(command wt config shell init bash)""#)]
#[case::source_process_sub(r#"source <(wt config shell init zsh)"#)]
#[case::fish_source(r#"wt config shell init fish | source"#)]
#[case::with_if_check(
r#"if command -v wt >/dev/null; then eval "$(wt config shell init bash)"; fi"#
)]
#[case::single_quotes(r#"eval '$( wt config shell init bash )'"#)]
fn test_wt_eval_patterns_match(#[case] line: &str) {
assert!(
is_shell_integration_line(line, "wt"),
"Should match for 'wt': {line}"
);
}
#[rstest]
#[case::git_space_wt(r#"eval "$(git wt config shell init bash)""#)]
#[case::git_hyphen_wt(r#"eval "$(git-wt config shell init bash)""#)]
#[case::command_git_wt(r#"eval "$(command git wt config shell init bash)""#)]
#[case::command_git_hyphen_wt(r#"eval "$(command git-wt config shell init bash)""#)]
fn test_git_wt_patterns_dont_match_wt(#[case] line: &str) {
assert!(
!is_shell_integration_line(line, "wt"),
"Should NOT match for 'wt' (this is git-wt integration): {line}"
);
}
#[rstest]
#[case::git_hyphen_wt(r#"eval "$(git-wt config shell init bash)""#)]
#[case::git_space_wt(r#"eval "$(git wt config shell init bash)""#)]
#[case::command_git_wt(r#"eval "$(command git wt config shell init bash)""#)]
fn test_git_wt_eval_patterns_match(#[case] line: &str) {
assert!(
is_shell_integration_line(line, "git-wt"),
"Should match for 'git-wt': {line}"
);
}
#[rstest]
#[case::bash_comment(r#"# eval "$(wt config shell init bash)""#)]
#[case::indented_comment(r#" # eval "$(wt config shell init bash)""#)]
fn test_comments_dont_match(#[case] line: &str) {
assert!(
!is_shell_integration_line(line, "wt"),
"Comment should not match: {line}"
);
}
#[rstest]
#[case::just_command("wt config shell init bash")]
#[case::echo(r#"echo "wt config shell init bash""#)]
fn test_no_execution_context_doesnt_match(#[case] line: &str) {
assert!(
!is_shell_integration_line(line, "wt"),
"Without eval/source should not match: {line}"
);
}
#[rstest]
#[case::chezmoi_style(
r#"if command -v wt &>/dev/null; then eval "$(wt config shell init bash)"; fi"#,
"wt",
true
)]
#[case::nikiforov_style(r#"eval "$(command git wt config shell init bash)""#, "git-wt", true)]
#[case::nikiforov_not_wt(r#"eval "$(command git wt config shell init bash)""#, "wt", false)]
fn test_real_world_patterns(#[case] line: &str, #[case] cmd: &str, #[case] should_match: bool) {
assert_eq!(
is_shell_integration_line(line, cmd),
should_match,
"Line: {line}\nCommand: {cmd}\nExpected: {should_match}"
);
}
#[rstest]
#[case::wt_exe_basic(r#"eval "$(wt.exe config shell init bash)""#, "wt", true)]
#[case::wt_exe_with_command(r#"eval "$(command wt.exe config shell init bash)""#, "wt", true)]
#[case::git_wt_exe_basic(r#"eval "$(git-wt.exe config shell init bash)""#, "git-wt", true)]
#[case::git_wt_exe_with_command(
r#"eval "$(command git-wt.exe config shell init bash)""#,
"git-wt",
true
)]
#[case::git_wt_exe_with_if(
r#"if command -v git-wt.exe &> /dev/null; then eval "$(command git-wt.exe config shell init bash)"; fi"#,
"git-wt",
true
)]
#[case::issue_348_exact(
r#"eval "$(command git-wt.exe config shell init bash)""#,
"git-wt",
true
)]
fn test_windows_exe_suffix(#[case] line: &str, #[case] cmd: &str, #[case] should_match: bool) {
assert_eq!(
is_shell_integration_line(line, cmd),
should_match,
"Windows .exe test failed\nLine: {line}\nCommand: {cmd}\nExpected: {should_match}"
);
}
#[rstest]
#[case::wt_exe_not_git_wt(r#"eval "$(wt.exe config shell init bash)""#, "git-wt", false)]
#[case::git_wt_exe_not_wt(r#"eval "$(git-wt.exe config shell init bash)""#, "wt", false)]
#[case::my_git_wt_exe_not_git_wt(
r#"eval "$(my-git-wt.exe config shell init bash)""#,
"git-wt",
false
)]
fn test_windows_exe_no_false_positives(
#[case] line: &str,
#[case] cmd: &str,
#[case] should_match: bool,
) {
assert_eq!(
is_shell_integration_line(line, cmd),
should_match,
"Windows .exe false positive check failed\nLine: {line}\nCommand: {cmd}\nExpected: {should_match}"
);
}
#[test]
fn test_word_boundary_newt() {
let line = r#"eval "$(newt config shell init bash)""#;
assert!(
!is_shell_integration_line(line, "wt"),
"newt should not match wt"
);
}
#[test]
fn test_partial_command_no_match() {
let line = r#"eval "$(swt config shell init bash)""#;
assert!(
!is_shell_integration_line(line, "wt"),
"swt should not match wt"
);
}
fn assert_detects(line: &str, cmd: &str, description: &str) {
assert!(
is_shell_integration_line(line, cmd),
"FALSE NEGATIVE: {} not detected for cmd={}\nLine: {}",
description,
cmd,
line
);
}
fn assert_not_detects(line: &str, cmd: &str, description: &str) {
assert!(
!is_shell_integration_line(line, cmd),
"UNEXPECTED MATCH: {} matched for cmd={}\nLine: {}",
description,
cmd,
line
);
}
#[test]
fn test_dot_command_process_substitution() {
assert_detects(
". <(wt config shell init bash)",
"wt",
"dot command with process substitution",
);
}
#[test]
fn test_dot_command_zsh_equals() {
assert_detects(
". =(wt config shell init zsh)",
"wt",
"dot command with zsh =() substitution",
);
}
#[test]
fn test_powershell_iex_alias() {
assert_detects(
"iex (wt config shell init powershell | Out-String)",
"wt",
"PowerShell iex alias",
);
}
#[test]
fn test_powershell_iex_with_ampersand() {
assert_detects(
"iex (& wt config shell init powershell | Out-String)",
"wt",
"PowerShell iex with &",
);
}
#[test]
fn test_powershell_without_out_string_not_detected() {
assert_not_detects(
"iex (wt config shell init powershell)",
"wt",
"PowerShell without Out-String (outdated config)",
);
assert_not_detects(
"Invoke-Expression (& wt config shell init powershell)",
"wt",
"Invoke-Expression without Out-String (outdated config)",
);
assert_not_detects(
"if (Get-Command wt -ErrorAction SilentlyContinue) { Invoke-Expression (& wt config shell init powershell) }",
"wt",
"exact old canonical PowerShell line (must not detect)",
);
}
#[test]
fn test_powershell_permissive_mode_for_uninstall() {
assert!(
is_shell_integration_line_for_uninstall("iex (wt config shell init powershell)", "wt"),
"Permissive mode should detect old PowerShell config"
);
assert!(
is_shell_integration_line_for_uninstall(
"Invoke-Expression (& wt config shell init powershell)",
"wt"
),
"Permissive mode should detect old Invoke-Expression config"
);
assert!(
is_shell_integration_line_for_uninstall(
"if (Get-Command wt -ErrorAction SilentlyContinue) { Invoke-Expression (& wt config shell init powershell) }",
"wt"
),
"Permissive mode should detect exact old canonical PowerShell line"
);
assert!(
is_shell_integration_line_for_uninstall(
"iex (wt config shell init powershell | Out-String)",
"wt"
),
"Permissive mode should also detect new PowerShell config"
);
}
#[test]
fn test_powershell_block_comment() {
let line = "<# Invoke-Expression (wt config shell init powershell) #>";
assert_not_detects(line, "wt", "PowerShell block comment should not match");
}
#[test]
fn test_zsh_bare_equals_substitution() {
assert_detects(
". =(command wt config shell init zsh)",
"wt",
"dot with command prefix",
);
}
#[test]
fn test_backtick_substitution() {
assert_detects(
"eval \"`wt config shell init bash`\"",
"wt",
"backtick substitution",
);
}
#[test]
fn test_backtick_no_outer_quotes() {
assert_detects(
"eval `wt config shell init bash`",
"wt",
"backtick without outer quotes",
);
}
#[test]
fn test_absolute_path() {
assert_detects(
r#"eval "$(/usr/local/bin/wt config shell init bash)""#,
"wt",
"absolute path to binary",
);
}
#[test]
fn test_home_path() {
assert_detects(
r#"eval "$(~/.cargo/bin/wt config shell init bash)""#,
"wt",
"home-relative path",
);
}
#[test]
fn test_env_var_path() {
assert_detects(
r#"eval "$($HOME/.cargo/bin/wt config shell init bash)""#,
"wt",
"env var in path",
);
}
#[test]
fn test_worktrunk_bin_only() {
assert_not_detects(
r#"eval "$($WORKTRUNK_BIN config shell init bash)""#,
"wt",
"WORKTRUNK_BIN without default (expected: no match - cant tell which cmd)",
);
}
#[test]
fn test_git_wt_double_space() {
assert_not_detects(
r#"eval "$(git wt config shell init bash)""#,
"git-wt",
"double space (expected: no match due to pattern)",
);
}
#[test]
fn test_git_wt_tab_separator() {
let line = "eval \"$(git\twt config shell init bash)\"";
assert_not_detects(
line,
"git-wt",
"tab separator (expected: no match - only single space matched)",
);
}
#[test]
fn test_fish_standard() {
assert_detects(
"wt config shell init fish | source",
"wt",
"standard fish pattern",
);
}
#[test]
fn test_fish_with_command() {
assert_detects(
"command wt config shell init fish | source",
"wt",
"fish with command prefix",
);
}
#[test]
fn test_nushell_save_pattern() {
let line = "if (which wt | is-not-empty) { wt config shell init nu | save --force ($nu.default-config-dir | path join vendor/autoload/wt.nu) }";
assert_detects(line, "wt", "nushell save pattern (actual config line)");
}
#[test]
fn test_nushell_source_pattern() {
let line = "wt config shell init nu | source";
assert_detects(line, "wt", "nushell source pattern");
}
#[test]
fn test_inline_comment() {
assert_detects(
r#"eval "$(wt config shell init bash)" # setup wt"#,
"wt",
"inline comment after code",
);
}
#[test]
fn test_commented_in_middle() {
assert_not_detects(
r#"#eval "$(wt config shell init bash)""#,
"wt",
"line starting with # (expected: no match)",
);
}
#[test]
fn test_multiple_evals() {
let line =
r#"eval "$(wt config shell init bash)"; eval "$(git-wt config shell init bash)""#;
assert_detects(line, "wt", "wt in multi-command line");
assert_detects(line, "git-wt", "git-wt in multi-command line");
}
#[rstest]
#[case::my_git_wt(r#"eval "$(my-git-wt config shell init bash)""#)]
#[case::test_git_wt(r#"eval "$(test-git-wt config shell init bash)""#)]
#[case::underscore_git_wt(r#"eval "$(_git-wt config shell init bash)""#)]
#[case::x_git_wt(r#"eval "$(x-git-wt config shell init bash)""#)]
fn test_prefixed_git_wt_no_match(#[case] line: &str) {
assert_not_detects(line, "git-wt", "prefixed git-wt command should NOT match");
}
#[rstest]
#[case::agit_wt(r#"eval "$(agit wt config shell init bash)""#)]
#[case::xgit_wt(r#"eval "$(xgit wt config shell init bash)""#)]
#[case::mygit_wt(r#"eval "$(mygit wt config shell init bash)""#)]
fn test_prefixed_git_space_wt_no_match(#[case] line: &str) {
assert_not_detects(line, "git-wt", "prefixed 'git wt' should NOT match git-wt");
}
#[rstest]
#[case::greek(r#"eval "$(αgit-wt config shell init bash)""#, "git-wt")]
#[case::cyrillic(r#"eval "$(яwt config shell init bash)""#, "wt")]
fn test_unicode_alphanumerics_no_match(#[case] line: &str, #[case] cmd: &str) {
assert_not_detects(line, cmd, "Unicode alphanumeric before command");
}
#[rstest]
#[case::absolute_path(r#"alias gwt="/usr/bin/wt""#, "wt", "gwt", "/usr/bin/wt")]
#[case::exe_suffix(r#"alias gwt="wt.exe""#, "wt", "gwt", "wt.exe")]
#[case::exe_with_path(r#"alias gwt="/path/to/wt.exe""#, "wt", "gwt", "/path/to/wt.exe")]
#[case::single_quotes(r#"alias gwt='/usr/bin/wt'"#, "wt", "gwt", "/usr/bin/wt")]
#[case::git_wt_exe(r#"alias gwt="git-wt.exe""#, "git-wt", "gwt", "git-wt.exe")]
#[case::windows_path(
r#"alias gwt="C:\Program Files\wt\wt.exe""#,
"wt",
"gwt",
r"C:\Program Files\wt\wt.exe"
)]
fn test_bypass_alias_detected(
#[case] line: &str,
#[case] cmd: &str,
#[case] expected_name: &str,
#[case] expected_target: &str,
) {
let result = detect_bypass_alias(line, cmd, 1);
assert!(
result.is_some(),
"Expected bypass alias detection for: {line}"
);
let alias = result.unwrap();
assert_eq!(alias.alias_name, expected_name);
assert_eq!(alias.target, expected_target);
}
#[rstest]
#[case::function_name(r#"alias gwt="wt""#, "wt")]
#[case::git_wt_function(r#"alias gwt="git-wt""#, "git-wt")]
#[case::other_alias(r#"alias ll="ls -la""#, "wt")]
#[case::not_an_alias("eval \"$(wt config shell init bash)\"", "wt")]
#[case::commented_alias(r#"# alias gwt="/usr/bin/wt""#, "wt")]
#[case::substring_in_path(r#"alias gravity=/path/to/newton.bin"#, "wt")]
#[case::substring_in_path_quoted(r#"alias gravity="/path/to/newton.bin""#, "wt")]
fn test_bypass_alias_not_detected(#[case] line: &str, #[case] cmd: &str) {
let result = detect_bypass_alias(line, cmd, 1);
if !line.trim().starts_with('#') {
assert!(
result.is_none(),
"Should NOT detect bypass for: {line}, got: {:?}",
result
);
}
}
#[test]
fn test_unrelated_alias_not_detected() {
let result = detect_bypass_alias(r#"alias vim="nvim""#, "wt", 1);
assert!(result.is_none());
}
}