use serde::{Deserialize, Serialize};
use std::{
    collections::{BTreeMap, BTreeSet, HashMap},
    env, fs,
    path::{Component, Path, PathBuf},
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

#[cfg(windows)]
use std::os::windows::process::CommandExt;

#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalToolCommand {
    program: String,
    args: Vec<String>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExternalDiffScope {
    Head,
    Staged,
    Unstaged,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExternalToolRunKind {
    Diff,
    Merge,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedExternalTool {
    id: String,
    label: String,
    program: String,
    kinds: Vec<String>,
}

#[derive(Clone, Copy)]
struct ToolSpec {
    id: &'static str,
    label: &'static str,
    commands: &'static [&'static str],
    common_paths: &'static [&'static str],
    scan_roots: &'static [&'static str],
    scan_names: &'static [&'static str],
    program_override: Option<&'static str>,
    kinds: &'static [&'static str],
}

fn tool(
    id: &'static str,
    label: &'static str,
    commands: &'static [&'static str],
    common_paths: &'static [&'static str],
    scan_roots: &'static [&'static str],
    scan_names: &'static [&'static str],
    kinds: &'static [&'static str],
) -> ToolSpec {
    ToolSpec {
        id,
        label,
        commands,
        common_paths,
        scan_roots,
        scan_names,
        program_override: None,
        kinds,
    }
}

#[cfg(target_os = "macos")]
fn overridden_tool(
    id: &'static str,
    label: &'static str,
    common_paths: &'static [&'static str],
    program: &'static str,
    kinds: &'static [&'static str],
) -> ToolSpec {
    ToolSpec {
        id,
        label,
        commands: &[],
        common_paths,
        scan_roots: &[],
        scan_names: &[],
        program_override: Some(program),
        kinds,
    }
}

