From 5fe73c1f7b12bcae9fe63f813d38881e26ce25ec Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:29:33 +0900 Subject: [PATCH 1/8] Migrate CLI implementation to Rust --- .dockerignore | 4 +- .github/scripts/generate_formula.sh | 53 +- .github/workflows/ci.yml | 26 +- .github/workflows/release.yml | 28 +- .gitignore | 10 +- Cargo.lock | 186 ++++++ Cargo.toml | 14 + README.md | 12 +- nimble.lock | 16 - nix/nim-lock.json | 16 - nix/package.nix | 6 +- src/core.rs | 946 ++++++++++++++++++++++++++++ src/main.rs | 147 +++++ src/why.nim | 39 -- src/why_core.nim | 342 ---------- src/why_os.nim | 36 -- tests/e2e/Builder.Dockerfile | 8 +- tests/test_why_core.nim | 226 ------- why_cli.nimble | 15 - 19 files changed, 1336 insertions(+), 794 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 nimble.lock delete mode 100644 nix/nim-lock.json create mode 100644 src/core.rs create mode 100644 src/main.rs delete mode 100644 src/why.nim delete mode 100644 src/why_core.nim delete mode 100644 src/why_os.nim delete mode 100644 tests/test_why_core.nim delete mode 100644 why_cli.nimble diff --git a/.dockerignore b/.dockerignore index c592b34..6cf409e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,11 +6,9 @@ .vscode # Local build artifacts +target/ why why.exe -tests/test_why_core -tests/test_why_core.exe -nimcache/ # Editor/OS noise Thumbs.db diff --git a/.github/scripts/generate_formula.sh b/.github/scripts/generate_formula.sh index 07cf45d..33a0c17 100755 --- a/.github/scripts/generate_formula.sh +++ b/.github/scripts/generate_formula.sh @@ -6,44 +6,11 @@ VERSION=$1 URL=$2 SHA256=$3 -NIMBLE_FILE="why_cli.nimble" - -if [ ! -f "$NIMBLE_FILE" ]; then - echo "Error: $NIMBLE_FILE not found." >&2 +if [ ! -f "Cargo.toml" ]; then + echo "Error: Cargo.toml not found." >&2 exit 1 fi -get_nimble_version() { - local pkg_name=$1 - # Extract version from lines like: requires "cligen >= 1.7.0" - grep "requires \"$pkg_name" "$NIMBLE_FILE" | sed -E 's/.*>= *([0-9.]+).*/\1/' -} - -generate_resource() { - local name=$1 - local repo_url_base=$2 - - local ver=$(get_nimble_version "$name") - - if [ -z "$ver" ]; then - echo "Error: Could not find version for $name in $NIMBLE_FILE" >&2 - exit 1 - fi - - local dl_url="${repo_url_base}/archive/refs/tags/${ver}.tar.gz" - - local tmp_file="/tmp/${name}-${ver}.tar.gz" - - curl -sL "$dl_url" -o "$tmp_file" - local sha=$(sha256sum "$tmp_file" | awk '{print $1}') - rm "$tmp_file" - - echo " resource \"$name\" do" - echo " url \"$dl_url\"" - echo " sha256 \"$sha\"" - echo " end" -} - cat < :build - -EOF - -# Define dependencies here. The version is auto-detected from .nimble. -generate_resource "cligen" "https://github.com/c-blake/cligen" - -cat < :build def install - resource("cligen").stage do - (buildpath/"vendor/cligen").install Dir["*"] - end - - system "nim", "c", "-d:release", "--path:#{buildpath}/vendor/cligen", "-o:why", "src/why.nim" - bin.install "why" + system "cargo", "install", "--locked", "--path", ".", "--root", prefix end test do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02a3e46..bfb39b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,26 +22,22 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Nim - uses: jiro4989/setup-nim-action@v2 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable with: - nim-version: '2.2.6' + components: clippy, rustfmt - - name: Cache Nimble and Nim - uses: actions/cache@v4 - with: - path: | - ~/.nimble - ~/.cache/nim - key: ${{ runner.os }}-nim-2.2.6-nimble-${{ hashFiles('nimble.lock') }} - restore-keys: | - ${{ runner.os }}-nim-2.2.6-nimble- + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Check Formatting + run: cargo fmt --check - - name: Install Dependencies - run: nimble install -y --depsOnly + - name: Run Clippy + run: cargo clippy --all-targets -- -D warnings - name: Run Tests - run: nimble test + run: cargo test --locked e2e: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea92ec6..2e010be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,20 +16,24 @@ jobs: matrix: include: - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu target_os: linux cpu: amd64 archive_name: why-linux-amd64 - os: ubuntu-24.04 + target: aarch64-unknown-linux-gnu target_os: linux cpu: arm64 archive_name: why-linux-arm64 install_cross: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu - nim_flags: --cpu:arm64 --os:linux --gcc.exe:aarch64-linux-gnu-gcc --gcc.linkerexe:aarch64-linux-gnu-gcc + linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc - os: macos-15-intel + target: x86_64-apple-darwin target_os: darwin cpu: amd64 archive_name: why-darwin-amd64 - os: macos-15 + target: aarch64-apple-darwin target_os: darwin cpu: arm64 archive_name: why-darwin-arm64 @@ -37,21 +41,13 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Cache Nimble dependencies - uses: actions/cache@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable with: - path: ~/.nimble - key: ${{ runner.os }}-nimble-${{ hashFiles('nimble.lock') }} - restore-keys: | - ${{ runner.os }}-nimble- + targets: ${{ matrix.target }} - - name: Install Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: '2.2.6' - - - name: Install Dependencies - run: nimble install -y --depsOnly + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 - name: Install Cross-compiler (Linux ARM64 only) if: matrix.install_cross != '' @@ -60,8 +56,8 @@ jobs: - name: Build shell: bash run: | - nimble build -d:release ${{ matrix.nim_flags }} - mv why ${{ matrix.archive_name }} + ${{ matrix.linker_env }} cargo build --release --locked --target ${{ matrix.target }} + cp target/${{ matrix.target }}/release/why ${{ matrix.archive_name }} - name: Create Archive run: tar czf ${{ matrix.archive_name }}.tar.gz ${{ matrix.archive_name }} diff --git a/.gitignore b/.gitignore index 3b02b8b..d33acda 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,10 @@ # Output binaries +/target/ +result why why.exe -tests/test_why_core -tests/test_why_core.exe tests/e2e/why-linux-amd64 -# Nimble files -nimble.develop -config.nims -nimble.paths -nimbledeps - # LLM utilities repomix-output.xml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..17bb6dc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "why-cli" +version = "0.1.0" +dependencies = [ + "clap", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3bb503 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "why-cli" +version = "0.1.0" +edition = "2024" +authors = ["akira ueno"] +description = "Tells you why a command is installed on your system." +license = "MIT" + +[[bin]] +name = "why" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5.51", features = ["derive"] } diff --git a/README.md b/README.md index 5523286..fbbdbd8 100644 --- a/README.md +++ b/README.md @@ -128,22 +128,22 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/ ### Build from Source -Requirements: [Nim](https://nim-lang.org/) compiler (`nim` and `nimble`). +Requirements: [Rust](https://www.rust-lang.org/) toolchain (`cargo` and `rustc`). ```bash git clone [https://github.com/akriaueno/why-cli.git](https://github.com/akriaueno/why-cli.git) cd why-cli -nimble build -d:release -# The binary is created as './why' -# Add it to your PATH (e.g., cp why /usr/local/bin/) +cargo build --release +# The binary is created as './target/release/why' +# Add it to your PATH (e.g., cp target/release/why /usr/local/bin/) ``` -If you change dependencies, run `nimble lock` to update `nimble.lock`. +If you change dependencies, run `cargo update` or `cargo generate-lockfile` to update `Cargo.lock`. ### Testing ```bash -nimble test +cargo test ``` ## License diff --git a/nimble.lock b/nimble.lock deleted file mode 100644 index 5549872..0000000 --- a/nimble.lock +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": 2, - "packages": { - "cligen": { - "version": "1.9.5", - "vcsRevision": "f082560a4bbd4afa1b35e9810b291d17c265b666", - "url": "https://github.com/c-blake/cligen.git", - "downloadMethod": "git", - "dependencies": [], - "checksums": { - "sha1": "f7512afc5f8d11e252e2ced2dfdbc251bb575b4c" - } - } - }, - "tasks": {} -} diff --git a/nix/nim-lock.json b/nix/nim-lock.json deleted file mode 100644 index 33c27d7..0000000 --- a/nix/nim-lock.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "depends": [ - { - "fetchSubmodules": false, - "leaveDotGit": false, - "method": "git", - "packages": [ - "cligen" - ], - "rev": "f082560a4bbd4afa1b35e9810b291d17c265b666", - "sha256": "0s6nfvwmc36c9w55k3d0m3gbnm6hiwxnqqz11pjdq41ixnzx4crz", - "srcDir": ".", - "url": "https://github.com/c-blake/cligen.git" - } - ] -} diff --git a/nix/package.nix b/nix/package.nix index 989cc5c..dd7b202 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,14 +1,14 @@ { lib, - buildNimPackage, + rustPlatform, }: -buildNimPackage { +rustPlatform.buildRustPackage { pname = "why-cli"; version = "0.1.0"; src = lib.cleanSource ../.; - lockFile = ./nim-lock.json; + cargoLock.lockFile = ../Cargo.lock; meta = { description = "Tells you why a command is installed on your system"; diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..a92ab1d --- /dev/null +++ b/src/core.rs @@ -0,0 +1,946 @@ +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MatchKind { + Contains, + StartsWith, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderRule { + pub name: &'static str, + pub kind: MatchKind, + pub patterns: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExecResult { + pub output: String, + pub exit_code: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DirEntryKind { + File, + LinkToFile, + Other, +} + +pub trait WhyCtx { + fn get_env(&self, key: &str) -> String; + fn get_current_dir(&self) -> String; + fn get_home_dir(&self) -> String; + fn file_exists(&self, path: &str) -> bool; + fn symlink_exists(&self, path: &str) -> bool; + fn expand_symlink(&self, path: &str) -> String; + fn dir_exists(&self, path: &str) -> bool; + fn list_dir(&self, dir: &str) -> Vec<(DirEntryKind, String)>; + fn find_exe(&self, name: &str) -> String; + fn exec_cmd(&self, cmd: &str) -> ExecResult; + fn param_str0(&self) -> String; +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WhyResult { + pub command_name: String, + pub origin_path: String, + pub real_path: String, + pub provider: String, + pub hint: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WhyError { + pub msg: String, + pub code: i32, +} + +pub type WhyCoreResult = Result; + +pub fn default_rules(home_dir: &str) -> Vec { + vec![ + ProviderRule { + name: "Homebrew", + kind: MatchKind::Contains, + patterns: strings(&[ + "/opt/homebrew", + "/usr/local/cellar", + "/home/linuxbrew/.linuxbrew", + "/.linuxbrew/cellar", + "/.linuxbrew/caskroom", + ]), + }, + ProviderRule { + name: "MacPorts", + kind: MatchKind::StartsWith, + patterns: strings(&["/opt/local/"]), + }, + ProviderRule { + name: "Nix", + kind: MatchKind::StartsWith, + patterns: strings(&[ + "/nix/store", + "/run/current-system/sw", + "/nix/var/nix/profiles", + ]), + }, + ProviderRule { + name: "Flatpak", + kind: MatchKind::StartsWith, + patterns: vec![ + "/var/lib/flatpak/exports/bin".to_string(), + join_path(home_dir, ".local/share/flatpak/exports/bin"), + ], + }, + ProviderRule { + name: "Mise", + kind: MatchKind::Contains, + patterns: strings(&["mise/shims", ".local/share/mise"]), + }, + ProviderRule { + name: "asdf", + kind: MatchKind::Contains, + patterns: strings(&[".asdf/shims", ".asdf/installs"]), + }, + ProviderRule { + name: "Snap", + kind: MatchKind::Contains, + patterns: strings(&["/snap/", "snap/bin"]), + }, + ProviderRule { + name: "SDKMAN!", + kind: MatchKind::Contains, + patterns: strings(&[".sdkman"]), + }, + ProviderRule { + name: "Volta", + kind: MatchKind::Contains, + patterns: strings(&[".volta"]), + }, + ProviderRule { + name: "nvm", + kind: MatchKind::Contains, + patterns: strings(&[".nvm"]), + }, + ProviderRule { + name: "fnm", + kind: MatchKind::Contains, + patterns: strings(&[".fnm", ".local/share/fnm", "fnm_multishells"]), + }, + ProviderRule { + name: "pyenv", + kind: MatchKind::Contains, + patterns: strings(&[".pyenv", "pyenv/shims"]), + }, + ProviderRule { + name: "rbenv", + kind: MatchKind::Contains, + patterns: strings(&[".rbenv", "rbenv/shims"]), + }, + ProviderRule { + name: "rvm", + kind: MatchKind::Contains, + patterns: strings(&[".rvm", "/usr/local/rvm"]), + }, + ProviderRule { + name: "Rustup", + kind: MatchKind::Contains, + patterns: strings(&[".rustup", "rustup/toolchains"]), + }, + ProviderRule { + name: "Conda", + kind: MatchKind::Contains, + patterns: strings(&[ + ".conda", + "/miniconda", + "/anaconda", + "/mambaforge", + "/miniforge", + ]), + }, + ProviderRule { + name: "Scoop", + kind: MatchKind::Contains, + patterns: strings(&["scoop/shims", "scoop/apps"]), + }, + ProviderRule { + name: "Chocolatey", + kind: MatchKind::Contains, + patterns: strings(&["chocolatey/bin", "chocolatey/lib"]), + }, + ProviderRule { + name: "winget", + kind: MatchKind::Contains, + patterns: strings(&["WindowsApps", "Microsoft/WindowsApps"]), + }, + ProviderRule { + name: "Cargo", + kind: MatchKind::Contains, + patterns: strings(&[".cargo/bin"]), + }, + ProviderRule { + name: "npm", + kind: MatchKind::Contains, + patterns: strings(&["node_modules", "/npm", "npm/"]), + }, + ProviderRule { + name: "pip", + kind: MatchKind::Contains, + patterns: strings(&[ + "site-packages", + "dist-packages", + "/pipx/", + ".local/bin/pipx", + "/bin/pip", + "/bin/pip3", + ]), + }, + ProviderRule { + name: "Go", + kind: MatchKind::Contains, + patterns: strings(&["go/bin"]), + }, + ProviderRule { + name: "System", + kind: MatchKind::StartsWith, + patterns: strings(&["/bin", "/usr/bin", "/sbin", "/usr/sbin"]), + }, + ] +} + +pub fn find_origin_path(command_name: &str, ctx: &dyn WhyCtx) -> String { + if command_name.contains(std::path::MAIN_SEPARATOR) || command_name.contains('/') { + if ctx.file_exists(command_name) || ctx.symlink_exists(command_name) { + return absolute_normalized_no_symlink(command_name, &ctx.get_current_dir()); + } + return String::new(); + } + + for dir in split_path_env(&ctx.get_env("PATH")) { + if dir.is_empty() { + continue; + } + let candidate = join_path(&dir, command_name); + if ctx.file_exists(&candidate) || ctx.symlink_exists(&candidate) { + return absolute_normalized_no_symlink(&candidate, &ctx.get_current_dir()); + } + } + + String::new() +} + +pub fn resolve_symlink_chain(path: &str, ctx: &dyn WhyCtx) -> String { + let mut current = path.to_string(); + let mut visited = HashSet::new(); + + while ctx.symlink_exists(¤t) { + if !visited.insert(current.clone()) { + break; + } + + let target = ctx.expand_symlink(¤t); + current = if is_absolute_path(&target) { + normalize_path_string(&target) + } else { + normalize_path_string(&join_path(&parent_dir(¤t), &target)) + }; + } + + current +} + +pub fn detect_provider_by_path( + origin_path: &str, + real_path: &str, + rules: &[ProviderRule], +) -> String { + let normalized_real = normalize_separators(real_path); + let normalized_origin = normalize_separators(origin_path); + let check_paths = [normalized_real, normalized_origin]; + + for rule in rules { + for path in &check_paths { + if path.is_empty() { + continue; + } + + for pattern in &rule.patterns { + let normalized_pattern = normalize_separators(pattern); + match rule.kind { + MatchKind::Contains if path.contains(&normalized_pattern) => { + return rule.name.to_string(); + } + MatchKind::StartsWith if path.starts_with(&normalized_pattern) => { + return rule.name.to_string(); + } + _ => {} + } + } + } + } + + "Unknown".to_string() +} + +pub fn check_system_package_manager(path: &str, ctx: &dyn WhyCtx) -> String { + for check in [ + check_pkg_manager_dpkg, + check_pkg_manager_rpm, + check_pkg_manager_apk, + check_pkg_manager_pacman, + check_pkg_manager_portage_qfile, + check_pkg_manager_portage_equery, + ] { + let detected = check(path, ctx); + if !detected.is_empty() { + return detected; + } + } + + String::new() +} + +pub fn find_flatpak_fallback(short_name: &str, ctx: &dyn WhyCtx, home_dir: &str) -> String { + let search_dirs = [ + "/var/lib/flatpak/exports/bin".to_string(), + join_path(home_dir, ".local/share/flatpak/exports/bin"), + ]; + let query = short_name.to_ascii_lowercase(); + + for dir in search_dirs { + if !ctx.dir_exists(&dir) { + continue; + } + + for (kind, path) in ctx.list_dir(&dir) { + if matches!(kind, DirEntryKind::File | DirEntryKind::LinkToFile) { + let filename = file_name(&path).to_ascii_lowercase(); + if filename == query || filename.ends_with(&format!(".{query}")) { + return path; + } + } + } + } + + String::new() +} + +pub fn why_core(command_name: &str, ctx: &dyn WhyCtx) -> WhyCoreResult { + let mut result = WhyResult { + command_name: command_name.to_string(), + ..WhyResult::default() + }; + + let mut origin_path; + + if command_name == "why" { + origin_path = find_origin_path(command_name, ctx); + if origin_path.is_empty() { + let invoked = ctx.param_str0(); + if !invoked.is_empty() && (ctx.file_exists(&invoked) || ctx.symlink_exists(&invoked)) { + origin_path = absolute_normalized_no_symlink(&invoked, &ctx.get_current_dir()); + } + } + } else { + origin_path = find_origin_path(command_name, ctx); + + if origin_path.is_empty() || file_name(&origin_path) != command_name { + let flatpak_path = find_flatpak_fallback(command_name, ctx, &ctx.get_home_dir()); + if flatpak_path.is_empty() { + return Err(WhyError { + msg: format!("command '{command_name}' was not found"), + code: 1, + }); + } + + result.hint = format!( + "Hint: command '{command_name}' was not found in PATH, but found '{}' in Flatpak.", + file_name(&flatpak_path) + ); + origin_path = absolute_normalized_no_symlink(&flatpak_path, &ctx.get_current_dir()); + } else { + origin_path = absolute_normalized_no_symlink(&origin_path, &ctx.get_current_dir()); + } + } + + result.origin_path = origin_path; + result.real_path = resolve_symlink_chain(&result.origin_path, ctx); + + let rules = default_rules(&ctx.get_home_dir()); + result.provider = detect_provider_by_path(&result.origin_path, &result.real_path, &rules); + + if result.provider == "System" || result.provider == "Unknown" { + let sys_info = check_system_package_manager(&result.real_path, ctx); + if !sys_info.is_empty() { + result.provider = sys_info; + } + } + + Ok(result) +} + +fn check_pkg_manager_dpkg(path: &str, ctx: &dyn WhyCtx) -> String { + if ctx.find_exe("dpkg").is_empty() { + return String::new(); + } + let result = ctx.exec_cmd(&format!("dpkg -S {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + match result + .output + .split(':') + .next() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + { + Some(pkg) => format!("apt/dpkg ({pkg})"), + None => String::new(), + } +} + +fn check_pkg_manager_rpm(path: &str, ctx: &dyn WhyCtx) -> String { + let has_rpm = !ctx.find_exe("rpm").is_empty(); + let has_zypper = !ctx.find_exe("zypper").is_empty(); + if !has_rpm { + return String::new(); + } + let result = ctx.exec_cmd(&format!("rpm -qf {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + let output = result.output.trim(); + if output.is_empty() { + return String::new(); + } + if has_zypper { + format!("zypper/rpm ({output})") + } else { + format!("yum/rpm ({output})") + } +} + +fn check_pkg_manager_apk(path: &str, ctx: &dyn WhyCtx) -> String { + if ctx.find_exe("apk").is_empty() { + return String::new(); + } + let result = ctx.exec_cmd(&format!("apk info -W {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + let Some(pkg) = result + .output + .lines() + .next() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + else { + return String::new(); + }; + format!("apk ({pkg})") +} + +fn check_pkg_manager_pacman(path: &str, ctx: &dyn WhyCtx) -> String { + if ctx.find_exe("pacman").is_empty() { + return String::new(); + } + let result = ctx.exec_cmd(&format!("pacman -Qo {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + let trimmed = result.output.trim(); + if let Some((_, owned_by)) = trimmed.split_once(" is owned by ") { + return format!("pacman ({})", owned_by.trim()); + } + if trimmed.is_empty() { + String::new() + } else { + format!("pacman ({trimmed})") + } +} + +fn check_pkg_manager_portage_qfile(path: &str, ctx: &dyn WhyCtx) -> String { + if ctx.find_exe("qfile").is_empty() { + return String::new(); + } + let result = ctx.exec_cmd(&format!("qfile -qv {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + let Some(pkg) = result + .output + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + else { + return String::new(); + }; + format!("portage ({pkg})") +} + +fn check_pkg_manager_portage_equery(path: &str, ctx: &dyn WhyCtx) -> String { + if ctx.find_exe("equery").is_empty() { + return String::new(); + } + let result = ctx.exec_cmd(&format!("equery b {}", shell_quote(path))); + if result.exit_code != 0 { + return String::new(); + } + for line in result.output.lines() { + if let Some(idx) = line.find(" (") { + let pkg = line[..idx].trim(); + if !pkg.is_empty() && !pkg.starts_with('*') { + return format!("portage ({pkg})"); + } + } + } + String::new() +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn split_path_env(path: &str) -> Vec { + path.split(if cfg!(windows) { ';' } else { ':' }) + .map(str::to_string) + .collect() +} + +fn absolute_normalized_no_symlink(path: &str, cwd: &str) -> String { + if path.is_empty() { + return String::new(); + } + if is_absolute_path(path) { + normalize_path_string(path) + } else { + normalize_path_string(&join_path(cwd, path)) + } +} + +fn normalize_path_string(path: &str) -> String { + let normalized = normalize_path(Path::new(path)); + normalized.to_string_lossy().into_owned() +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn normalize_separators(path: &str) -> String { + path.replace('\\', "/") +} + +fn join_path(base: &str, child: &str) -> String { + if base.is_empty() { + return child.to_string(); + } + if child.is_empty() { + return base.to_string(); + } + if base.ends_with('/') || base.ends_with('\\') { + format!("{base}{child}") + } else { + format!("{base}/{child}") + } +} + +fn parent_dir(path: &str) -> String { + Path::new(path) + .parent() + .map(|parent| parent.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +fn is_absolute_path(path: &str) -> bool { + Path::new(path).is_absolute() || path.starts_with('/') +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct FakeCtx { + env: HashMap, + current_dir: String, + home_dir: String, + files: HashSet, + symlinks: HashMap, + dirs: HashSet, + dir_entries: HashMap>, + exes: HashMap, + commands: HashMap, + arg0: String, + } + + impl FakeCtx { + fn base() -> Self { + Self { + current_dir: "/work".to_string(), + home_dir: "/home/test".to_string(), + arg0: "/usr/bin/why".to_string(), + ..Self::default() + } + } + + fn with_path(mut self, path: &str) -> Self { + self.env.insert("PATH".to_string(), path.to_string()); + self + } + + fn with_file(mut self, path: &str) -> Self { + self.files.insert(path.to_string()); + self + } + + fn with_exe(mut self, name: &str, path: &str) -> Self { + self.exes.insert(name.to_string(), path.to_string()); + self + } + + fn with_command(mut self, cmd: &str, output: &str, exit_code: i32) -> Self { + self.commands.insert( + cmd.to_string(), + ExecResult { + output: output.to_string(), + exit_code, + }, + ); + self + } + } + + impl WhyCtx for FakeCtx { + fn get_env(&self, key: &str) -> String { + self.env.get(key).cloned().unwrap_or_default() + } + + fn get_current_dir(&self) -> String { + self.current_dir.clone() + } + + fn get_home_dir(&self) -> String { + self.home_dir.clone() + } + + fn file_exists(&self, path: &str) -> bool { + self.files.contains(path) + } + + fn symlink_exists(&self, path: &str) -> bool { + self.symlinks.contains_key(path) + } + + fn expand_symlink(&self, path: &str) -> String { + self.symlinks.get(path).cloned().unwrap_or_default() + } + + fn dir_exists(&self, path: &str) -> bool { + self.dirs.contains(path) + } + + fn list_dir(&self, dir: &str) -> Vec<(DirEntryKind, String)> { + self.dir_entries.get(dir).cloned().unwrap_or_default() + } + + fn find_exe(&self, name: &str) -> String { + self.exes.get(name).cloned().unwrap_or_default() + } + + fn exec_cmd(&self, cmd: &str) -> ExecResult { + self.commands.get(cmd).cloned().unwrap_or(ExecResult { + output: String::new(), + exit_code: 1, + }) + } + + fn param_str0(&self) -> String { + self.arg0.clone() + } + } + + #[test] + fn finds_origin_in_path_and_detects_provider() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/node"); + + let result = why_core("node", &ctx).unwrap(); + assert_eq!(result.origin_path, "/usr/bin/node"); + assert_eq!(result.provider, "System"); + } + + #[test] + fn flatpak_fallback_returns_hint_and_path() { + let flatpak_dir = "/var/lib/flatpak/exports/bin"; + let flatpak_exe = "/var/lib/flatpak/exports/bin/org.test.Foo"; + let mut ctx = FakeCtx::base(); + ctx.dirs.insert(flatpak_dir.to_string()); + ctx.dir_entries.insert( + flatpak_dir.to_string(), + vec![(DirEntryKind::File, flatpak_exe.to_string())], + ); + + let result = why_core("foo", &ctx).unwrap(); + assert!(!result.hint.is_empty()); + assert_eq!(result.origin_path, flatpak_exe); + assert_eq!(result.provider, "Flatpak"); + } + + #[test] + fn system_package_manager_detection_via_dpkg() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/bash") + .with_exe("dpkg", "/usr/bin/dpkg") + .with_command("dpkg -S '/usr/bin/bash'", "bash: /usr/bin/bash\n", 0); + + let result = why_core("bash", &ctx).unwrap(); + assert_eq!(result.provider, "apt/dpkg (bash)"); + } + + #[test] + fn system_package_manager_detection_via_zypper() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/ls") + .with_exe("zypper", "/usr/bin/zypper") + .with_exe("rpm", "/usr/bin/rpm") + .with_command("rpm -qf '/usr/bin/ls'", "coreutils-9.2-1\n", 0); + + let result = why_core("ls", &ctx).unwrap(); + assert_eq!(result.provider, "zypper/rpm (coreutils-9.2-1)"); + } + + #[test] + fn detect_provider_by_path_prefers_real_path() { + let provider = detect_provider_by_path( + "/home/test/.local/bin/thing", + "/usr/bin/thing", + &default_rules("/home/test"), + ); + assert_eq!(provider, "System"); + } + + #[test] + fn asdf_shim_takes_precedence_over_system_path() { + let provider = detect_provider_by_path( + "/home/test/.asdf/shims/node", + "/usr/bin/node", + &default_rules("/home/test"), + ); + assert_eq!(provider, "asdf"); + } + + #[test] + fn detects_common_version_managers_by_path() { + 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", + ), + ]; + + let rules = default_rules("/home/test"); + for (expected, origin, real) in cases { + assert_eq!(detect_provider_by_path(origin, real, &rules), expected); + } + } + + #[test] + fn detects_package_managers_by_path() { + let cases = [ + ("MacPorts", "/opt/local/bin/port", "/opt/local/bin/port"), + ( + "Nix", + "/nix/store/abc123/bin/nix", + "/nix/store/abc123/bin/nix", + ), + ( + "Scoop", + r"C:\Users\bob\scoop\shims\node.exe", + r"C:\Users\bob\scoop\apps\nodejs\current\node.exe", + ), + ( + "Chocolatey", + r"C:\ProgramData\chocolatey\bin\git.exe", + r"C:\ProgramData\chocolatey\lib\git\tools\git.exe", + ), + ( + "winget", + r"C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\wt.exe", + r"C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\wt.exe", + ), + ]; + + let rules = default_rules("/home/test"); + for (expected, origin, real) in cases { + assert_eq!(detect_provider_by_path(origin, real, &rules), expected); + } + } + + #[test] + fn system_package_manager_detection_via_apk() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/ls") + .with_exe("apk", "/sbin/apk") + .with_command("apk info -W '/usr/bin/ls'", "busybox-1.36.1-r0\n", 0); + + let result = why_core("ls", &ctx).unwrap(); + assert_eq!(result.provider, "apk (busybox-1.36.1-r0)"); + } + + #[test] + fn system_package_manager_detection_via_pacman() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/ls") + .with_exe("pacman", "/usr/bin/pacman") + .with_command( + "pacman -Qo '/usr/bin/ls'", + "/usr/bin/ls is owned by coreutils 9.2-1\n", + 0, + ); + + let result = why_core("ls", &ctx).unwrap(); + assert_eq!(result.provider, "pacman (coreutils 9.2-1)"); + } + + #[test] + fn system_package_manager_detection_via_portage_qfile() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/ls") + .with_exe("qfile", "/usr/bin/qfile") + .with_command( + "qfile -qv '/usr/bin/ls'", + "sys-apps/coreutils-9.2 /usr/bin/ls\n", + 0, + ); + + let result = why_core("ls", &ctx).unwrap(); + assert_eq!(result.provider, "portage (sys-apps/coreutils-9.2)"); + } + + #[test] + fn system_package_manager_detection_via_portage_equery() { + let ctx = FakeCtx::base() + .with_path("/usr/bin:/bin") + .with_file("/usr/bin/ls") + .with_exe("equery", "/usr/bin/equery") + .with_command( + "equery b '/usr/bin/ls'", + "sys-apps/coreutils-9.2 (/usr/bin/ls)\n", + 0, + ); + + let result = why_core("ls", &ctx).unwrap(); + assert_eq!(result.provider, "portage (sys-apps/coreutils-9.2)"); + } + + #[test] + fn resolves_relative_symlink_chain() { + let mut ctx = FakeCtx::base(); + ctx.symlinks.insert( + "/home/test/.cargo/bin/rustc".to_string(), + "../.rustup/toolchains/stable/bin/rustc".to_string(), + ); + let resolved = resolve_symlink_chain("/home/test/.cargo/bin/rustc", &ctx); + assert_eq!( + resolved, + "/home/test/.cargo/.rustup/toolchains/stable/bin/rustc" + ); + } + + #[test] + fn stops_on_symlink_loop() { + let mut ctx = FakeCtx::base(); + ctx.symlinks + .insert("/tmp/a".to_string(), "/tmp/b".to_string()); + ctx.symlinks + .insert("/tmp/b".to_string(), "/tmp/a".to_string()); + + let resolved = resolve_symlink_chain("/tmp/a", &ctx); + assert_eq!(resolved, "/tmp/a"); + } + + #[test] + fn command_not_found_returns_error() { + let ctx = FakeCtx::base(); + let err = why_core("missing", &ctx).unwrap_err(); + assert_eq!(err.code, 1); + assert!(err.msg.contains("missing")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..696780f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,147 @@ +mod core; + +use std::env; +use std::fs; +use std::path::Path; +use std::process::{Command, ExitCode}; + +use clap::Parser; + +use crate::core::{DirEntryKind, ExecResult, WhyCtx, why_core}; + +#[derive(Parser, Debug)] +#[command( + name = "why", + version, + about = "Identify why a command is installed on your system" +)] +struct Args { + /// The command to investigate, for example 'node' or 'ls'. + command: String, +} + +struct DefaultCtx; + +impl WhyCtx for DefaultCtx { + fn get_env(&self, key: &str) -> String { + env::var(key).unwrap_or_default() + } + + fn get_current_dir(&self) -> String { + env::current_dir() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default() + } + + fn get_home_dir(&self) -> String { + env::var("HOME") + .or_else(|_| env::var("USERPROFILE")) + .unwrap_or_default() + } + + fn file_exists(&self, path: &str) -> bool { + Path::new(path).is_file() + } + + fn symlink_exists(&self, path: &str) -> bool { + fs::symlink_metadata(path) + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false) + } + + fn expand_symlink(&self, path: &str) -> String { + fs::read_link(path) + .map(|target| target.to_string_lossy().into_owned()) + .unwrap_or_default() + } + + fn dir_exists(&self, path: &str) -> bool { + Path::new(path).is_dir() + } + + fn list_dir(&self, dir: &str) -> Vec<(DirEntryKind, String)> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + + entries + .filter_map(Result::ok) + .map(|entry| { + let kind = entry + .file_type() + .map(|file_type| { + if file_type.is_file() { + DirEntryKind::File + } else if file_type.is_symlink() { + DirEntryKind::LinkToFile + } else { + DirEntryKind::Other + } + }) + .unwrap_or(DirEntryKind::Other); + (kind, entry.path().to_string_lossy().into_owned()) + }) + .collect() + } + + fn find_exe(&self, name: &str) -> String { + let path = env::var("PATH").unwrap_or_default(); + let separator = if cfg!(windows) { ';' } else { ':' }; + for dir in path.split(separator).filter(|dir| !dir.is_empty()) { + let candidate = if dir.ends_with('/') || dir.ends_with('\\') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + }; + if Path::new(&candidate).is_file() { + return candidate; + } + } + String::new() + } + + fn exec_cmd(&self, cmd: &str) -> ExecResult { + let output = if cfg!(windows) { + Command::new("cmd").args(["/C", cmd]).output() + } else { + Command::new("sh").args(["-c", cmd]).output() + }; + + match output { + Ok(output) => ExecResult { + output: String::from_utf8_lossy(&output.stdout).into_owned(), + exit_code: output.status.code().unwrap_or(1), + }, + Err(_) => ExecResult { + output: String::new(), + exit_code: 1, + }, + } + } + + fn param_str0(&self) -> String { + env::args().next().unwrap_or_default() + } +} + +fn main() -> ExitCode { + let args = Args::parse(); + let ctx = DefaultCtx; + + match why_core(&args.command, &ctx) { + Ok(result) => { + if !result.hint.is_empty() { + println!("{}", result.hint); + } + println!("Command: {}", result.command_name); + println!("Provider: {}", result.provider); + println!("Origin Path: {}", result.origin_path); + println!("Real Path: {}", result.real_path); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("Error: {}", err.msg); + ExitCode::from(err.code as u8) + } + } +} diff --git a/src/why.nim b/src/why.nim deleted file mode 100644 index b08e980..0000000 --- a/src/why.nim +++ /dev/null @@ -1,39 +0,0 @@ -import os -import strutils -import cligen -import why_core -import why_os - -proc showResult(res: WhyResult) = - echo "Command: ", res.commandName - echo "Provider: ", res.provider - echo "Origin Path: ", res.originPath - echo "Real Path: ", res.realPath - -proc why(commandName: string) = - if commandName == "why": - echo "Checking self-identity..." - - let ctx = defaultCtx() - let (ok, res, err) = whyCore(commandName, ctx) - - if not ok: - stderr.writeLine err.msg - quit err.code - - if res.hint.len > 0: - echo res.hint - - showResult(res) - -when isMainModule: - let rawArgs = commandLineParams() - var patchedArgs = rawArgs - if rawArgs.len > 0 and not rawArgs[0].startsWith("-"): - patchedArgs = @["--commandName=" & rawArgs[0]] - if rawArgs.len > 1: - patchedArgs.add(rawArgs[1..^1]) - - dispatchCf(why, help = { - "commandName": "The command to investigate (e.g. 'node', 'ls')" - }, cmdLine = patchedArgs) diff --git a/src/why_core.nim b/src/why_core.nim deleted file mode 100644 index d03cafe..0000000 --- a/src/why_core.nim +++ /dev/null @@ -1,342 +0,0 @@ -import os -import strutils -import sets -import osproc - -type - MatchKind* = enum - mkContains - mkStartsWith - - ProviderRule* = object - name*: string - kind*: MatchKind - patterns*: seq[string] - - ExecResult* = tuple[outp: string, exitCode: int] - - DirEntryKind* = enum - dekFile - dekLinkToFile - dekOther - - WhyCtx* = object - getEnv*: proc(key: string): string - getCurrentDir*: proc(): string - getHomeDir*: proc(): string - fileExists*: proc(path: string): bool - symlinkExists*: proc(path: string): bool - expandSymlink*: proc(path: string): string - dirExists*: proc(path: string): bool - listDir*: proc(dir: string): seq[(DirEntryKind, string)] - findExe*: proc(name: string): string - execCmd*: proc(cmd: string): ExecResult - paramStr0*: proc(): string - - WhyResult* = object - commandName*: string - originPath*: string - realPath*: string - provider*: string - hint*: string - - WhyError* = object - msg*: string - code*: int - -proc defaultRules*(homeDir: string): seq[ProviderRule] = - return @[ - ProviderRule(name: "Homebrew", kind: mkContains, patterns: @[ - "/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" - ]), - ProviderRule(name: "Mise", kind: mkContains, patterns: @[ - "mise/shims", ".local/share/mise" - ]), - ProviderRule(name: "asdf", kind: mkContains, patterns: @[ - ".asdf/shims", ".asdf/installs" - ]), - 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" - ]), - ProviderRule(name: "npm", kind: mkContains, patterns: @[ - "node_modules", "/npm", "npm/" - ]), - ProviderRule(name: "pip", kind: mkContains, patterns: @[ - "site-packages", "dist-packages", "/pipx/", ".local/bin/pipx", - "/bin/pip", "/bin/pip3" - ]), - ProviderRule(name: "Go", kind: mkContains, patterns: @[ - "go/bin" - ]), - ProviderRule(name: "System", kind: mkStartsWith, patterns: @[ - "/bin", "/usr/bin", "/sbin", "/usr/sbin" - ]) - ] - -proc absoluteNormalizedNoSymlink(path: string, cwd: string): string = - if path.len == 0: - return path - if isAbsolute(path): - return normalizedPath(path) - return normalizedPath(cwd / path) - -proc findOriginPath*(commandName: string, ctx: WhyCtx): string = - if commandName.contains(DirSep): - if ctx.fileExists(commandName) or ctx.symlinkExists(commandName): - return absoluteNormalizedNoSymlink(commandName, ctx.getCurrentDir()) - return "" - - for dir in ctx.getEnv("PATH").split(PathSep): - if dir.len == 0: - continue - let candidate = dir / commandName - if ctx.fileExists(candidate) or ctx.symlinkExists(candidate): - return absoluteNormalizedNoSymlink(candidate, ctx.getCurrentDir()) - - "" - -proc resolveSymlinkChain*(path: string, ctx: WhyCtx): string = - var current = path - var visited = initHashSet[string]() - while ctx.symlinkExists(current): - if visited.contains(current): - break - visited.incl(current) - var target = ctx.expandSymlink(current) - if not isAbsolute(target): - target = joinPath(parentDir(current), target) - current = normalizedPath(target) - return current - -proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule]): string = - let normalizedReal = realPath.replace('\\', '/') - let normalizedOrigin = originPath.replace('\\', '/') - let checkPaths = @[normalizedReal, normalizedOrigin] - - for rule in rules: - for path in checkPaths: - if path.len == 0: - continue - - for pattern in rule.patterns: - let normalizedPattern = pattern.replace('\\', '/') - case rule.kind - of mkContains: - if normalizedPattern in path: - return rule.name - of mkStartsWith: - 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 = - 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 "" - -proc findFlatpakFallback*(shortName: string, ctx: WhyCtx, homeDir: string): string = - let searchDirs = @[ - "/var/lib/flatpak/exports/bin", - homeDir / ".local/share/flatpak/exports/bin" - ] - let query = shortName.toLowerAscii() - - for dir in searchDirs: - if not ctx.dirExists(dir): - continue - - for entry in ctx.listDir(dir): - let kind = entry[0] - let path = entry[1] - if kind == dekFile or kind == dekLinkToFile: - let filename = extractFilename(path).toLowerAscii() - if filename == query or filename.endsWith("." & query): - return path - return "" - -proc whyCore*(commandName: string, ctx: WhyCtx): tuple[ok: bool, res: WhyResult, err: WhyError] = - var res: WhyResult - res.commandName = commandName - - var originPath = "" - - if commandName == "why": - originPath = findOriginPath(commandName, ctx) - if originPath.len == 0: - let invoked = ctx.paramStr0() - if invoked.len > 0 and (ctx.fileExists(invoked) or ctx.symlinkExists(invoked)): - originPath = absoluteNormalizedNoSymlink(invoked, ctx.getCurrentDir()) - else: - originPath = findOriginPath(commandName, ctx) - - if originPath.len == 0 or extractFilename(originPath) != commandName: - let flatpakPath = findFlatpakFallback(commandName, ctx, ctx.getHomeDir()) - if flatpakPath.len > 0: - res.hint = "Hint: Command '" & commandName & "' not found in PATH, but found '" & - extractFilename(flatpakPath) & "' in Flatpak." - originPath = absoluteNormalizedNoSymlink(flatpakPath, ctx.getCurrentDir()) - else: - return (false, WhyResult(), WhyError(msg: "Error: command '" & commandName & "' not found.", code: 1)) - - originPath = absoluteNormalizedNoSymlink(originPath, ctx.getCurrentDir()) - - res.originPath = originPath - res.realPath = resolveSymlinkChain(originPath, ctx) - - let rules = defaultRules(ctx.getHomeDir()) - res.provider = detectProviderByPath(res.originPath, res.realPath, rules) - - if res.provider == "System" or res.provider == "Unknown": - let sysInfo = checkSystemPackageManager(res.realPath, ctx) - if sysInfo.len > 0: - res.provider = sysInfo - - return (true, res, WhyError()) diff --git a/src/why_os.nim b/src/why_os.nim deleted file mode 100644 index 07b473c..0000000 --- a/src/why_os.nim +++ /dev/null @@ -1,36 +0,0 @@ -import os -import osproc -import why_core - -proc listDirImpl(dir: string): seq[(DirEntryKind, string)] = - var entries: seq[(DirEntryKind, string)] = @[] - for kind, path in walkDir(dir): - let entryKind = - case kind - of pcFile: - dekFile - of pcLinkToFile: - dekLinkToFile - else: - dekOther - entries.add((entryKind, path)) - return entries - -proc execCmdImpl(cmd: string): ExecResult = - let (outp, exitCode) = execCmdEx(cmd) - return (outp, exitCode) - -proc defaultCtx*(): WhyCtx = - WhyCtx( - getEnv: proc(key: string): string = getEnv(key), - getCurrentDir: proc(): string = getCurrentDir(), - getHomeDir: proc(): string = getHomeDir(), - fileExists: proc(path: string): bool = fileExists(path), - symlinkExists: proc(path: string): bool = symlinkExists(path), - expandSymlink: proc(path: string): string = expandSymlink(path), - dirExists: proc(path: string): bool = dirExists(path), - listDir: listDirImpl, - findExe: proc(name: string): string = findExe(name), - execCmd: execCmdImpl, - paramStr0: proc(): string = paramStr(0) - ) diff --git a/tests/e2e/Builder.Dockerfile b/tests/e2e/Builder.Dockerfile index 2d7bcf3..cb5464c 100644 --- a/tests/e2e/Builder.Dockerfile +++ b/tests/e2e/Builder.Dockerfile @@ -1,8 +1,8 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular +FROM rust:1.90-bookworm WORKDIR /work RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ + && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY . . -RUN nimble build -d:release \ - && install -m 0755 /work/why /usr/local/bin/why +RUN cargo build --release --locked \ + && install -m 0755 /work/target/release/why /usr/local/bin/why diff --git a/tests/test_why_core.nim b/tests/test_why_core.nim deleted file mode 100644 index 888bf56..0000000 --- a/tests/test_why_core.nim +++ /dev/null @@ -1,226 +0,0 @@ -import std/[unittest, tables] -import ../src/why_core - -suite "whyCore": - test "finds origin in PATH and detects provider": - var files = {"/usr/bin/node": 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 = "", - execCmd: proc(cmd: string): ExecResult = ("", 1), - paramStr0: proc(): string = "/usr/bin/why" - ) - - let (ok, res, err) = whyCore("node", ctx) - check ok - check err.msg.len == 0 - check res.originPath == "/usr/bin/node" - check res.provider == "System" - - test "flatpak fallback returns hint and path": - let flatpakDir = "/var/lib/flatpak/exports/bin" - let flatpakExe = flatpakDir & "/org.test.Foo" - - let ctx = WhyCtx( - getEnv: proc(key: string): string = "", - getCurrentDir: proc(): string = "/work", - getHomeDir: proc(): string = "/home/test", - fileExists: proc(p: string): bool = false, - symlinkExists: proc(p: string): bool = false, - expandSymlink: proc(p: string): string = "", - dirExists: proc(p: string): bool = p == flatpakDir, - listDir: proc(dir: string): seq[(DirEntryKind, string)] = - if dir == flatpakDir: @[(dekFile, flatpakExe)] else: @[], - findExe: proc(name: string): string = "", - execCmd: proc(cmd: string): ExecResult = ("", 1), - paramStr0: proc(): string = "/usr/bin/why" - ) - - let (ok, res, err) = whyCore("foo", ctx) - check ok - check err.msg.len == 0 - check res.hint.len > 0 - check res.originPath == flatpakExe - check res.provider == "Flatpak" - - test "system package manager detection via dpkg": - var files = {"/usr/bin/bash": 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 == "dpkg": "/usr/bin/dpkg" else: "", - execCmd: proc(cmd: string): ExecResult = ("bash: /usr/bin/bash\n", 0), - paramStr0: proc(): string = "/usr/bin/why" - ) - - let (ok, res, err) = whyCore("bash", ctx) - check ok - 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( - "/home/test/.local/bin/thing", - "/usr/bin/thing", - rules - ) - check provider == "System" - - test "asdf shim takes precedence over system path": - let rules = defaultRules("/home/test") - let provider = detectProviderByPath( - "/home/test/.asdf/shims/node", - "/usr/bin/node", - 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)" diff --git a/why_cli.nimble b/why_cli.nimble deleted file mode 100644 index ace413c..0000000 --- a/why_cli.nimble +++ /dev/null @@ -1,15 +0,0 @@ -# Package - -version = "0.1.0" -author = "akira ueno" -description = "Tells you why a command is installed on your system." -license = "MIT" -srcDir = "src" -bin = @["why"] - -# Dependencies -requires "nim >= 2.2.6" -requires "cligen >= 1.7.0" - -task test, "Run unit tests": - exec "nim c -r tests/test_why_core.nim" From 18768906ce351bb8b6a74256a21c4b1d70b92c97 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:30:49 +0900 Subject: [PATCH 2/8] Update CI and release builds for Rust --- .github/scripts/generate_formula.sh | 4 +++- .github/workflows/ci.yml | 11 +++++++++++ .github/workflows/release.yml | 6 ++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/scripts/generate_formula.sh b/.github/scripts/generate_formula.sh index 33a0c17..d917e50 100755 --- a/.github/scripts/generate_formula.sh +++ b/.github/scripts/generate_formula.sh @@ -6,6 +6,8 @@ VERSION=$1 URL=$2 SHA256=$3 +readonly VERSION URL SHA256 + if [ ! -f "Cargo.toml" ]; then echo "Error: Cargo.toml not found." >&2 exit 1 @@ -22,7 +24,7 @@ class Why < Formula depends_on "rust" => :build def install - system "cargo", "install", "--locked", "--path", ".", "--root", prefix + system "cargo", "install", "--locked", *std_cargo_args end test do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfb39b3..8b8a69b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,14 @@ jobs: run: ./tests/e2e/run.sh env: E2E_JOBS: 2 + + nix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - name: Build Nix Package + run: nix build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e010be..07483bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,8 @@ jobs: target_os: linux cpu: amd64 archive_name: why-linux-amd64 + install_cross: '' + linker_env: '' - os: ubuntu-24.04 target: aarch64-unknown-linux-gnu target_os: linux @@ -32,11 +34,15 @@ jobs: target_os: darwin cpu: amd64 archive_name: why-darwin-amd64 + install_cross: '' + linker_env: '' - os: macos-15 target: aarch64-apple-darwin target_os: darwin cpu: arm64 archive_name: why-darwin-arm64 + install_cross: '' + linker_env: '' steps: - uses: actions/checkout@v6 From 41ab66714e47ebb8544811d14d62023f525b62ae Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:33:57 +0900 Subject: [PATCH 3/8] Build Linux ARM release on native runner --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07483bb..cdc169d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,13 +22,13 @@ jobs: archive_name: why-linux-amd64 install_cross: '' linker_env: '' - - os: ubuntu-24.04 + - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu target_os: linux cpu: arm64 archive_name: why-linux-arm64 - install_cross: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu - linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + install_cross: '' + linker_env: '' - os: macos-15-intel target: x86_64-apple-darwin target_os: darwin @@ -69,7 +69,7 @@ jobs: run: tar czf ${{ matrix.archive_name }}.tar.gz ${{ matrix.archive_name }} - name: Release to GitHub - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: ${{ matrix.archive_name }}.tar.gz @@ -107,8 +107,8 @@ jobs: # Calculate SHA256 hash SHA256=$(sha256sum source.tar.gz | awk '{print $1}') - echo "sha=$SHA256" >> $GITHUB_OUTPUT - echo "url=$SOURCE_URL" >> $GITHUB_OUTPUT + echo "sha=$SHA256" >> "$GITHUB_OUTPUT" + echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT" - name: Generate Formula run: | From 30708f34a9e82fb5e6247b5d32c12f3202e7c05d Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:39:13 +0900 Subject: [PATCH 4/8] Align Rust migration documentation --- README.md | 2 +- tests/e2e/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fbbdbd8..3b6ff84 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Even if the command name (e.g., `steam`) differs from the Flatpak ID, `why` can ```bash $ why steam -Hint: Command 'steam' not found in PATH, but found 'com.valvesoftware.Steam' in Flatpak. +Hint: command 'steam' was not found in PATH, but found 'com.valvesoftware.Steam' in Flatpak. Command: steam Provider: Flatpak Origin Path: /var/lib/flatpak/exports/bin/com.valvesoftware.Steam diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 1900b74..f055795 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -26,6 +26,6 @@ Run a subset: ## 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. +- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the glibc-based builder binary. - Gentoo coverage is provided via a stage3 image with portage-utils (qfile). - Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. From fe4a95255922a3770175a66030d06e8d1440e4b9 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:43:53 +0900 Subject: [PATCH 5/8] Update checkout action for Node 24 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b8a69b..37c79cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - ubuntu-latest - macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Run E2E Tests run: ./tests/e2e/run.sh @@ -53,7 +53,7 @@ jobs: nix: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdc169d..58e678c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,10 +82,10 @@ jobs: if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') steps: - name: Checkout why-cli repository (for scripts) - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Checkout tap repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: akriaueno/homebrew-tap token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} From 0ee5a66cb66fad45efdf10ed14e06b99c637a5f5 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:46:46 +0900 Subject: [PATCH 6/8] Harden GitHub Actions references --- .github/workflows/ci.yml | 18 ++++++++++++------ .github/workflows/release.yml | 21 ++++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37c79cc..dd5bc3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,15 +20,17 @@ jobs: - ubuntu-latest - macos-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy, rustfmt - name: Cache Cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - name: Check Formatting run: cargo fmt --check @@ -43,7 +45,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Run E2E Tests run: ./tests/e2e/run.sh @@ -53,10 +57,12 @@ jobs: nix: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Nix - uses: cachix/install-nix-action@v31 + uses: cachix/install-nix-action@b97f05dcb019ddea06450a50ef6203d2fdc19fee # v31 - name: Build Nix Package run: nix build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e678c..6d34fb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,15 +45,19 @@ jobs: linker_env: '' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: ${{ matrix.target }} - name: Cache Cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + save-if: "false" - name: Install Cross-compiler (Linux ARM64 only) if: matrix.install_cross != '' @@ -69,7 +73,7 @@ jobs: run: tar czf ${{ matrix.archive_name }}.tar.gz ${{ matrix.archive_name }} - name: Release to GitHub - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 if: startsWith(github.ref, 'refs/tags/') with: files: ${{ matrix.archive_name }}.tar.gz @@ -82,13 +86,16 @@ jobs: if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') steps: - name: Checkout why-cli repository (for scripts) - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Checkout tap repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: repository: akriaueno/homebrew-tap token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + persist-credentials: false path: homebrew-tap - name: Ensure Formula directory exists @@ -125,7 +132,7 @@ jobs: echo "=========================" - name: Create PR on tap repository - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} # fine-grained PAT (homebrew-tap, contents:write & pull-requests:write) path: homebrew-tap From a00ff301f68258b011d4c3af5f201b6f07ce09a8 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:47:03 +0900 Subject: [PATCH 7/8] Fix review documentation and builder pin --- README.md | 2 +- tests/e2e/Builder.Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3b6ff84..9ab9429 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/ Requirements: [Rust](https://www.rust-lang.org/) toolchain (`cargo` and `rustc`). ```bash -git clone [https://github.com/akriaueno/why-cli.git](https://github.com/akriaueno/why-cli.git) +git clone https://github.com/akriaueno/why-cli.git cd why-cli cargo build --release # The binary is created as './target/release/why' diff --git a/tests/e2e/Builder.Dockerfile b/tests/e2e/Builder.Dockerfile index cb5464c..d491174 100644 --- a/tests/e2e/Builder.Dockerfile +++ b/tests/e2e/Builder.Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.90-bookworm +FROM rust:1.90-bookworm@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ From 751b5c589e82aee79b6907f87bb8375b8f8d44c7 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sat, 30 May 2026 20:47:33 +0900 Subject: [PATCH 8/8] Fix explicit path and Homebrew detection --- src/core.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/core.rs b/src/core.rs index a92ab1d..027953e 100644 --- a/src/core.rs +++ b/src/core.rs @@ -65,10 +65,10 @@ pub fn default_rules(home_dir: &str) -> Vec { kind: MatchKind::Contains, patterns: strings(&[ "/opt/homebrew", - "/usr/local/cellar", + "/usr/local/Cellar", "/home/linuxbrew/.linuxbrew", - "/.linuxbrew/cellar", - "/.linuxbrew/caskroom", + "/.linuxbrew/Cellar", + "/.linuxbrew/Caskroom", ]), }, ProviderRule { @@ -210,7 +210,7 @@ pub fn default_rules(home_dir: &str) -> Vec { } pub fn find_origin_path(command_name: &str, ctx: &dyn WhyCtx) -> String { - if command_name.contains(std::path::MAIN_SEPARATOR) || command_name.contains('/') { + if is_explicit_command_path(command_name) { if ctx.file_exists(command_name) || ctx.symlink_exists(command_name) { return absolute_normalized_no_symlink(command_name, &ctx.get_current_dir()); } @@ -345,7 +345,10 @@ pub fn why_core(command_name: &str, ctx: &dyn WhyCtx) -> WhyCoreResult { } else { origin_path = find_origin_path(command_name, ctx); - if origin_path.is_empty() || file_name(&origin_path) != command_name { + let use_flatpak_fallback = !is_explicit_command_path(command_name) + && (origin_path.is_empty() || file_name(&origin_path) != command_name); + + if use_flatpak_fallback { let flatpak_path = find_flatpak_fallback(command_name, ctx, &ctx.get_home_dir()); if flatpak_path.is_empty() { return Err(WhyError { @@ -359,6 +362,11 @@ pub fn why_core(command_name: &str, ctx: &dyn WhyCtx) -> WhyCoreResult { file_name(&flatpak_path) ); origin_path = absolute_normalized_no_symlink(&flatpak_path, &ctx.get_current_dir()); + } else if origin_path.is_empty() { + return Err(WhyError { + msg: format!("command '{command_name}' was not found"), + code: 1, + }); } else { origin_path = absolute_normalized_no_symlink(&origin_path, &ctx.get_current_dir()); } @@ -577,6 +585,10 @@ fn is_absolute_path(path: &str) -> bool { Path::new(path).is_absolute() || path.starts_with('/') } +fn is_explicit_command_path(command_name: &str) -> bool { + command_name.starts_with('.') || command_name.contains('/') || command_name.contains('\\') +} + fn shell_quote(value: &str) -> String { if value.is_empty() { return "''".to_string(); @@ -717,6 +729,29 @@ mod tests { assert_eq!(result.provider, "Flatpak"); } + #[test] + fn explicit_path_uses_that_path_without_flatpak_fallback() { + let flatpak_dir = "/var/lib/flatpak/exports/bin"; + let mut ctx = FakeCtx::base(); + ctx.dirs.insert(flatpak_dir.to_string()); + ctx.dir_entries.insert( + flatpak_dir.to_string(), + vec![( + DirEntryKind::File, + "/var/lib/flatpak/exports/bin/org.test.Ls".to_string(), + )], + ); + ctx.files.insert("/usr/bin/ls".to_string()); + + let result = why_core("/usr/bin/ls", &ctx).unwrap(); + assert_eq!(result.origin_path, "/usr/bin/ls"); + assert_eq!(result.provider, "System"); + + let err = why_core("/usr/bin/missing", &ctx).unwrap_err(); + assert_eq!(err.code, 1); + assert!(err.msg.contains("/usr/bin/missing")); + } + #[test] fn system_package_manager_detection_via_dpkg() { let ctx = FakeCtx::base() @@ -821,6 +856,21 @@ mod tests { #[test] fn detects_package_managers_by_path() { let cases = [ + ( + "Homebrew", + "/usr/local/bin/node", + "/usr/local/Cellar/node/25.2.1/bin/node", + ), + ( + "Homebrew", + "/home/linuxbrew/.linuxbrew/bin/node", + "/home/linuxbrew/.linuxbrew/Cellar/node/25.2.1/bin/node", + ), + ( + "Homebrew", + "/home/test/.linuxbrew/bin/firefox", + "/home/test/.linuxbrew/Caskroom/firefox/145.0/firefox", + ), ("MacPorts", "/opt/local/bin/port", "/opt/local/bin/port"), ( "Nix",