Files
Kigi-CLI/crates/codegen/kigi-config/src/shell.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

683 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Windows shell detection for terminal command execution.
//!
//! Default cascade: pwsh → powershell.exe → Git Bash → powershell.exe (fallback).
//!
//! PowerShell is preferred over Git Bash because MSYS2/Git Bash performs
//! POSIX-to-Windows path translation, mangling every flag starting with `/`
//! (e.g. MSBuild `/t:Build`, cl.exe `/nologo`). This breaks native Windows
//! C++/C#/.NET builds.
//!
//! Set `KIGI_SHELL` to override auto-detection: `pwsh`, `powershell`,
//! `bash`, or `cmd`. Result is cached for the process lifetime.
/// Detected Windows shell and how to invoke it.
#[cfg(not(unix))]
#[derive(Clone, Debug)]
pub enum WindowsShell {
GitBash(String),
Pwsh,
PowerShell,
Cmd,
}
/// Detect the best available shell on Windows.
///
/// If `KIGI_SHELL` is set, it takes precedence over auto-detection.
/// Otherwise the cascade is: pwsh → powershell.exe → Git Bash → cmd.exe.
///
/// Result is cached for the process lifetime.
#[cfg(not(unix))]
pub fn detect_windows_shell() -> &'static WindowsShell {
use std::sync::OnceLock;
static CACHED: OnceLock<WindowsShell> = OnceLock::new();
CACHED.get_or_init(|| {
// Explicit override via KIGI_SHELL.
if let Ok(val) = std::env::var("KIGI_SHELL") {
match val.trim().to_ascii_lowercase().as_str() {
"pwsh" => {
tracing::info!("Windows shell (KIGI_SHELL override): pwsh");
return WindowsShell::Pwsh;
}
"powershell" => {
tracing::info!("Windows shell (KIGI_SHELL override): powershell.exe");
return WindowsShell::PowerShell;
}
"bash" | "gitbash" | "git-bash" => {
if let Some(path) = find_git_bash() {
tracing::info!(
shell = path,
"Windows shell (KIGI_SHELL override): Git Bash"
);
return WindowsShell::GitBash(path);
}
tracing::warn!(
"KIGI_SHELL={val} but Git Bash not found; falling through to auto-detect"
);
}
"cmd" | "cmd.exe" => {
tracing::info!("Windows shell (KIGI_SHELL override): cmd.exe");
return WindowsShell::Cmd;
}
other => {
tracing::warn!(
"KIGI_SHELL={other} is not recognized \
(expected pwsh|powershell|bash|cmd); falling through to auto-detect"
);
}
}
}
// Auto-detect: prefer PowerShell over Git Bash. PowerShell
// passes `/flag` arguments through unchanged, which is required
// for native Windows toolchains (MSBuild, cl.exe, dotnet).
// pwsh (PowerShell 7+).
if let Ok(output) = {
let mut cmd = std::process::Command::new("where");
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
}
// powershell.exe (Windows PowerShell 5.1).
if std::path::Path::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
.exists()
{
tracing::info!("Windows shell: powershell.exe");
return WindowsShell::PowerShell;
}
// Git Bash: available but not preferred (MSYS2 path translation
// breaks `/flag` arguments for native toolchains).
if let Some(path) = find_git_bash() {
tracing::info!(shell = path, "Windows shell: Git Bash");
return WindowsShell::GitBash(path);
}
tracing::info!("Windows shell: powershell.exe (fallback)");
WindowsShell::PowerShell
})
}
/// Locate Git Bash on disk. Checks common install paths, then falls back
/// to `where bash.exe` (filtering for Git paths to avoid WSL bash).
#[cfg(not(unix))]
fn find_git_bash() -> Option<String> {
let candidates = [
std::env::var("PROGRAMFILES")
.map(|pf| format!("{pf}\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
std::env::var("PROGRAMFILES(X86)")
.map(|pf| format!("{pf}\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
std::env::var("LOCALAPPDATA")
.map(|la| format!("{la}\\Programs\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
];
for candidate in &candidates {
if !candidate.is_empty() && std::path::Path::new(candidate).exists() {
return Some(candidate.clone());
}
}
// Fall back to PATH; prefer Git Bash over WSL bash.
if let Ok(output) = {
let mut cmd = std::process::Command::new("where");
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
}
}
}
None
}
#[cfg(not(unix))]
impl WindowsShell {
/// Short display name for user-facing contexts (e.g. "bash", "pwsh").
pub fn name(&self) -> &'static str {
match self {
Self::GitBash(_) => "bash",
Self::Pwsh => "pwsh",
Self::PowerShell => "powershell",
Self::Cmd => "cmd.exe",
}
}
/// Whether this shell supports the `&&` pipeline chain operator for
/// error-propagating command chaining.
///
/// - `pwsh` (PowerShell 7+): `&&` added in PS 7.0.
/// - Git Bash: standard bash `&&`.
/// - `powershell.exe` (5.1): no `&&` support; use `;`.
/// - `cmd.exe`: `&&` works but is inconsistent with the `-Command`
/// invocation style used elsewhere; use `;` for uniformity.
pub fn supports_chain_operator(&self) -> bool {
matches!(self, Self::Pwsh | Self::GitBash(_))
}
/// Whether `grep`, `head`, `tail`, `sed`, `awk`, `find` are usable
/// from this shell. True for Git Bash (MSYS2 bundles them inside the
/// bash subprocess); false for PowerShell and `cmd.exe`.
pub fn has_unix_utilities(&self) -> bool {
matches!(self, Self::GitBash(_))
}
/// How this shell interprets a bare `&` token. Drives the `run_terminal_cmd`
/// background-operator validation, which must differ per shell.
pub fn ampersand_semantics(&self) -> AmpersandSemantics {
match self {
Self::GitBash(_) => AmpersandSemantics::PosixBackground,
Self::Pwsh => AmpersandSemantics::PowerShellCore,
Self::PowerShell => AmpersandSemantics::WindowsPowerShell,
Self::Cmd => AmpersandSemantics::CmdSeparator,
}
}
}
/// Returns the appropriate command chaining separator for the current
/// platform and detected shell.
///
/// - Unix: always `"&&"` (bash/zsh).
/// - Windows with pwsh or Git Bash: `"&&"` (both support pipeline chain
/// operators).
/// - Windows with powershell.exe (5.1) or cmd.exe: `";"`.
pub fn chain_separator() -> &'static str {
#[cfg(unix)]
{
"&&"
}
#[cfg(not(unix))]
{
if detect_windows_shell().supports_chain_operator() {
"&&"
} else {
";"
}
}
}
/// Whether `grep`, `head`, `tail`, `sed`, `awk`, `find` are usable from
/// the active shell. True on Unix and Windows + Git Bash; false on
/// Windows + PowerShell or `cmd.exe`.
///
/// Tool descriptions branch on this to swap Unix-centric guidance for
/// shell-aware guidance and avoid `'grep' is not recognized` failures.
pub fn has_unix_utilities() -> bool {
#[cfg(unix)]
{
true
}
#[cfg(not(unix))]
{
detect_windows_shell().has_unix_utilities()
}
}
/// Whether `name` resolves to an executable on the current `$PATH`.
///
/// Used by the truncated-MCP steer to name only tools that are actually present
/// on the tool server's `$PATH` (no "if available" hedge). `which` handles the
/// platform details (PATHEXT and App Execution Aliases on Windows).
///
/// Probes the base environment (tool server is co-located with the shell tool
/// in production). Per-session `export PATH` mutations inside the persistent
/// shell are not reflected (uncommon for `jq`/`python`/`sed`/`cut`).
pub fn is_command_available(name: &str) -> bool {
which::which(name).is_ok()
}
/// How a shell interprets a bare `&` token. Drives `run_terminal_cmd`
/// background-operator detection and remediation, which must differ per shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AmpersandSemantics {
/// Bash/POSIX: a bare `&` backgrounds the command (Unix shells, Git Bash).
PosixBackground,
/// PowerShell 7+ (`pwsh`): a *leading* `&` is the call/invocation operator;
/// a *trailing* `&` starts a background job.
PowerShellCore,
/// Windows PowerShell 5.1 (`powershell.exe`): a *leading* `&` is the call
/// operator; a *trailing* `&` is a parse error.
WindowsPowerShell,
/// `cmd.exe`: `&` is an unconditional sequential command separator.
CmdSeparator,
}
/// How the active shell interprets a bare `&`. Unix shells are always
/// [`AmpersandSemantics::PosixBackground`]; on Windows it depends on the
/// detected shell (Git Bash vs. PowerShell vs. `cmd.exe`).
pub fn ampersand_semantics() -> AmpersandSemantics {
#[cfg(unix)]
{
AmpersandSemantics::PosixBackground
}
#[cfg(not(unix))]
{
detect_windows_shell().ampersand_semantics()
}
}
/// How to invoke a command in the detected Windows shell.
#[cfg(not(unix))]
pub struct ShellInvocation {
pub program: String,
pub args: Vec<String>,
/// Env vars that must be set on the child process (e.g. `MSYS_NO_PATHCONV`
/// for Git Bash to prevent POSIX-to-Windows path translation of `/flags`).
pub env: Vec<(&'static str, &'static str)>,
}
/// Build `(program, args, env)` for running `command` in the detected shell.
#[cfg(not(unix))]
pub fn shell_command_argv(command: &str) -> ShellInvocation {
invocation_for(detect_windows_shell(), command)
}
/// Pure builder split out of `shell_command_argv` so tests can exercise every
/// `WindowsShell` variant, not just the one installed on the test host.
#[cfg(not(unix))]
fn invocation_for(shell: &WindowsShell, command: &str) -> ShellInvocation {
// Force UTF-8 for descendant tools. Windows' legacy ANSI codepage (cp1252)
// makes locale-sensitive children mis-decode UTF-8 subprocess output — e.g.
// Python's text-mode `subprocess` raised `UnicodeDecodeError` on `gh` output.
// `PYTHONUTF8=1` is the fix (forces `locale.getpreferredencoding` to utf-8);
// `PYTHONIOENCODING` covers the interpreter's own stdio, `surrogateescape`
// matching UTF-8 Mode's leniency. Applied before the per-request env, so an
// explicit caller value still overrides these defaults.
let utf8_env = [
("PYTHONUTF8", "1"),
("PYTHONIOENCODING", "utf-8:surrogateescape"),
];
match shell {
WindowsShell::GitBash(path) => ShellInvocation {
program: path.clone(),
args: vec!["-c".to_string(), command.to_string()],
// Disable MSYS2 POSIX-to-Windows path translation so `/flag`
// arguments (MSBuild /t:, cl.exe /nologo, etc.) pass through.
env: vec![
("MSYS_NO_PATHCONV", "1"),
("MSYS2_ARG_CONV_EXCL", "*"),
utf8_env[0],
utf8_env[1],
],
},
WindowsShell::Pwsh => ShellInvocation {
program: "pwsh".to_string(),
args: vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
env: utf8_env.to_vec(),
},
WindowsShell::PowerShell => ShellInvocation {
program: "powershell.exe".to_string(),
args: vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
env: utf8_env.to_vec(),
},
WindowsShell::Cmd => ShellInvocation {
program: "cmd".to_string(),
args: vec!["/C".to_string(), command.to_string()],
env: utf8_env.to_vec(),
},
}
}
// =============================================================================
// Unix shell resolution
// =============================================================================
//
// Locates an absolute path to a bash/zsh binary on Unix:
//
// 1. `$KIGI_SHELL` override, if it names the requested kind and is runnable.
// 2. `$SHELL`, if it names the requested kind and is runnable.
// Covers most NixOS / Homebrew / `nix-darwin` setups where the user's
// login shell already lives at the resolved path (e.g.
// `/run/current-system/sw/bin/bash`, `/opt/homebrew/bin/bash`).
// 3. `which::which(name)` — walks `$PATH`. Catches NixOS profile shells in
// `/nix/store/...` or `/etc/profiles/per-user/<u>/bin/` when `/bin/bash`
// is absent.
// 4. A fixed candidate list: `{/bin, /usr/bin, /usr/local/bin,
// /opt/homebrew/bin} × {bash,zsh}`.
// 5. Hardcoded `/bin/<name>` — historical behavior, only reached when every
// earlier step has failed.
//
// The result is cached per kind in a process-wide `OnceLock`, so the cascade
// is run at most once per shell kind per process.
/// Which Unix shell we're asking about. Bash and zsh are the only kinds
/// supported by the persistent shell-state backend (the dump scripts are
/// bash/zsh-specific). Fish / dash / ksh users fall through to bash.
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnixShellKind {
Bash,
Zsh,
}
#[cfg(unix)]
impl UnixShellKind {
/// Binary file name (`"bash"` / `"zsh"`).
pub fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
}
}
/// Hardcoded historical default. Only used as the last-resort fallback.
fn hardcoded_default(self) -> &'static str {
match self {
Self::Bash => "/bin/bash",
Self::Zsh => "/bin/zsh",
}
}
}
/// Detect the user's preferred Unix shell kind from `$SHELL`. Defaults to
/// `Bash` when `$SHELL` is unset or unrecognized. Cheap; not cached.
#[cfg(unix)]
pub fn detect_unix_shell_kind() -> UnixShellKind {
match std::env::var("SHELL") {
Ok(s) if s.contains("zsh") => UnixShellKind::Zsh,
_ => UnixShellKind::Bash,
}
}
/// Absolute path to the requested Unix shell binary, computed via the
/// cascade above. Cached for the process lifetime.
#[cfg(unix)]
pub fn unix_shell_path(kind: UnixShellKind) -> &'static str {
use std::sync::OnceLock;
static BASH: OnceLock<String> = OnceLock::new();
static ZSH: OnceLock<String> = OnceLock::new();
let cache = match kind {
UnixShellKind::Bash => &BASH,
UnixShellKind::Zsh => &ZSH,
};
cache.get_or_init(|| {
let path = resolve_unix_shell_path(kind);
tracing::debug!(kind = ?kind, resolved = %path, "resolved Unix shell path");
path
})
}
#[cfg(unix)]
fn resolve_unix_shell_path(kind: UnixShellKind) -> String {
let name = kind.name();
let matches_kind = |p: &std::path::Path| p.file_name().and_then(|n| n.to_str()) == Some(name);
// 1) Explicit override via $KIGI_SHELL.
if let Ok(s) = std::env::var("KIGI_SHELL") {
let p = std::path::PathBuf::from(&s);
if matches_kind(&p) && is_executable(&p) {
return s;
}
}
// 2) $SHELL, when it matches the requested kind.
if let Ok(s) = std::env::var("SHELL") {
let p = std::path::PathBuf::from(&s);
if matches_kind(&p) && is_executable(&p) {
return s;
}
}
// 3) `which` walks $PATH (handles NixOS, Homebrew, custom profiles).
if let Ok(p) = which::which(name)
&& is_executable(&p)
{
return p.to_string_lossy().into_owned();
}
// 4) Common install dirs.
for dir in ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
let p = std::path::PathBuf::from(dir).join(name);
if is_executable(&p) {
return p.to_string_lossy().into_owned();
}
}
// 5) Hardcoded fallback — same as historical behavior. Spawn will fail at
// runtime on a pure NixOS host with no bash, but that's no worse than
// before this resolver existed.
kind.hardcoded_default().to_string()
}
/// Whether `path` is an executable file.
///
/// First tries the file's mode bits (any-x). If that's inconclusive, falls
/// back to actually invoking `<path> --version`. The `--version` fallback
/// exists for Nix and other environments where the `X_OK` mode-bit check can
/// be misleading: some Nix overlay filesystems expose binaries whose
/// owner/group/world mode bits don't reflect their real executability.
///
/// The probe is spawned via `kigi_tty_utils::detach_std_command` so that
/// the child does NOT inherit the parent's controlling TTY. The resolver
/// runs lazily inside `unix_shell_path`'s `OnceLock::get_or_init` which
/// can fire during interactive TUI/pager startup; without detach, a
/// misbehaving shell binary that emits mouse-tracking escapes or asks
/// for a controlling tty during `--version` would spew garbage onto the
/// pager screen. `stdin`, `stdout`, and `stderr` are pinned to `null`
/// to drop any output the binary does emit. See `codegen-conventions`
/// SKILL.md for the workspace-wide subprocess rule.
#[cfg(unix)]
fn is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
&& meta.permissions().mode() & 0o111 != 0
{
return true;
}
// Nix fallback. Detach from the controlling TTY via kigi_tty_utils so
// the probe (which the resolver may run during interactive TUI/pager
// startup) cannot leak escapes onto the parent's terminal.
let mut cmd = std::process::Command::new(path);
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn has_unix_utilities_is_true_on_unix() {
assert!(has_unix_utilities());
}
#[cfg(unix)]
#[test]
fn chain_separator_is_ampersand_on_unix() {
assert_eq!(chain_separator(), "&&");
}
#[test]
fn is_command_available_detects_present_and_absent() {
// A shell present on every host of this OS resolves; a bogus name never
// does. `cmd` resolves via PATHEXT on Windows, `sh` lives on $PATH on Unix.
#[cfg(windows)]
let present = "cmd";
#[cfg(not(windows))]
let present = "sh";
assert!(is_command_available(present));
assert!(!is_command_available(
"xai-definitely-not-a-real-command-xyz"
));
}
#[cfg(unix)]
#[test]
fn ampersand_semantics_is_posix_background_on_unix() {
assert_eq!(ampersand_semantics(), AmpersandSemantics::PosixBackground);
}
#[cfg(unix)]
#[test]
fn unix_shell_path_returns_a_bash() {
// Whatever it returns, it must end in "bash" (the resolver guarantees
// the result's file_name matches the requested kind, even for the
// hardcoded `/bin/bash` fallback).
let p = unix_shell_path(UnixShellKind::Bash);
assert!(
std::path::Path::new(p).file_name().and_then(|n| n.to_str()) == Some("bash"),
"expected a path ending in 'bash', got {p}"
);
}
#[cfg(unix)]
#[test]
fn unix_shell_path_is_cached() {
// Two calls return the same `&'static str` (pointer equality).
let a = unix_shell_path(UnixShellKind::Bash);
let b = unix_shell_path(UnixShellKind::Bash);
assert!(
std::ptr::eq(a.as_ptr(), b.as_ptr()),
"result should be cached"
);
}
#[cfg(unix)]
#[test]
fn is_executable_recognizes_bin_sh() {
// /bin/sh is the one path POSIX promises across every Unix variant
// we care about; on macOS and Linux distros it's always executable.
// (Pure NixOS images may lack it, in which case this test is
// skipped — same approach as the existing `/bin/bash` gated tests.)
if !std::path::Path::new("/bin/sh").exists() {
return;
}
assert!(is_executable(std::path::Path::new("/bin/sh")));
}
#[cfg(unix)]
#[test]
fn is_executable_rejects_non_executable() {
let tmp = tempfile::NamedTempFile::new().unwrap();
// Mode bits explicitly cleared — not executable.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(!is_executable(tmp.path()));
}
#[cfg(unix)]
#[test]
fn detect_unix_shell_kind_falls_back_to_bash() {
// We can't safely mutate $SHELL in a multithreaded test runner, so just
// sanity-check the function returns *something* and doesn't panic.
let _ = detect_unix_shell_kind();
}
/// Only Git Bash bundles the Unix utilities; the PowerShell and cmd
/// variants do not.
#[cfg(not(unix))]
#[test]
fn has_unix_utilities_only_true_for_gitbash() {
assert!(
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into())
.has_unix_utilities()
);
assert!(!WindowsShell::Pwsh.has_unix_utilities());
assert!(!WindowsShell::PowerShell.has_unix_utilities());
assert!(!WindowsShell::Cmd.has_unix_utilities());
}
/// Git Bash backgrounds with a bare `&`; PowerShell uses `&` as the call
/// operator; `cmd.exe` uses it as a sequential separator.
#[cfg(not(unix))]
#[test]
fn ampersand_semantics_per_windows_shell() {
assert_eq!(
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into())
.ampersand_semantics(),
AmpersandSemantics::PosixBackground
);
assert_eq!(
WindowsShell::Pwsh.ampersand_semantics(),
AmpersandSemantics::PowerShellCore
);
assert_eq!(
WindowsShell::PowerShell.ampersand_semantics(),
AmpersandSemantics::WindowsPowerShell
);
assert_eq!(
WindowsShell::Cmd.ampersand_semantics(),
AmpersandSemantics::CmdSeparator
);
}
/// Every Windows shell variant injects the UTF-8 env defaults. Builds all
/// four variants directly so it doesn't depend on the test host's shell.
#[cfg(not(unix))]
#[test]
fn invocation_for_sets_utf8_env_on_every_variant() {
let variants = [
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into()),
WindowsShell::Pwsh,
WindowsShell::PowerShell,
WindowsShell::Cmd,
];
for shell in &variants {
let inv = invocation_for(shell, "echo hi");
assert!(
inv.env.contains(&("PYTHONUTF8", "1")),
"expected PYTHONUTF8=1 in env for {shell:?}, got {:?}",
inv.env
);
assert!(
inv.env
.contains(&("PYTHONIOENCODING", "utf-8:surrogateescape")),
"expected PYTHONIOENCODING=utf-8:surrogateescape in env for {shell:?}, got {:?}",
inv.env
);
}
}
/// GitBash keeps its pre-existing MSYS2 path-translation guards in addition
/// to the UTF-8 defaults (the UTF-8 entries are appended, not replacing).
#[cfg(not(unix))]
#[test]
fn invocation_for_gitbash_keeps_msys_vars() {
let inv = invocation_for(
&WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into()),
"echo hi",
);
assert!(
inv.env.contains(&("MSYS_NO_PATHCONV", "1")),
"{:?}",
inv.env
);
assert!(
inv.env.contains(&("MSYS2_ARG_CONV_EXCL", "*")),
"{:?}",
inv.env
);
}
}