#[cfg(windows)]
fn known_tools() -> Vec<ToolSpec> {
    const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
    const EDITOR: &[&str] = &["editor"];
    const DIFF_MERGE: &[&str] = &["diff", "merge"];
    const TERMINAL: &[&str] = &["terminal"];
    const FILE_MANAGER: &[&str] = &["fileManager"];
    const JETBRAINS_ROOTS: &[&str] = &[
        r"%PROGRAMFILES%\JetBrains",
        r"%LOCALAPPDATA%\JetBrains\Toolbox\apps",
    ];

    vec![
        tool(
            "vscode",
            "Visual Studio Code",
            &["Code.exe", "code.cmd"],
            &[
                r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe",
                r"%PROGRAMFILES%\Microsoft VS Code\Code.exe",
            ],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscode-insiders",
            "Visual Studio Code Insiders",
            &["Code - Insiders.exe", "code-insiders.cmd"],
            &[
                r"%LOCALAPPDATA%\Programs\Microsoft VS Code Insiders\Code - Insiders.exe",
                r"%PROGRAMFILES%\Microsoft VS Code Insiders\Code - Insiders.exe",
            ],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "cursor",
            "Cursor",
            &["Cursor.exe", "cursor.cmd"],
            &[r"%LOCALAPPDATA%\Programs\cursor\Cursor.exe"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "windsurf",
            "Windsurf",
            &["Windsurf.exe", "windsurf.cmd"],
            &[r"%LOCALAPPDATA%\Programs\Windsurf\Windsurf.exe"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscodium",
            "VSCodium",
            &["VSCodium.exe", "codium.cmd"],
            &[
                r"%LOCALAPPDATA%\Programs\VSCodium\VSCodium.exe",
                r"%PROGRAMFILES%\VSCodium\VSCodium.exe",
            ],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "zed",
            "Zed",
            &["zed.exe"],
            &[r"%LOCALAPPDATA%\Programs\Zed\Zed.exe"],
            &[],
            &[],
            &["editor", "diff"],
        ),
        tool(
            "sublime-text",
            "Sublime Text",
            &["subl.exe", "sublime_text.exe"],
            &[
                r"%PROGRAMFILES%\Sublime Text\subl.exe",
                r"%PROGRAMFILES%\Sublime Text 3\subl.exe",
            ],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "notepad-plus-plus",
            "Notepad++",
            &["notepad++.exe"],
            &[
                r"%PROGRAMFILES%\Notepad++\notepad++.exe",
                r"%PROGRAMFILES(X86)%\Notepad++\notepad++.exe",
            ],
            &[],
            &[],
            EDITOR,
        ),
        tool("neovim", "Neovim", &["nvim.exe"], &[], &[], &[], EDITOR),
        tool(
            "vim",
            "Vim",
            &["gvim.exe", "vim.exe"],
            &[
                r"%PROGRAMFILES%\Vim\vim91\gvim.exe",
                r"%PROGRAMFILES%\Vim\vim90\gvim.exe",
            ],
            &[r"%PROGRAMFILES%\Vim"],
            &["gvim.exe"],
            EDITOR,
        ),
        tool(
            "emacs",
            "Emacs",
            &["runemacs.exe", "emacs.exe"],
            &[],
            &[r"%PROGRAMFILES%\Emacs"],
            &["runemacs.exe"],
            EDITOR,
        ),
        tool(
            "notepad",
            "Windows Notepad",
            &["notepad.exe"],
            &[r"%WINDIR%\System32\notepad.exe"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "intellij-idea",
            "IntelliJ IDEA",
            &["idea64.exe", "idea.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["idea64.exe", "idea.exe"],
            CODE_FAMILY,
        ),
        tool(
            "webstorm",
            "WebStorm",
            &["webstorm64.exe", "webstorm.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["webstorm64.exe", "webstorm.exe"],
            CODE_FAMILY,
        ),
        tool(
            "pycharm",
            "PyCharm",
            &["pycharm64.exe", "pycharm.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["pycharm64.exe", "pycharm.exe"],
            CODE_FAMILY,
        ),
        tool(
            "phpstorm",
            "PhpStorm",
            &["phpstorm64.exe", "phpstorm.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["phpstorm64.exe", "phpstorm.exe"],
            CODE_FAMILY,
        ),
        tool(
            "rider",
            "JetBrains Rider",
            &["rider64.exe", "rider.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["rider64.exe", "rider.exe"],
            CODE_FAMILY,
        ),
        tool(
            "clion",
            "CLion",
            &["clion64.exe", "clion.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["clion64.exe", "clion.exe"],
            CODE_FAMILY,
        ),
        tool(
            "rustrover",
            "RustRover",
            &["rustrover64.exe", "rustrover.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["rustrover64.exe", "rustrover.exe"],
            CODE_FAMILY,
        ),
        tool(
            "goland",
            "GoLand",
            &["goland64.exe", "goland.exe"],
            &[],
            JETBRAINS_ROOTS,
            &["goland64.exe", "goland.exe"],
            CODE_FAMILY,
        ),
        tool(
            "beyond-compare",
            "Beyond Compare",
            &["BCompare.exe", "BComp.exe"],
            &[
                r"%PROGRAMFILES%\Beyond Compare 5\BCompare.exe",
                r"%PROGRAMFILES%\Beyond Compare 4\BCompare.exe",
                r"%PROGRAMFILES(X86)%\Beyond Compare 4\BCompare.exe",
            ],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "winmerge",
            "WinMerge",
            &["WinMergeU.exe"],
            &[
                r"%PROGRAMFILES%\WinMerge\WinMergeU.exe",
                r"%LOCALAPPDATA%\Programs\WinMerge\WinMergeU.exe",
            ],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "meld",
            "Meld",
            &["meld.exe", "meld"],
            &[
                r"%LOCALAPPDATA%\Programs\Meld\Meld.exe",
                r"%PROGRAMFILES%\Meld\Meld.exe",
            ],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "kdiff3",
            "KDiff3",
            &["kdiff3.exe"],
            &[
                r"%PROGRAMFILES%\KDiff3\bin\kdiff3.exe",
                r"%PROGRAMFILES%\KDiff3\kdiff3.exe",
            ],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "p4merge",
            "P4Merge",
            &["p4merge.exe"],
            &[r"%PROGRAMFILES%\Perforce\p4merge.exe"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "araxis-merge",
            "Araxis Merge",
            &["Compare.exe"],
            &[r"%PROGRAMFILES%\Araxis\Araxis Merge\Compare.exe"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "tortoisegitmerge",
            "TortoiseGitMerge",
            &["TortoiseGitMerge.exe"],
            &[r"%PROGRAMFILES%\TortoiseGit\bin\TortoiseGitMerge.exe"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "windows-terminal",
            "Windows Terminal",
            &["wt.exe"],
            &[r"%LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "powershell",
            "PowerShell 7",
            &["pwsh.exe"],
            &[r"%PROGRAMFILES%\PowerShell\7\pwsh.exe"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "windows-powershell",
            "Windows PowerShell",
            &["powershell.exe"],
            &[r"%WINDIR%\System32\WindowsPowerShell\v1.0\powershell.exe"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "git-bash",
            "Git Bash",
            &["git-bash.exe", "bash.exe"],
            &[
                r"%PROGRAMFILES%\Git\git-bash.exe",
                r"%LOCALAPPDATA%\Programs\Git\git-bash.exe",
                r"%PROGRAMFILES%\Git\bin\bash.exe",
                r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe",
            ],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "cmd",
            "Command Prompt",
            &["cmd.exe"],
            &[r"%WINDIR%\System32\cmd.exe"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "wezterm",
            "WezTerm",
            &["wezterm-gui.exe", "wezterm.exe"],
            &[r"%PROGRAMFILES%\WezTerm\wezterm-gui.exe"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "alacritty",
            "Alacritty",
            &["alacritty.exe"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "kitty-terminal",
            "kitty",
            &["kitty.exe"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "explorer",
            "Windows Explorer",
            &["explorer.exe"],
            &[r"%WINDIR%\explorer.exe"],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "total-commander",
            "Total Commander",
            &["TOTALCMD64.EXE", "TOTALCMD.EXE"],
            &[
                r"%PROGRAMFILES%\totalcmd\TOTALCMD64.EXE",
                r"%PROGRAMFILES(X86)%\totalcmd\TOTALCMD.EXE",
            ],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "directory-opus",
            "Directory Opus",
            &["dopus.exe"],
            &[r"%PROGRAMFILES%\GPSoftware\Directory Opus\dopus.exe"],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "double-commander",
            "Double Commander",
            &["doublecmd.exe"],
            &[r"%PROGRAMFILES%\Double Commander\doublecmd.exe"],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "freecommander",
            "FreeCommander XE",
            &["FreeCommander.exe"],
            &[
                r"%LOCALAPPDATA%\FreeCommander XE\FreeCommander.exe",
                r"%PROGRAMFILES%\FreeCommander XE\FreeCommander.exe",
            ],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "xyplorer",
            "XYplorer",
            &["XYplorer.exe"],
            &[r"%PROGRAMFILES(X86)%\XYplorer\XYplorer.exe"],
            &[],
            &[],
            FILE_MANAGER,
        ),
    ]
}

#[cfg(target_os = "macos")]
fn known_tools() -> Vec<ToolSpec> {
    const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
    const EDITOR: &[&str] = &["editor"];
    const DIFF_MERGE: &[&str] = &["diff", "merge"];
    const TERMINAL: &[&str] = &["terminal"];
    const FILE_MANAGER: &[&str] = &["fileManager"];

    vec![
        tool(
            "vscode",
            "Visual Studio Code",
            &["code"],
            &["/Applications/Visual Studio Code.app/Contents/MacOS/Electron"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscode-insiders",
            "Visual Studio Code Insiders",
            &["code-insiders"],
            &["/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "cursor",
            "Cursor",
            &["cursor"],
            &["/Applications/Cursor.app/Contents/MacOS/Cursor"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "windsurf",
            "Windsurf",
            &["windsurf"],
            &["/Applications/Windsurf.app/Contents/MacOS/Windsurf"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscodium",
            "VSCodium",
            &["codium"],
            &["/Applications/VSCodium.app/Contents/MacOS/Electron"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "zed",
            "Zed",
            &["zed"],
            &["/Applications/Zed.app/Contents/MacOS/zed"],
            &[],
            &[],
            &["editor", "diff"],
        ),
        tool(
            "sublime-text",
            "Sublime Text",
            &["subl"],
            &["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "nova",
            "Nova",
            &["nova"],
            &["/Applications/Nova.app/Contents/MacOS/Nova"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "textmate",
            "TextMate",
            &["mate"],
            &["/Applications/TextMate.app/Contents/Resources/mate"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "bbedit",
            "BBEdit",
            &["bbedit"],
            &["/Applications/BBEdit.app/Contents/Helpers/bbedit"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "xcode",
            "Xcode",
            &["xed"],
            &["/usr/bin/xed"],
            &[],
            &[],
            EDITOR,
        ),
        tool("neovim", "Neovim", &["nvim"], &[], &[], &[], EDITOR),
        tool("vim", "Vim", &["mvim", "vim"], &[], &[], &[], EDITOR),
        tool(
            "emacs",
            "Emacs",
            &["emacs"],
            &["/Applications/Emacs.app/Contents/MacOS/Emacs"],
            &[],
            &[],
            EDITOR,
        ),
        tool(
            "intellij-idea",
            "IntelliJ IDEA",
            &["idea"],
            &["/Applications/IntelliJ IDEA.app/Contents/MacOS/idea"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "webstorm",
            "WebStorm",
            &["webstorm"],
            &["/Applications/WebStorm.app/Contents/MacOS/webstorm"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "pycharm",
            "PyCharm",
            &["pycharm"],
            &["/Applications/PyCharm.app/Contents/MacOS/pycharm"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "phpstorm",
            "PhpStorm",
            &["phpstorm"],
            &["/Applications/PhpStorm.app/Contents/MacOS/phpstorm"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "rider",
            "JetBrains Rider",
            &["rider"],
            &["/Applications/Rider.app/Contents/MacOS/rider"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "clion",
            "CLion",
            &["clion"],
            &["/Applications/CLion.app/Contents/MacOS/clion"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "rustrover",
            "RustRover",
            &["rustrover"],
            &["/Applications/RustRover.app/Contents/MacOS/rustrover"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "goland",
            "GoLand",
            &["goland"],
            &["/Applications/GoLand.app/Contents/MacOS/goland"],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "beyond-compare",
            "Beyond Compare",
            &["bcompare"],
            &["/Applications/Beyond Compare.app/Contents/MacOS/bcomp"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "kaleidoscope",
            "Kaleidoscope",
            &["ksdiff"],
            &["/Applications/Kaleidoscope.app/Contents/Helpers/ksdiff"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "araxis-merge",
            "Araxis Merge",
            &["compare"],
            &["/Applications/Araxis Merge.app/Contents/Utilities/compare"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "p4merge",
            "P4Merge",
            &["p4merge"],
            &["/Applications/p4merge.app/Contents/MacOS/p4merge"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool(
            "opendiff",
            "FileMerge",
            &["opendiff"],
            &["/usr/bin/opendiff"],
            &[],
            &[],
            DIFF_MERGE,
        ),
        overridden_tool(
            "terminal",
            "Terminal",
            &[
                "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
                "/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
            ],
            "/usr/bin/open",
            TERMINAL,
        ),
        overridden_tool(
            "iterm2",
            "iTerm2",
            &["/Applications/iTerm.app/Contents/MacOS/iTerm2"],
            "/usr/bin/open",
            TERMINAL,
        ),
        overridden_tool(
            "warp",
            "Warp",
            &["/Applications/Warp.app/Contents/MacOS/stable"],
            "/usr/bin/open",
            TERMINAL,
        ),
        tool(
            "wezterm",
            "WezTerm",
            &["wezterm"],
            &["/Applications/WezTerm.app/Contents/MacOS/wezterm"],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "alacritty",
            "Alacritty",
            &["alacritty"],
            &["/Applications/Alacritty.app/Contents/MacOS/alacritty"],
            &[],
            &[],
            TERMINAL,
        ),
        overridden_tool(
            "finder",
            "Finder",
            &["/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder"],
            "/usr/bin/open",
            FILE_MANAGER,
        ),
        tool(
            "forklift",
            "ForkLift",
            &["forklift"],
            &["/Applications/ForkLift.app/Contents/MacOS/ForkLift"],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "path-finder",
            "Path Finder",
            &[],
            &["/Applications/Path Finder.app/Contents/MacOS/Path Finder"],
            &[],
            &[],
            FILE_MANAGER,
        ),
    ]
}

#[cfg(all(unix, not(target_os = "macos")))]
fn known_tools() -> Vec<ToolSpec> {
    const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
    const EDITOR: &[&str] = &["editor"];
    const DIFF_MERGE: &[&str] = &["diff", "merge"];
    const TERMINAL: &[&str] = &["terminal"];
    const FILE_MANAGER: &[&str] = &["fileManager"];

    vec![
        tool(
            "vscode",
            "Visual Studio Code",
            &["code"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscode-insiders",
            "Visual Studio Code Insiders",
            &["code-insiders"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool("cursor", "Cursor", &["cursor"], &[], &[], &[], CODE_FAMILY),
        tool(
            "windsurf",
            "Windsurf",
            &["windsurf"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "vscodium",
            "VSCodium",
            &["codium"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool("zed", "Zed", &["zed"], &[], &[], &[], &["editor", "diff"]),
        tool(
            "sublime-text",
            "Sublime Text",
            &["subl"],
            &[],
            &[],
            &[],
            EDITOR,
        ),
        tool("lapce", "Lapce", &["lapce"], &[], &[], &[], EDITOR),
        tool("kate", "Kate", &["kate"], &[], &[], &[], EDITOR),
        tool(
            "gedit",
            "GNOME Text Editor",
            &["gnome-text-editor", "gedit"],
            &[],
            &[],
            &[],
            EDITOR,
        ),
        tool("geany", "Geany", &["geany"], &[], &[], &[], EDITOR),
        tool("neovim", "Neovim", &["nvim"], &[], &[], &[], EDITOR),
        tool("vim", "Vim", &["gvim", "vim"], &[], &[], &[], EDITOR),
        tool(
            "emacs",
            "Emacs",
            &["emacsclient", "emacs"],
            &[],
            &[],
            &[],
            EDITOR,
        ),
        tool("helix", "Helix", &["hx"], &[], &[], &[], EDITOR),
        tool(
            "intellij-idea",
            "IntelliJ IDEA",
            &["idea", "idea.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "webstorm",
            "WebStorm",
            &["webstorm", "webstorm.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "pycharm",
            "PyCharm",
            &["pycharm", "pycharm.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "phpstorm",
            "PhpStorm",
            &["phpstorm", "phpstorm.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "rider",
            "JetBrains Rider",
            &["rider", "rider.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "clion",
            "CLion",
            &["clion", "clion.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "rustrover",
            "RustRover",
            &["rustrover", "rustrover.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "goland",
            "GoLand",
            &["goland", "goland.sh"],
            &[],
            &[],
            &[],
            CODE_FAMILY,
        ),
        tool(
            "beyond-compare",
            "Beyond Compare",
            &["bcompare"],
            &[],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool("meld", "Meld", &["meld"], &[], &[], &[], DIFF_MERGE),
        tool("kdiff3", "KDiff3", &["kdiff3"], &[], &[], &[], DIFF_MERGE),
        tool(
            "p4merge",
            "P4Merge",
            &["p4merge"],
            &[],
            &[],
            &[],
            DIFF_MERGE,
        ),
        tool("kompare", "Kompare", &["kompare"], &[], &[], &[], &["diff"]),
        tool(
            "x-terminal",
            "System terminal",
            &["x-terminal-emulator"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "gnome-terminal",
            "GNOME Terminal",
            &["gnome-terminal"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool("konsole", "Konsole", &["konsole"], &[], &[], &[], TERMINAL),
        tool(
            "kitty-terminal",
            "kitty",
            &["kitty"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool("wezterm", "WezTerm", &["wezterm"], &[], &[], &[], TERMINAL),
        tool(
            "alacritty",
            "Alacritty",
            &["alacritty"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool(
            "xfce-terminal",
            "Xfce Terminal",
            &["xfce4-terminal"],
            &[],
            &[],
            &[],
            TERMINAL,
        ),
        tool("tilix", "Tilix", &["tilix"], &[], &[], &[], TERMINAL),
        tool(
            "system-file-manager",
            "System file manager",
            &["xdg-open"],
            &[],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "nautilus",
            "GNOME Files",
            &["nautilus"],
            &[],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "dolphin",
            "Dolphin",
            &["dolphin"],
            &[],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool("thunar", "Thunar", &["thunar"], &[], &[], &[], FILE_MANAGER),
        tool("nemo", "Nemo", &["nemo"], &[], &[], &[], FILE_MANAGER),
        tool(
            "pcmanfm",
            "PCManFM",
            &["pcmanfm", "pcmanfm-qt"],
            &[],
            &[],
            &[],
            FILE_MANAGER,
        ),
        tool(
            "double-commander",
            "Double Commander",
            &["doublecmd"],
            &[],
            &[],
            &[],
            FILE_MANAGER,
        ),
    ]
}

fn expand_path_template(template: &str) -> Option<PathBuf> {
    let mut expanded = template.to_string();
    for key in [
        "LOCALAPPDATA",
        "PROGRAMFILES",
        "PROGRAMFILES(X86)",
        "WINDIR",
        "HOME",
    ] {
        let token = format!("%{key}%");
        if expanded.to_ascii_uppercase().contains(&token) {
            let value = env::var(key).ok()?;
            expanded = expanded.replace(&token, &value);
            expanded = expanded.replace(&token.to_ascii_lowercase(), &value);
        }
    }
    if let Some(rest) = expanded.strip_prefix("~/") {
        expanded = env::var("HOME").ok().map(|home| format!("{home}/{rest}"))?;
    }
    if expanded.contains('%') {
        None
    } else {
        Some(PathBuf::from(expanded))
    }
}

fn executable_on_path(name: &str) -> Option<PathBuf> {
    let path = Path::new(name);
    if path.components().count() > 1 && path.is_file() {
        return Some(path.to_path_buf());
    }
    let path_value = env::var_os("PATH")?;
    #[cfg(windows)]
    let extensions: Vec<String> = if path.extension().is_some() {
        vec![String::new()]
    } else {
        env::var("PATHEXT")
            .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string())
            .split(';')
            .map(|value| value.to_ascii_lowercase())
            .collect()
    };
    #[cfg(not(windows))]
    let extensions = vec![String::new()];

    for directory in env::split_paths(&path_value) {
        for extension in &extensions {
            let candidate = if extension.is_empty() {
                directory.join(name)
            } else {
                directory.join(format!("{name}{extension}"))
            };
            if candidate.is_file() {
                return Some(candidate);
            }
        }
    }
    None
}

fn index_scan_root(root: &Path, depth: usize, index: &mut HashMap<String, PathBuf>) {
    if depth == 0 || !root.is_dir() {
        return;
    }
    let Ok(entries) = fs::read_dir(root) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            index_scan_root(&path, depth - 1, index);
        } else if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
            let key = name.to_ascii_lowercase();
            let should_replace = index.get(&key).is_none_or(|current| path > *current);
            if should_replace {
                index.insert(key, path);
            }
        }
    }
}

fn build_scan_indexes(specs: &[ToolSpec]) -> HashMap<String, HashMap<String, PathBuf>> {
    let roots: BTreeSet<&str> = specs
        .iter()
        .flat_map(|spec| spec.scan_roots.iter().copied())
        .collect();
    let mut indexes = HashMap::new();
    for template in roots {
        let Some(root) = expand_path_template(template) else {
            continue;
        };
        if !root.is_dir() {
            continue;
        }
        let mut index = HashMap::new();
        index_scan_root(&root, 8, &mut index);
        indexes.insert(template.to_string(), index);
    }
    indexes
}

fn detected_program(
    spec: &ToolSpec,
    indexes: &HashMap<String, HashMap<String, PathBuf>>,
) -> Option<PathBuf> {
    let found = spec
        .common_paths
        .iter()
        .find_map(|template| {
            let path = expand_path_template(template)?;
            path.is_file().then_some(path)
        })
        .or_else(|| {
            spec.commands
                .iter()
                .find_map(|name| executable_on_path(name))
        })
        .or_else(|| {
            spec.scan_roots.iter().find_map(|root| {
                let index = indexes.get(*root)?;
                spec.scan_names
                    .iter()
                    .find_map(|name| index.get(&name.to_ascii_lowercase()).cloned())
            })
        })?;

    if let Some(program) = spec.program_override {
        executable_on_path(program).or_else(|| Some(PathBuf::from(program)))
    } else {
        Some(found)
    }
}

#[tauri::command]
pub async fn detect_external_tools() -> Result<Vec<DetectedExternalTool>, String> {
    tauri::async_runtime::spawn_blocking(|| {
        let specs = known_tools();
        let indexes = build_scan_indexes(&specs);
        let mut seen = BTreeSet::new();
        let mut detected = Vec::new();
        for spec in specs {
            let Some(program) = detected_program(&spec, &indexes) else {
                continue;
            };
            if !seen.insert(spec.id) {
                continue;
            }
            detected.push(DetectedExternalTool {
                id: spec.id.to_string(),
                label: spec.label.to_string(),
                program: program.to_string_lossy().into_owned(),
                kinds: spec.kinds.iter().map(|kind| (*kind).to_string()).collect(),
            });
        }
        Ok(detected)
    })
    .await
    .map_err(|error| format!("External tool detection failed: {error}"))?
}

fn git_command() -> Command {
    let mut command = Command::new("git");
    command.env("LC_ALL", "C");
    #[cfg(windows)]
    command.creation_flags(CREATE_NO_WINDOW);
    command
}

fn resolve_repo(path: &str) -> Result<PathBuf, String> {
    if path.trim().is_empty() {
        return Err("Repository path must not be empty.".to_string());
    }
    let output = git_command()
        .arg("-C")
        .arg(path)
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .map_err(|error| format!("Could not start Git: {error}"))?;
    if !output.status.success() {
        return Err("Not a Git repository or repository is unreachable.".to_string());
    }
    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
    PathBuf::from(root)
        .canonicalize()
        .map_err(|error| format!("Could not resolve repository path: {error}"))
}

fn repo_child(repo: &Path, file: &str, must_exist: bool) -> Result<PathBuf, String> {
    let child = Path::new(file);
    if file.trim().is_empty()
        || child.is_absolute()
        || child.components().any(|part| {
            matches!(
                part,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        })
    {
        return Err("File path must stay within the repository.".to_string());
    }
    let candidate = repo.join(child);
    if must_exist {
        let canonical = candidate
            .canonicalize()
            .map_err(|error| format!("Could not resolve file path: {error}"))?;
        if !canonical.starts_with(repo) {
            return Err("File path lies outside the repository.".to_string());
        }
        return Ok(canonical);
    }
    if candidate.exists() {
        let canonical = candidate
            .canonicalize()
            .map_err(|error| format!("Could not resolve file path: {error}"))?;
        if !canonical.starts_with(repo) {
            return Err("File path lies outside the repository.".to_string());
        }
        return Ok(canonical);
    }
    Ok(candidate)
}

fn validate_command(command: &ExternalToolCommand) -> Result<(), String> {
    if command.program.trim().is_empty() {
        return Err("External tool program must not be empty.".to_string());
    }
    if command.program.contains('\0') || command.args.iter().any(|arg| arg.contains('\0')) {
        return Err("External tool command contains an invalid null character.".to_string());
    }
    if command.args.len() > 64 || command.args.iter().any(|arg| arg.len() > 8192) {
        return Err("External tool command is too long.".to_string());
    }
    Ok(())
}

fn expand_args(
    command: &ExternalToolCommand,
    values: &BTreeMap<&str, String>,
) -> Result<Vec<String>, String> {
    validate_command(command)?;
    command
        .args
        .iter()
        .map(|template| {
            let mut result = template.clone();
            for (key, value) in values {
                result = result.replace(&format!("{{{key}}}"), value);
            }
            if let Some(start) = result.find('{') {
                if result[start..].contains('}') {
                    return Err(format!(
                        "Unknown or unavailable placeholder in argument '{template}'."
                    ));
                }
            }
            Ok(result)
        })
        .collect()
}

fn command_in_new_window(mut command: ExternalToolCommand) -> ExternalToolCommand {
    let executable = Path::new(command.program.trim())
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(command.program.trim())
        .to_ascii_lowercase();

    let remove_flags = |args: &mut Vec<String>, flags: &[&str]| {
        args.retain(|arg| !flags.iter().any(|flag| arg.eq_ignore_ascii_case(flag)));
    };
    let prepend_flag = |args: &mut Vec<String>, flag: &str| {
        if !args.iter().any(|arg| arg.eq_ignore_ascii_case(flag)) {
            args.insert(0, flag.to_string());
        }
    };

    if matches!(
        executable.as_str(),
        "code"
            | "code.exe"
            | "code-insiders"
            | "code-insiders.exe"
            | "code - insiders.exe"
            | "cursor"
            | "cursor.exe"
            | "windsurf"
            | "windsurf.exe"
            | "vscodium"
            | "vscodium.exe"
            | "codium"
            | "codium.exe"
    ) {
        remove_flags(&mut command.args, &["--reuse-window", "-r"]);
        prepend_flag(&mut command.args, "--new-window");
    } else if matches!(executable.as_str(), "zed" | "zed.exe") {
        remove_flags(&mut command.args, &["--reuse", "-r"]);
        prepend_flag(&mut command.args, "--new");
    } else if matches!(
        executable.as_str(),
        "subl" | "subl.exe" | "sublime_text" | "sublime_text.exe"
    ) {
        prepend_flag(&mut command.args, "--new-window");
    } else if executable == "notepad++.exe" {
        prepend_flag(&mut command.args, "-multiInst");
    } else if executable == "kate" || executable == "kate.exe" {
        prepend_flag(&mut command.args, "--new");
    } else if executable == "geany" || executable == "geany.exe" {
        prepend_flag(&mut command.args, "--new-instance");
    } else if executable == "wt" || executable == "wt.exe" {
        let mut index = 0;
        while index < command.args.len() {
            let is_window = command.args[index].eq_ignore_ascii_case("-w")
                || command.args[index].eq_ignore_ascii_case("--window")
                || command.args[index]
                    .to_ascii_lowercase()
                    .starts_with("--window=");
            if is_window {
                let has_separate_value = !command.args[index].contains('=');
                command.args.remove(index);
                if has_separate_value && index < command.args.len() {
                    command.args.remove(index);
                }
            } else {
                index += 1;
            }
        }
        command
            .args
            .splice(0..0, ["-w".to_string(), "new".to_string()]);
    } else if executable == "explorer" || executable == "explorer.exe" {
        prepend_flag(&mut command.args, "/n,");
    } else if matches!(
        executable.as_str(),
        "totalcmd" | "totalcmd.exe" | "totalcmd64" | "totalcmd64.exe"
    ) {
        command
            .args
            .retain(|arg| !arg.to_ascii_lowercase().starts_with("/o"));
        prepend_flag(&mut command.args, "/N");
    } else if executable == "open" {
        prepend_flag(&mut command.args, "-n");
    } else if executable.starts_with("bcomp") {
        remove_flags(&mut command.args, &["/solo", "-solo"]);
        #[cfg(windows)]
        prepend_flag(&mut command.args, "/solo");
        #[cfg(not(windows))]
        prepend_flag(&mut command.args, "-solo");
    } else if executable == "winmergeu" || executable == "winmergeu.exe" {
        remove_flags(&mut command.args, &["/s", "/sw", "-s", "-sw"]);
        prepend_flag(&mut command.args, "/s-");
    }

    command
}

fn tool_values(repo: &Path, file: Option<&Path>) -> BTreeMap<&'static str, String> {
    let mut values = BTreeMap::new();
    values.insert("repo", repo.to_string_lossy().into_owned());
    if let Some(file) = file {
        values.insert("file", file.to_string_lossy().into_owned());
        values.insert(
            "parent",
            file.parent().unwrap_or(repo).to_string_lossy().into_owned(),
        );
    } else {
        values.insert("file", repo.to_string_lossy().into_owned());
        values.insert("parent", repo.to_string_lossy().into_owned());
    }
    values
}

fn spawn_tool(
    command: ExternalToolCommand,
    repo: &Path,
    values: BTreeMap<&str, String>,
) -> Result<(), String> {
    let command = command_in_new_window(command);
    let args = expand_args(&command, &values)?;
    let mut process = Command::new(command.program.trim());
    process.args(args).current_dir(repo);
    process
        .spawn()
        .map_err(|error| format!("Could not launch external tool: {error}"))?;
    Ok(())
}

fn run_tool(
    command: ExternalToolCommand,
    repo: &Path,
    values: BTreeMap<&str, String>,
    kind: ExternalToolRunKind,
) -> Result<(), String> {
    let command = command_in_new_window(command);
    let args = expand_args(&command, &values)?;
    let program = command.program.trim().to_owned();
    let mut process = Command::new(command.program.trim());
    process.args(args).current_dir(repo);
    #[cfg(windows)]
    process.creation_flags(CREATE_NO_WINDOW);
    let status = process
        .status()
        .map_err(|error| format!("Could not launch external tool: {error}"))?;
    if !status.success() && !is_expected_tool_exit(&program, kind, status.code()) {
        return Err(format!("External tool exited with status {status}."));
    }
    Ok(())
}

fn is_expected_tool_exit(program: &str, kind: ExternalToolRunKind, code: Option<i32>) -> bool {
    let Some(code) = code else { return false };
    // Many diff drivers use 1 to report "files differ" rather than a launch failure.
    if kind == ExternalToolRunKind::Diff && code == 1 {
        return true;
    }

    let executable = Path::new(program)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(program)
        .to_ascii_lowercase();
    let beyond_compare = executable.starts_with("bcomp");

    // Beyond Compare documents these as comparison results. In particular, 13 means
    // "rules-based differences" and is commonly returned when a normal GUI compare closes.
    // Codes 14 (conflicts) and 100+ (execution/output errors) intentionally remain failures.
    beyond_compare && matches!(code, 1 | 2 | 11 | 12 | 13)
}

fn temp_dir(label: &str) -> Result<PathBuf, String> {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let path = std::env::temp_dir().join(format!("gitty-{label}-{}-{stamp}", std::process::id()));
    fs::create_dir_all(&path)
        .map_err(|error| format!("Could not create temporary tool directory: {error}"))?;
    Ok(path)
}

fn git_blob(repo: &Path, spec: &str) -> Result<Vec<u8>, String> {
    let output = git_command()
        .arg("-C")
        .arg(repo)
        .args(["show", spec])
        .output()
        .map_err(|error| format!("Could not start Git: {error}"))?;
    if output.status.success() {
        Ok(output.stdout)
    } else {
        Ok(Vec::new())
    }
}

#[tauri::command(async)]
pub fn launch_external_tool(
    path: String,
    file: Option<String>,
    command: ExternalToolCommand,
) -> Result<(), String> {
    let repo = resolve_repo(&path)?;
    let file_path = file
        .as_deref()
        .map(|file| repo_child(&repo, file, true))
        .transpose()?;
    spawn_tool(command, &repo, tool_values(&repo, file_path.as_deref()))
}

#[tauri::command]
pub async fn launch_external_diff(
    path: String,
    file: String,
    command: ExternalToolCommand,
    scope: ExternalDiffScope,
) -> Result<(), String> {
    tauri::async_runtime::spawn_blocking(move || {
        let repo = resolve_repo(&path)?;
        let working_file = repo_child(&repo, &file, false)?;
        let temporary = temp_dir("diff")?;
        let name = Path::new(&file)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("file");
        let (left_label, left_content, right_label, right_content) = match scope {
            ExternalDiffScope::Head => (
                "HEAD",
                git_blob(&repo, &format!("HEAD:{file}"))?,
                "WORKTREE",
                if working_file.is_file() {
                    fs::read(&working_file)
                        .map_err(|error| format!("Could not read working file: {error}"))?
                } else {
                    Vec::new()
                },
            ),
            ExternalDiffScope::Staged => (
                "HEAD",
                git_blob(&repo, &format!("HEAD:{file}"))?,
                "INDEX",
                git_blob(&repo, &format!(":0:{file}"))?,
            ),
            ExternalDiffScope::Unstaged => (
                "INDEX",
                git_blob(&repo, &format!(":0:{file}"))?,
                "WORKTREE",
                if working_file.is_file() {
                    fs::read(&working_file)
                        .map_err(|error| format!("Could not read working file: {error}"))?
                } else {
                    Vec::new()
                },
            ),
        };
        let left = temporary.join(format!("{left_label}-{name}"));
        let right = temporary.join(format!("{right_label}-{name}"));
        fs::write(&left, left_content)
            .map_err(|error| format!("Could not write comparison file: {error}"))?;
        fs::write(&right, right_content)
            .map_err(|error| format!("Could not write comparison file: {error}"))?;
        let mut values = tool_values(&repo, Some(&working_file));
        values.insert("left", left.to_string_lossy().into_owned());
        values.insert("right", right.to_string_lossy().into_owned());
        let result = run_tool(command, &repo, values, ExternalToolRunKind::Diff);
        let _ = fs::remove_dir_all(temporary);
        result
    })
    .await
    .map_err(|error| format!("External diff task failed: {error}"))?
}

#[tauri::command]
pub async fn launch_external_merge(
    path: String,
    file: String,
    command: ExternalToolCommand,
) -> Result<(), String> {
    tauri::async_runtime::spawn_blocking(move || {
        let repo = resolve_repo(&path)?;
        let result_file = repo_child(&repo, &file, true)?;
        let temporary = temp_dir("merge")?;
        let name = Path::new(&file)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("file");
        let base = temporary.join(format!("BASE-{name}"));
        let ours = temporary.join(format!("OURS-{name}"));
        let theirs = temporary.join(format!("THEIRS-{name}"));
        fs::write(&base, git_blob(&repo, &format!(":1:{file}"))?)
            .map_err(|error| format!("Could not write merge base: {error}"))?;
        fs::write(&ours, git_blob(&repo, &format!(":2:{file}"))?)
            .map_err(|error| format!("Could not write ours file: {error}"))?;
        fs::write(&theirs, git_blob(&repo, &format!(":3:{file}"))?)
            .map_err(|error| format!("Could not write theirs file: {error}"))?;
        let mut values = tool_values(&repo, Some(&result_file));
        values.insert("base", base.to_string_lossy().into_owned());
        values.insert("ours", ours.to_string_lossy().into_owned());
        values.insert("theirs", theirs.to_string_lossy().into_owned());
        values.insert("result", result_file.to_string_lossy().into_owned());
        let result = run_tool(command, &repo, values, ExternalToolRunKind::Merge);
        let _ = fs::remove_dir_all(temporary);
        result
    })
    .await
    .map_err(|error| format!("External merge task failed: {error}"))?
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn expands_placeholders_without_shell_parsing() {
        let command = ExternalToolCommand {
            program: "tool".into(),
            args: vec!["--diff".into(), "{left}".into(), "title={file}".into()],
        };
        let values = BTreeMap::from([
            ("left", "C:\\a & b.txt".to_string()),
            ("file", "quoted file.txt".to_string()),
        ]);
        assert_eq!(
            expand_args(&command, &values).unwrap(),
            vec!["--diff", "C:\\a & b.txt", "title=quoted file.txt"]
        );
    }

    #[test]
    fn forces_code_family_tools_into_a_new_window() {
        let command = command_in_new_window(ExternalToolCommand {
            program: "C:/Program Files/Microsoft VS Code/Code.exe".into(),
            args: vec![
                "--reuse-window".into(),
                "--diff".into(),
                "{left}".into(),
                "{right}".into(),
            ],
        });

        assert_eq!(
            command.args,
            vec!["--new-window", "--diff", "{left}", "{right}"]
        );
    }

    #[test]
    fn forces_windows_terminal_to_use_a_fresh_window() {
        let command = command_in_new_window(ExternalToolCommand {
            program: "wt.exe".into(),
            args: vec![
                "--window".into(),
                "last".into(),
                "-d".into(),
                "{repo}".into(),
            ],
        });

        assert_eq!(command.args, vec!["-w", "new", "-d", "{repo}"]);
    }

    #[test]
    fn forces_single_instance_compare_and_file_tools_into_new_windows() {
        let beyond = command_in_new_window(ExternalToolCommand {
            program: "BCompare.exe".into(),
            args: vec!["/readonly".into(), "{left}".into(), "{right}".into()],
        });
        let winmerge = command_in_new_window(ExternalToolCommand {
            program: "WinMergeU.exe".into(),
            args: vec!["/s".into(), "{left}".into(), "{right}".into()],
        });
        let total_commander = command_in_new_window(ExternalToolCommand {
            program: "TOTALCMD64.EXE".into(),
            args: vec!["/O".into(), "/T".into(), "{repo}".into()],
        });

        #[cfg(windows)]
        assert_eq!(beyond.args[0], "/solo");
        #[cfg(not(windows))]
        assert_eq!(beyond.args[0], "-solo");
        assert_eq!(winmerge.args, vec!["/s-", "{left}", "{right}"]);
        assert_eq!(total_commander.args, vec!["/N", "/T", "{repo}"]);
    }

    #[test]
    fn rejects_unknown_placeholder() {
        let command = ExternalToolCommand {
            program: "tool".into(),
            args: vec!["{missing}".into()],
        };
        assert!(
            expand_args(&command, &BTreeMap::new())
                .unwrap_err()
                .contains("placeholder")
        );
    }

    #[test]
    fn rejects_empty_program() {
        let command = ExternalToolCommand {
            program: " ".into(),
            args: vec![],
        };
        assert!(expand_args(&command, &BTreeMap::new()).is_err());
    }

    #[test]
    fn repository_is_a_valid_file_placeholder_for_repository_actions() {
        let repo = Path::new("C:/repo");
        let values = tool_values(repo, None);
        assert_eq!(values.get("repo"), values.get("file"));
        assert_eq!(values.get("repo"), values.get("parent"));
    }

    #[test]
    fn accepts_documented_beyond_compare_result_codes() {
        for code in [1, 2, 11, 12, 13] {
            assert!(is_expected_tool_exit(
                "C:/Program Files/Beyond Compare 5/BCompare.exe",
                ExternalToolRunKind::Diff,
                Some(code)
            ));
            assert!(is_expected_tool_exit(
                "BComp.exe",
                ExternalToolRunKind::Merge,
                Some(code)
            ));
        }
    }

    #[test]
    fn keeps_real_external_tool_failures_visible() {
        assert!(!is_expected_tool_exit(
            "BCompare.exe",
            ExternalToolRunKind::Merge,
            Some(14)
        ));
        assert!(!is_expected_tool_exit(
            "BCompare.exe",
            ExternalToolRunKind::Merge,
            Some(101)
        ));
        assert!(!is_expected_tool_exit(
            "Code.exe",
            ExternalToolRunKind::Diff,
            Some(13)
        ));
        assert!(is_expected_tool_exit(
            "meld",
            ExternalToolRunKind::Diff,
            Some(1)
        ));
    }
}
