diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c592b34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.serena +.DS_Store +.idea +.vscode + +# Local build artifacts +why +why.exe +tests/test_why_core +tests/test_why_core.exe +nimcache/ + +# Editor/OS noise +Thumbs.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f986123..02a3e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,14 @@ jobs: - name: Run Tests run: nimble test + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Run E2E Tests + run: ./tests/e2e/run.sh + env: + E2E_JOBS: 2 diff --git a/.gitignore b/.gitignore index b5f491a..3b02b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ why why.exe tests/test_why_core tests/test_why_core.exe +tests/e2e/why-linux-amd64 # Nimble files nimble.develop @@ -11,4 +12,7 @@ nimble.paths nimbledeps # LLM utilities -repomix-output.xml \ No newline at end of file +repomix-output.xml + +# Serena +.serena/ diff --git a/README.md b/README.md index 842456a..04690d9 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ **"Why is this command here? Who installed it?"** `why` is a CLI tool that identifies the installation source (provider) of a command. -It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, or simply a system file. +It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, asdf, SDKMAN!, or simply a system file. ## Features -- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, npm, pip, Cargo, Go, etc. +- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, zypper/rpm, apk, pacman, Nix, MacPorts, npm, pip, Cargo, Go, etc. - Symlink Resolution: Traces the "Origin Path" (where your shell finds it) to the "Real Path" (where the binary actually lives). -- System Package Manager Integration: Automatically queries `dpkg` or `rpm` for system files to identify the package name. +- System Package Manager Integration: Automatically queries `dpkg`, `rpm`/`zypper`, `apk`, `pacman`, or Portage tools for system files to identify the package name. ## Installation @@ -77,8 +77,8 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/ `why` currently supports detection for: -- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), Snap, Flatpak -- Version Managers: Mise, Volta +- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), zypper/rpm (SUSE), apk (Alpine), pacman (Arch), Portage (Gentoo), MacPorts, Nix, Snap, Flatpak, Scoop, Chocolatey, winget +- Version Managers: Mise, Volta, asdf, SDKMAN!, nvm, fnm, pyenv, rbenv, rvm, Rustup, Conda - Language Managers: npm (Global), pip/pipx (Python), Cargo (Rust), Go - System: Standard system paths (`/usr/bin`, etc.) diff --git a/src/why_core.nim b/src/why_core.nim index b5c254e..d03cafe 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -50,6 +50,12 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = "/opt/homebrew", "/usr/local/cellar", "/home/linuxbrew/.linuxbrew", "/.linuxbrew/cellar", "/.linuxbrew/caskroom" ]), + ProviderRule(name: "MacPorts", kind: mkStartsWith, patterns: @[ + "/opt/local/" + ]), + ProviderRule(name: "Nix", kind: mkStartsWith, patterns: @[ + "/nix/store", "/run/current-system/sw", "/nix/var/nix/profiles" + ]), ProviderRule(name: "Flatpak", kind: mkStartsWith, patterns: @[ "/var/lib/flatpak/exports/bin", homeDir / ".local/share/flatpak/exports/bin" @@ -63,6 +69,42 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = ProviderRule(name: "Snap", kind: mkContains, patterns: @[ "/snap/", "snap/bin" ]), + ProviderRule(name: "SDKMAN!", kind: mkContains, patterns: @[ + ".sdkman" + ]), + ProviderRule(name: "Volta", kind: mkContains, patterns: @[ + ".volta" + ]), + ProviderRule(name: "nvm", kind: mkContains, patterns: @[ + ".nvm" + ]), + ProviderRule(name: "fnm", kind: mkContains, patterns: @[ + ".fnm", ".local/share/fnm", "fnm_multishells" + ]), + ProviderRule(name: "pyenv", kind: mkContains, patterns: @[ + ".pyenv", "pyenv/shims" + ]), + ProviderRule(name: "rbenv", kind: mkContains, patterns: @[ + ".rbenv", "rbenv/shims" + ]), + ProviderRule(name: "rvm", kind: mkContains, patterns: @[ + ".rvm", "/usr/local/rvm" + ]), + ProviderRule(name: "Rustup", kind: mkContains, patterns: @[ + ".rustup", "rustup/toolchains" + ]), + ProviderRule(name: "Conda", kind: mkContains, patterns: @[ + ".conda", "/miniconda", "/anaconda", "/mambaforge", "/miniforge" + ]), + ProviderRule(name: "Scoop", kind: mkContains, patterns: @[ + "scoop/shims", "scoop/apps" + ]), + ProviderRule(name: "Chocolatey", kind: mkContains, patterns: @[ + "chocolatey/bin", "chocolatey/lib" + ]), + ProviderRule(name: "winget", kind: mkContains, patterns: @[ + "WindowsApps", "Microsoft/WindowsApps" + ]), ProviderRule(name: "Cargo", kind: mkContains, patterns: @[ ".cargo/bin" ]), @@ -73,9 +115,6 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = "site-packages", "dist-packages", "/pipx/", ".local/bin/pipx", "/bin/pip", "/bin/pip3" ]), - ProviderRule(name: "Volta", kind: mkContains, patterns: @[ - ".volta" - ]), ProviderRule(name: "Go", kind: mkContains, patterns: @[ "go/bin" ]), @@ -120,7 +159,9 @@ proc resolveSymlinkChain*(path: string, ctx: WhyCtx): string = return current proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule]): string = - let checkPaths = @[realPath, originPath] + let normalizedReal = realPath.replace('\\', '/') + let normalizedOrigin = originPath.replace('\\', '/') + let checkPaths = @[normalizedReal, normalizedOrigin] for rule in rules: for path in checkPaths: @@ -128,28 +169,116 @@ proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule continue for pattern in rule.patterns: + let normalizedPattern = pattern.replace('\\', '/') case rule.kind of mkContains: - if pattern in path: + if normalizedPattern in path: return rule.name of mkStartsWith: - if path.startsWith(pattern): + if path.startsWith(normalizedPattern): return rule.name return "Unknown" +type + PkgManagerStrategy = object + name: string + cmd: proc(path: string, ctx: WhyCtx): string {.nimcall.} + +proc checkPkgManagerDpkg(path: string, ctx: WhyCtx): string = + if ctx.findExe("dpkg").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path)) + if exitCode != 0: + return "" + let parts = outp.split(":") + if parts.len == 0: + return "" + return "apt/dpkg (" & parts[0].strip() & ")" + +proc checkPkgManagerRpm(path: string, ctx: WhyCtx): string = + let hasRpm = ctx.findExe("rpm").len > 0 + let hasZypper = ctx.findExe("zypper").len > 0 + if not hasRpm: + return "" + let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) + if exitCode != 0: + return "" + if hasZypper: + return "zypper/rpm (" & outp.strip() & ")" + return "yum/rpm (" & outp.strip() & ")" + +proc checkPkgManagerApk(path: string, ctx: WhyCtx): string = + if ctx.findExe("apk").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("apk info -W " & quoteShell(path)) + if exitCode != 0: + return "" + let lines = outp.splitLines() + if lines.len == 0: + return "" + let pkg = lines[0].strip() + if pkg.len == 0: + return "" + return "apk (" & pkg & ")" + +proc checkPkgManagerPacman(path: string, ctx: WhyCtx): string = + if ctx.findExe("pacman").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("pacman -Qo " & quoteShell(path)) + if exitCode != 0: + return "" + let trimmed = outp.strip() + let marker = " is owned by " + if trimmed.contains(marker): + let parts = trimmed.split(marker) + if parts.len > 1: + return "pacman (" & parts[1].strip() & ")" + if trimmed.len > 0: + return "pacman (" & trimmed & ")" + return "" + +proc checkPkgManagerPortageQfile(path: string, ctx: WhyCtx): string = + if ctx.findExe("qfile").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path)) + if exitCode != 0: + return "" + let lines = outp.splitLines() + if lines.len == 0: + return "" + let tokens = lines[0].splitWhitespace() + if tokens.len == 0: + return "" + return "portage (" & tokens[0].strip() & ")" + +proc checkPkgManagerPortageEquery(path: string, ctx: WhyCtx): string = + if ctx.findExe("equery").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("equery b " & quoteShell(path)) + if exitCode != 0: + return "" + for line in outp.splitLines(): + let idx = line.find(" (") + if idx > 0: + let pkg = line[0.. 0 and not pkg.startsWith("*"): + return "portage (" & pkg & ")" + return "" + proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = - if ctx.findExe("dpkg").len > 0: - let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path)) - if exitCode == 0: - let parts = outp.split(":") - if parts.len > 0: - return "apt/dpkg (" & parts[0].strip() & ")" - - if ctx.findExe("rpm").len > 0: - let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) - if exitCode == 0: - return "yum/rpm (" & outp.strip() & ")" + let checks = @[ + PkgManagerStrategy(name: "dpkg", cmd: checkPkgManagerDpkg), + PkgManagerStrategy(name: "rpm", cmd: checkPkgManagerRpm), + PkgManagerStrategy(name: "apk", cmd: checkPkgManagerApk), + PkgManagerStrategy(name: "pacman", cmd: checkPkgManagerPacman), + PkgManagerStrategy(name: "qfile", cmd: checkPkgManagerPortageQfile), + PkgManagerStrategy(name: "equery", cmd: checkPkgManagerPortageEquery), + ] + for check in checks: + let detected = check.cmd(path, ctx) + if detected.len > 0: + return detected return "" diff --git a/tests/e2e/Builder.Dockerfile b/tests/e2e/Builder.Dockerfile new file mode 100644 index 0000000..2d7bcf3 --- /dev/null +++ b/tests/e2e/Builder.Dockerfile @@ -0,0 +1,8 @@ +FROM nimlang/nim:2.2.4-ubuntu-regular +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release \ + && install -m 0755 /work/why /usr/local/bin/why diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..1900b74 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,31 @@ +# E2E Tests (Docker) + +These tests run `why` inside distro containers to exercise real package-manager lookups. + +## Run + +```bash +./tests/e2e/run.sh +``` + +Run a subset: + +```bash +./tests/e2e/run.sh ubuntu alpine +``` + +## Coverage + +- ubuntu: apt/dpkg + path-based providers (asdf/SDKMAN!/nvm/fnm/pyenv/rbenv/rvm/rustup/conda/mise/volta/macports/nix) +- fedora: yum/rpm +- opensuse: zypper/rpm +- alpine: apk +- arch: pacman +- gentoo: portage (qfile) + +## Notes + +- The `why` binary is built once via `tests/e2e/Builder.Dockerfile` and copied into each distro image. +- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the Ubuntu-built binary. +- Gentoo coverage is provided via a stage3 image with portage-utils (qfile). +- Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile new file mode 100644 index 0000000..13d359c --- /dev/null +++ b/tests/e2e/alpine/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.20 +RUN apk add --no-cache bash ca-certificates gcompat libc6-compat libstdc++ +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/alpine/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/alpine/test.sh b/tests/e2e/alpine/test.sh new file mode 100755 index 0000000..cdf621f --- /dev/null +++ b/tests/e2e/alpine/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +out=$($WHY ls) +assert_contains "$out" "Provider: apk (" + +echo "alpine e2e OK" diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile new file mode 100644 index 0000000..923e33a --- /dev/null +++ b/tests/e2e/arch/Dockerfile @@ -0,0 +1,7 @@ +FROM archlinux:base +RUN pacman -Syu --noconfirm bash ca-certificates coreutils \ + && pacman -Scc --noconfirm +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/arch/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/arch/test.sh b/tests/e2e/arch/test.sh new file mode 100755 index 0000000..7508258 --- /dev/null +++ b/tests/e2e/arch/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +out=$($WHY ls) +assert_contains "$out" "Provider: pacman (" + +echo "arch e2e OK" diff --git a/tests/e2e/common.sh b/tests/e2e/common.sh new file mode 100755 index 0000000..11246f2 --- /dev/null +++ b/tests/e2e/common.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile new file mode 100644 index 0000000..dca914e --- /dev/null +++ b/tests/e2e/fedora/Dockerfile @@ -0,0 +1,7 @@ +FROM fedora:40 +RUN dnf -y install bash ca-certificates coreutils \ + && dnf clean all +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/fedora/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/fedora/test.sh b/tests/e2e/fedora/test.sh new file mode 100755 index 0000000..3129a89 --- /dev/null +++ b/tests/e2e/fedora/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +out=$($WHY ls) +assert_contains "$out" "Provider: yum/rpm (" + +echo "fedora e2e OK" diff --git a/tests/e2e/gentoo/Dockerfile b/tests/e2e/gentoo/Dockerfile new file mode 100644 index 0000000..14c2058 --- /dev/null +++ b/tests/e2e/gentoo/Dockerfile @@ -0,0 +1,8 @@ +FROM gentoo/stage3:latest +ENV FEATURES="-sandbox -usersandbox" +RUN emerge --sync \ + && emerge --quiet app-portage/portage-utils +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/gentoo/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/gentoo/test.sh b/tests/e2e/gentoo/test.sh new file mode 100755 index 0000000..39bdbdd --- /dev/null +++ b/tests/e2e/gentoo/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +PATH="/bin:$PATH" out=$($WHY bash) +assert_contains "$out" "Provider: portage (" + +echo "gentoo e2e OK" diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile new file mode 100644 index 0000000..f134e2f --- /dev/null +++ b/tests/e2e/opensuse/Dockerfile @@ -0,0 +1,7 @@ +FROM opensuse/leap:15.6 +RUN zypper -n install bash ca-certificates coreutils \ + && zypper clean -a +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/opensuse/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/opensuse/test.sh b/tests/e2e/opensuse/test.sh new file mode 100755 index 0000000..c8356c7 --- /dev/null +++ b/tests/e2e/opensuse/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +out=$($WHY ls) +assert_contains "$out" "Provider: zypper/rpm (" + +echo "opensuse e2e OK" diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..5449cfc --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +DISTROS=(ubuntu fedora opensuse alpine arch gentoo) +BUILDER_IMAGE="why-e2e-builder" +BUILDER_DOCKERFILE="$ROOT_DIR/tests/e2e/Builder.Dockerfile" +BIN_PATH="$ROOT_DIR/tests/e2e/why-linux-amd64" + +if [[ $# -gt 0 ]]; then + DISTROS=("$@") +fi + +jobs=${E2E_JOBS:-$(nproc 2>/dev/null || echo 4)} + +build_binary() { + if [[ ! -f "$BUILDER_DOCKERFILE" ]]; then + echo "Missing builder Dockerfile: ${BUILDER_DOCKERFILE}" >&2 + return 1 + fi + + echo "==> Building why binary" + docker build --progress=plain -f "$BUILDER_DOCKERFILE" -t "$BUILDER_IMAGE" "$ROOT_DIR" + local container_id + container_id=$(docker create "$BUILDER_IMAGE") + docker cp "$container_id":/usr/local/bin/why "$BIN_PATH" + docker rm -v "$container_id" >/dev/null + chmod +x "$BIN_PATH" + echo "==> Binary ready: ${BIN_PATH}" + echo +} + +run_one() { + local distro="$1" + local image="why-e2e-${distro}" + local dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" + if [[ ! -f "$dockerfile" ]]; then + echo "Missing Dockerfile for ${distro}: ${dockerfile}" >&2 + return 1 + fi + + echo "==> Building ${image}" + docker build --progress=plain -f "$dockerfile" -t "$image" "$ROOT_DIR" + echo "==> Running ${image}" + docker run --rm "$image" + echo "==> ${distro} OK" + echo +} + +build_binary + +if command -v parallel >/dev/null 2>&1; then + export ROOT_DIR + export -f run_one + parallel --line-buffer --tag --halt now,fail=1 --jobs "$jobs" run_one ::: "${DISTROS[@]}" +else + pids=() + fail=0 + + prune_pids() { + local alive=() + local pid + for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + alive+=("$pid") + fi + done + pids=("${alive[@]}") + } + + wait_any() { + if wait -n 2>/dev/null; then + prune_pids + return 0 + fi + local pid="${pids[0]:-}" + if [[ -n "$pid" ]]; then + wait "$pid" + local status=$? + prune_pids + return "$status" + fi + return 0 + } + + for distro in "${DISTROS[@]}"; do + run_one "$distro" & + pids+=("$!") + while ((${#pids[@]} >= jobs)); do + if ! wait_any; then + fail=1 + break + fi + done + if ((fail)); then + break + fi + done + + if ((fail)); then + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + fi + + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + fail=1 + fi + done + + if ((fail)); then + exit 1 + fi +fi diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile new file mode 100644 index 0000000..919f4f8 --- /dev/null +++ b/tests/e2e/ubuntu/Dockerfile @@ -0,0 +1,8 @@ +FROM ubuntu:22.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/ubuntu/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/ubuntu/test.sh b/tests/e2e/ubuntu/test.sh new file mode 100755 index 0000000..a68bf8c --- /dev/null +++ b/tests/e2e/ubuntu/test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +make_bin() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat <<'SCRIPT' > "$path" +#!/usr/bin/env sh +echo ok +SCRIPT + chmod +x "$path" +} + +run_and_assert_provider() { + local expected="$1" + local cmd="$2" + local output + output=$("$WHY" "$cmd") + assert_contains "$output" "Provider: $expected" +} + +# dpkg +PATH="/bin:$PATH" out=$($WHY bash) +assert_contains "$out" "Provider: apt/dpkg (" + +# MacPorts +make_bin /opt/local/bin/port +PATH="/opt/local/bin:$PATH" run_and_assert_provider "MacPorts" "port" + +# Nix +make_bin /nix/store/test/bin/nix +PATH="/nix/store/test/bin:$PATH" run_and_assert_provider "Nix" "nix" + +# asdf +make_bin /root/.asdf/shims/node +PATH="/root/.asdf/shims:$PATH" run_and_assert_provider "asdf" "node" + +# SDKMAN! +make_bin /root/.sdkman/candidates/java/current/bin/java +PATH="/root/.sdkman/candidates/java/current/bin:$PATH" run_and_assert_provider "SDKMAN!" "java" + +# nvm +make_bin /root/.nvm/versions/node/v20.0.0/bin/node +PATH="/root/.nvm/versions/node/v20.0.0/bin:$PATH" run_and_assert_provider "nvm" "node" + +# fnm +make_bin /root/.local/share/fnm/node-versions/v20.0.0/installation/bin/node +PATH="/root/.local/share/fnm/node-versions/v20.0.0/installation/bin:$PATH" run_and_assert_provider "fnm" "node" + +# pyenv +make_bin /root/.pyenv/shims/python +PATH="/root/.pyenv/shims:$PATH" run_and_assert_provider "pyenv" "python" + +# rbenv +make_bin /root/.rbenv/shims/ruby +PATH="/root/.rbenv/shims:$PATH" run_and_assert_provider "rbenv" "ruby" + +# rvm +make_bin /root/.rvm/rubies/ruby-3.2.2/bin/ruby +PATH="/root/.rvm/rubies/ruby-3.2.2/bin:$PATH" run_and_assert_provider "rvm" "ruby" + +# rustup (symlink from cargo bin to rustup toolchain) +make_bin /root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc +mkdir -p /root/.cargo/bin +ln -sf /root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc /root/.cargo/bin/rustc +PATH="/root/.cargo/bin:$PATH" run_and_assert_provider "Rustup" "rustc" + +# conda +make_bin /root/miniconda3/bin/python +PATH="/root/miniconda3/bin:$PATH" run_and_assert_provider "Conda" "python" + +# mise +make_bin /root/.local/share/mise/shims/node +PATH="/root/.local/share/mise/shims:$PATH" run_and_assert_provider "Mise" "node" + +# volta +make_bin /root/.volta/bin/node +PATH="/root/.volta/bin:$PATH" run_and_assert_provider "Volta" "node" + +echo "ubuntu e2e OK" diff --git a/tests/test_why_core.nim b/tests/test_why_core.nim index 316fa68..888bf56 100644 --- a/tests/test_why_core.nim +++ b/tests/test_why_core.nim @@ -76,6 +76,32 @@ suite "whyCore": check err.msg.len == 0 check res.provider == "apt/dpkg (bash)" + test "system package manager detection via zypper": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "zypper": "/usr/bin/zypper" + elif name == "rpm": "/usr/bin/rpm" + else: "", + execCmd: proc(cmd: string): ExecResult = ("coreutils-9.2-1\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "zypper/rpm (coreutils-9.2-1)" + test "detectProviderByPath prefers real path": let rules = defaultRules("/home/test") let provider = detectProviderByPath( @@ -93,3 +119,108 @@ suite "whyCore": rules ) check provider == "asdf" + + test "detects common version managers by path": + let rules = defaultRules("/home/test") + let cases = { + "asdf": ("/home/test/.asdf/shims/node", "/home/test/.asdf/installs/nodejs/20.0.0/bin/node"), + "SDKMAN!": ("/home/test/.sdkman/candidates/java/current/bin/java", "/home/test/.sdkman/candidates/java/17.0.9/bin/java"), + "nvm": ("/home/test/.nvm/versions/node/v20.2.0/bin/node", "/home/test/.nvm/versions/node/v20.2.0/bin/node"), + "fnm": ("/home/test/.local/share/fnm/node-versions/v20.2.0/installation/bin/node", "/home/test/.local/share/fnm/node-versions/v20.2.0/installation/bin/node"), + "pyenv": ("/home/test/.pyenv/shims/python", "/home/test/.pyenv/versions/3.11.4/bin/python"), + "rbenv": ("/home/test/.rbenv/shims/ruby", "/home/test/.rbenv/versions/3.2.2/bin/ruby"), + "rvm": ("/home/test/.rvm/rubies/ruby-3.2.2/bin/ruby", "/home/test/.rvm/rubies/ruby-3.2.2/bin/ruby"), + "Rustup": ("/home/test/.cargo/bin/rustc", "/home/test/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc"), + "Conda": ("/home/test/miniconda3/bin/python", "/home/test/miniconda3/bin/python") + }.toTable + + for expected, paths in cases: + let provider = detectProviderByPath(paths[0], paths[1], rules) + check provider == expected + + test "detects package managers by path": + let rules = defaultRules("/home/test") + let cases = { + "MacPorts": ("/opt/local/bin/port", "/opt/local/bin/port"), + "Nix": ("/nix/store/abc123/bin/nix", "/nix/store/abc123/bin/nix"), + "Scoop": ("C:\\Users\\bob\\scoop\\shims\\node.exe", "C:\\Users\\bob\\scoop\\apps\\nodejs\\current\\node.exe"), + "Chocolatey": ("C:\\ProgramData\\chocolatey\\bin\\git.exe", "C:\\ProgramData\\chocolatey\\lib\\git\\tools\\git.exe"), + "winget": ("C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\\wt.exe", + "C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\\wt.exe") + }.toTable + + for expected, paths in cases: + let provider = detectProviderByPath(paths[0], paths[1], rules) + check provider == expected + + test "system package manager detection via apk": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "apk": "/sbin/apk" else: "", + execCmd: proc(cmd: string): ExecResult = ("busybox-1.36.1-r0\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "apk (busybox-1.36.1-r0)" + + test "system package manager detection via pacman": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "pacman": "/usr/bin/pacman" else: "", + execCmd: proc(cmd: string): ExecResult = ("/usr/bin/ls is owned by coreutils 9.2-1\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "pacman (coreutils 9.2-1)" + + test "system package manager detection via portage qfile": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "qfile": "/usr/bin/qfile" else: "", + execCmd: proc(cmd: string): ExecResult = ("sys-apps/coreutils-9.2 /usr/bin/ls\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "portage (sys-apps/coreutils-9.2)"