From ad8da60cb2df4ec66ca66b257ea1fb908bebe936 Mon Sep 17 00:00:00 2001 From: Leonardo Berbert Date: Thu, 29 Oct 2026 15:26:58 -0300 Subject: [PATCH] Harden sloth-bash file ops, resolver, and installer. Fix destructive cp/mv/rd/python quirks, unify Flatpak/Distrobox resolution, preserve user config on install/update, and add shell tests plus CI. Co-authored-by: Cursor --- .github/workflows/test.yml | 20 ++ LICENSE | 21 ++ README.md | 28 +- install.sh | 124 +++++--- sloth-bash | 619 ++++++++++++++++++++++++++----------- tests/run.sh | 218 +++++++++++++ 6 files changed, 804 insertions(+), 226 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 LICENSE create mode 100755 tests/run.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6f30dd9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + shell-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: ShellCheck + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: '.' + ignore_paths: .github + severity: error + - name: Functional tests + run: bash tests/run.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f534ef3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 sloth-bash contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2094310..ebeef18 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,33 @@ `curl -fsSL https://raw.githubusercontent.com/psygreg/sloth-bash/main/install.sh | bash` ## Features -- **Intuitive response:** just the file name will run it if executable; load a venv if there's any and wrap it in `python3` if it's a *.py* file; or trigger `xdg-open` for other cases. +- **Intuitive response:** just the file name will run it if executable; run a `*.py` file with the local `venv`/`.venv` interpreter when present (without activating it into your shell); or trigger `xdg-open` for other cases. - **Flatpak redirection:** simplifies running flatpak applications from terminal by making `flatpak run` obsolete through smart command handling. No more `io.github.kolunmi.Bazaar`, just `bazaar` will do the trick! -- **Easily open apps from terminal automatically detaching them** with `detach appname`. Perfect for usage with something like Quake Terminal for Gnome or `yakuake` Also works with *flatpak redirection*, local executables, and *distrobox passthrough*. -- **`cp` and `mv` redirection to `rsync`:** make your file operations more efficient with `rsync`'s delta copies, while also getting it to smartly display progress on larger tasks. +- **Easily open apps from terminal automatically detaching them** with `detach appname`. Perfect for usage with something like Quake Terminal for Gnome or `yakuake`. Also works with *flatpak redirection*, local executables, and *distrobox passthrough*. +- **`cp` and `mv` helpers:** large same-device file copies can show `rsync` progress; directories without `-r` are refused (like GNU `cp`); same-filesystem `mv` stays a real `mv`. - **Simple file management in your shell:** automatic `ls` after `cd` so you never get lost in your files, and enabled *autocd* shell option so you can navigate your subdirectories more intuitively. -- **Most used zsh shortcuts**: `md`, `rd`, `mkcd`, `take`, `~` and `..` work exactly like zsh, while you can use `cd ...`, `cd ....` and `cd .....` for the other upwards navigation commands. -- **`all`**: selects all files and subdirectories at its location as targets for `rd`, `rm`, `cp` and `mv`. -- **Better autocompletion** with *Tab* key cycling through all options instead of just displaying them. +- **Most used zsh shortcuts**: `md`, `rd` (`rmdir`), `mkcd`, `take`, `~` and `..` work like zsh, while you can use `cd ...`, `cd ....` and `cd .....` for the other upwards navigation commands. +- **`all`**: selects all non-hidden files and subdirectories at its location as targets for `rd`, `rm`, `cp` and `mv`. `rm all` is interactive; `rd all` only removes empty directories. +- **Better autocompletion** with *Tab* key cycling through all options (Shift+Tab goes backwards when supported). - **FZF shell bindings:** if *fzf* is installed, `sloth-bash` enables the standard Bash integration, including fuzzy history search on `Ctrl+R`. - **Basic command colors:** enables colored output for `ls`, `dir`, `vdir` and `grep`, plus familiar aliases like `ll`, `la` and `l`. -- **Case-insensitive command parsing:**, `ls`, `Ls`, `lS` or `LS`, your command will always work. +- **Case-insensitive command parsing:** `ls`, `Ls`, `lS` or `LS`, your command will always work (exact name match, not regex). - **Distrobox passthrough:** enter your distrobox shell with `distroboxname` and pass commands to your distroboxes running `distroboxname command` (and any additional arguments if needed after). As distroboxes follow the user's *`~/.bashrc`* file, sloth-bash also works from within them! +## Optional integrations +- **Starship** is installed by the installer when missing (recommended). +- **fzf** is installed by the installer when missing. +- **Zellij** auto-start is **opt-in**: set `SLOTH_BASH_ZELLIJ=1` in your environment before sourcing sloth-bash. + ## Recommended usage - Install *[Starship](https://starship.rs)* for the complete experience. - Install *[fzf](https://github.com/junegunn/fzf)* if you want the full fuzzy shell bindings. - Copy **sloth-bash** to **`~/.sloth-bash`** -- Copy **alias-list** to **`~/.alias-list`** -- Copy **starship.toml** to **`~/.config/starship.toml`** (not required if you don't wanna use *Starship*) +- Copy **alias-list** to **`~/.alias-list`** (customize Flatpak aliases here; updates do not overwrite it) +- Copy **starship.toml** to **`~/.config/starship.toml`** only if you do not already have one - Add **`source ~/.sloth-bash`** to the end of your `~/.bashrc` file + +## Tests +```bash +bash tests/run.sh +``` diff --git a/install.sh b/install.sh index b72630c..03c3ae0 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +set -euo pipefail + +REPO_RAW="${SLOTH_BASH_RAW_BASE:-https://raw.githubusercontent.com/psygreg/sloth-bash/main}" # Argument parsing: Check if called from app APP_MODE=false @@ -11,10 +14,33 @@ for arg in "$@"; do done # If called from app and SCRIPT_DIR is set, source shared libraries -if [[ "$APP_MODE" == true ]] && [[ -n "$SCRIPT_DIR" ]] && [[ -f "$SCRIPT_DIR/libs/linuxtoys.lib" ]]; then +if [[ "$APP_MODE" == true ]] && [[ -n "${SCRIPT_DIR:-}" ]] && [[ -f "$SCRIPT_DIR/libs/linuxtoys.lib" ]]; then + # shellcheck source=/dev/null source "$SCRIPT_DIR/libs/linuxtoys.lib" fi +download() { + local url="$1" dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dest" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$dest" "$url" + else + echo "Need curl or wget to download $url" >&2 + return 1 + fi +} + +download_if_missing() { + local url="$1" dest="$2" + if [[ -e "$dest" ]]; then + echo "Keeping existing $dest" + return 0 + fi + mkdir -p "$(dirname -- "$dest")" + download "$url" "$dest" +} + header() { echo "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣤⠤⠴⠖⠚⠛⠉⠉⠉⠀⠀ " echo "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⢰⣸⣶⣇⠀⢰⣿⣿⣷⣤⠤⠴⠖⠒⠛⠉⠉⠁⠀⠀⠀⠀⠀⠀⣀⣀⣤⠤⠶⠀ " @@ -22,13 +48,13 @@ header() { echo "⠀⠀⠀⠀⣀⣠⣤⢾⣿⠽⢼⡛⠋⠉⠉⠁⢸⠀⠀⠈⢻⣄⣀⣠⠤⠤⣶⠒⠚⠋⢉⣉⣠⣤⠤⠤⠤⣤⣀⠀⠀⠀⠀⠀⠀⠀ " echo "⠀⠟⠋⠉⠉⠀⠀⣾⠃⠀⠈⢻⣀⣠⠤⢤⣿⠀⠀⠀⠀⢿⡇⠀⢀⣤⡿⠗⠒⢚⣏⠉⠱⡄⠀⠀⠀⢸⠈⠙⠲⣄⠀⠀⠀⠀ " echo "⠀⣤⣀⣀⡤⠤⠶⡿⠀⠀⠀⠀⣿⠀⠀⠀⢿⠀⠀⠀⠀⠘⣇⣴⠃⠀⠻⢤⡸⣿⡿⠏⢀⡿⠀⠀⢠⣏⡀⠀⠀⠈⢷⡀⠀⠀ " - echo "⠀⠉⠁⠀⠀⠀⢰⡇⠀⠀⠀⠀⠸⡆⠀⠀⢸⡁⠀⠀⠀⠀⢹⡏⠀⠀⠀⣰⣿⣗⠓⠒⠛⠀⠀⠀⠀⠀⠉⠓⢦⡀⠀⢳⡄⠀ " + echo "⠀⠉⠁⠀⠀⠀⢰⡇⠀⠀⠀⠀⠸⡆⠀⠀⢸⡁⠀⠀⠀⠀⢹⡏⠀⠀⠀⣰⣿⣗⠓⠒⠛⠀⠀⠀⠀⠀⠉⠓⢦⡀⠀⢳⡄⠀ " echo "⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⢳⠒⠒⢙⡇⠀⠀⠀⠀⠀⣧⠀⠀⠀⢿⣧⣽⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢳⡀⠀⢷⠀ " echo "⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠈⠁⠀⠘⠃⠀⠀⠀⠀⠀⢻⡀⠀⠀⠈⠛⠛⠀⣠⠞⡉⠉⠳⣄⠀⠀⠀⠀⠀⢳⠀⠘⡇ " echo "⠀⠀⠀⠀⠀⠀⢸⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣧⠀⠀⠀⠀⠀⢠⠇⣴⣿⣷⠀⠸⡆⠀⠀⠀⠀⢸⠀⠀⡇ " echo "⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣆⠀⠀⠀⠀⢸⡄⠉⠋⠉⠀⠀⢷⠀⠀⠀⠀⣸⠀⢠⡇ " echo "⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣆⠀⠀⠀⡼⠁⠀⠀⠀⠀⠀⢸⠀⠀⠀⣰⠃⠀⡼⠀ " - echo "⠀⠀⠀⠀⠀⠀⠀⢹⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⡀⢀⠇⠀⠀⠀⠀⠀⠀⣸⠀⣠⠞⠁⠀⡼⠃⠀ " + echo "⠀⠀⠀⠀⠀⠀⠀⢹⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⡀⢀⠇⠀⠀⠀⠀⠀⠀⣸⠀⣠⠞⠁⠀⡼⠃⠀ " echo "⠀⠀⠀⠀⠀⠀⠀⠀⢿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠾⣄⣀⡀⢀⣀⣀⡠⠷⠚⠁⠀⣠⠞⠁ " echo "⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠁⠀⠀⠀⢀⣠⠞⠃⠀⠀⠀⠀ " echo "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠖⠋⠁⠀⠀⠀⠀⠀⠀ " @@ -37,9 +63,9 @@ header() { echo " _ _ _ _ _ " echo " | | | | | | | | | | " echo " ___| | ___ | |_| |__ ______| |__ __ _ ___| |__ " - echo " / __| |/ _ \| __| '_ \______| '_ \\ / \`_ / __| '_ \\ " - echo " \__ \ | (_) | |_| | | | | |_) | (_| \__ \ | | | " - echo " |___/_|\___/ \__|_| |_| |_.__/ \__,_|___/_| |_| " + echo " / __| |/ _ \\| __| '_ \\______| '_ \\\\ / \`_ / __| '_ \\\\ " + echo " \\__ \\ | (_) | |_| | | | | |_) | (_| \\__ \\ | | | " + echo " |___/_|\\___/ \\__|_| |_| |_.__/ \\__,_|___/_| |_| " echo } @@ -55,12 +81,8 @@ show_menu() { echo read -rp "Enter your choice (1-3): " choice < /dev/tty case "$choice" in - 1) - return 0 # Install or update - ;; - 2) - return 1 # Remove - ;; + 1) return 0 ;; + 2) return 1 ;; 3) echo "Ok, bye!" exit 100 @@ -78,9 +100,7 @@ show_menu() { echo read -rp "Enter your choice (1-2): " choice < /dev/tty case "$choice" in - 1) - return 0 # Install - ;; + 1) return 0 ;; 2) echo "Ok, bye!" exit 100 @@ -95,44 +115,68 @@ show_menu() { show_menu action=$? -sudo -v +remove_source_lines() { + local file="$1" + [[ -f "$file" ]] || return 0 + # portable-ish in-place delete of source lines + local tmp + tmp=$(mktemp) + grep -v 'source ~/.sloth-bash' "$file" > "$tmp" || true + mv "$tmp" "$file" +} + if [[ $action -eq 1 ]]; then echo "Removing sloth-bash..." - rm -f ~/.sloth-bash ~/.alias-list - if [[ -f ~/.bashrc ]]; then - sed -i '/source ~\/.sloth-bash/d' ~/.bashrc - fi - if [[ -f ~/.profile ]]; then - sed -i '/source ~\/.sloth-bash/d' ~/.profile - fi + rm -f ~/.sloth-bash + # Keep ~/.alias-list and starship.toml — user customizations + remove_source_lines ~/.bashrc + remove_source_lines ~/.profile echo "sloth-bash has been removed." + echo "Note: ~/.alias-list, ~/.config/starship.toml, Starship, and fzf were left in place." exit 0 fi -if ! which starship > /dev/null; then - sudo mkdir -p /usr/local/bin # ensure the directory exists before attempting to install starship - if [[ ":$PATH:" != *":/usr/local/bin"* ]]; then - export PATH="/usr/local/bin:$PATH" - [[ -f ~/.bashrc ]] && grep -q "/usr/local/bin" ~/.bashrc || echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc + +if ! command -v starship >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo mkdir -p /usr/local/bin + if [[ ":$PATH:" != *":/usr/local/bin:"* ]]; then + export PATH="/usr/local/bin:$PATH" + if [[ -f ~/.bashrc ]] && ! grep -q '/usr/local/bin' ~/.bashrc; then + echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc + fi + fi + curl -fsS https://starship.rs/install.sh | sh + else + echo "Starship is not installed and sudo is unavailable; skipping Starship install." fi - curl -sS https://starship.rs/install.sh | sh fi -if [ "$APP_MODE" = true ]; then + +if [[ "$APP_MODE" == true ]]; then pkg_install fzf else - if ! which fzf > /dev/null; then - source <(curl -s https://raw.githubusercontent.com/psygreg/linuxtoys/master/p3/libs/linuxtoys.lib) + if ! command -v fzf >/dev/null 2>&1; then + # shellcheck source=/dev/null + source <(curl -fsS https://raw.githubusercontent.com/psygreg/linuxtoys/master/p3/libs/linuxtoys.lib) pkg_install fzf --ignore-appends fi fi -wget -O ~/.sloth-bash https://raw.githubusercontent.com/psygreg/sloth-bash/main/sloth-bash -wget -O ~/.alias-list https://raw.githubusercontent.com/psygreg/sloth-bash/main/alias-list -wget -O ~/.config/starship.toml https://raw.githubusercontent.com/psygreg/sloth-bash/main/starship.toml + +# Core script always updates; user config files are preserved when present. +download "$REPO_RAW/sloth-bash" ~/.sloth-bash +download_if_missing "$REPO_RAW/alias-list" ~/.alias-list +mkdir -p ~/.config +download_if_missing "$REPO_RAW/starship.toml" ~/.config/starship.toml + if [[ -f ~/.bashrc ]]; then - if ! grep -q "source ~/.sloth-bash" ~/.bashrc; then - echo "source ~/.sloth-bash" >> ~/.bashrc + if ! grep -q 'source ~/.sloth-bash' ~/.bashrc; then + echo 'source ~/.sloth-bash' >> ~/.bashrc fi else - if ! grep -q "source ~/.sloth-bash" ~/.profile; then - echo "source ~/.sloth-bash" >> ~/.profile + if [[ -f ~/.profile ]] && ! grep -q 'source ~/.sloth-bash' ~/.profile; then + echo 'source ~/.sloth-bash' >> ~/.profile + elif [[ ! -f ~/.profile ]]; then + echo 'source ~/.sloth-bash' >> ~/.bashrc fi -fi \ No newline at end of file +fi + +echo "sloth-bash installed. Open a new shell or run: source ~/.sloth-bash" diff --git a/sloth-bash b/sloth-bash index 128a7fd..ae3ebd6 100644 --- a/sloth-bash +++ b/sloth-bash @@ -1,8 +1,185 @@ +# shellcheck disable=SC1090 [[ -f ~/.alias-list ]] && source ~/.alias-list +# ----------------------------------------------------------------------------- +# helpers +# ----------------------------------------------------------------------------- + +_sloth_run_quick_lookup() { + if command -v timeout >/dev/null 2>&1; then + timeout 0.5s "$@" 2>/dev/null + else + "$@" 2>/dev/null + fi +} + +_sloth_has_option_arg() { + local arg + for arg in "$@"; do + if [[ "$arg" == -- ]]; then + return 1 + fi + if [[ "$arg" == -* ]]; then + return 0 + fi + done + return 1 +} + +_sloth_total_size_bytes() { + local total + total=$(du -sb "$@" 2>/dev/null | awk 'END {print $1}') + if [[ "$total" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$total" + return 0 + fi + + total=$(du -sk "$@" 2>/dev/null | awk '{sum += $1} END {print sum * 1024}') + if [[ "$total" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$total" + return 0 + fi + + return 1 +} + +# Same filesystem? Prefer real mv over rsync. +_sloth_same_filesystem() { + local src="$1" dest="$2" + local src_dev dest_dev dest_probe + + src_dev=$(stat -c '%d' "$src" 2>/dev/null) || return 1 + if [[ -e "$dest" ]]; then + dest_probe="$dest" + else + dest_probe=$(dirname -- "$dest") + fi + dest_dev=$(stat -c '%d' "$dest_probe" 2>/dev/null) || return 1 + [[ "$src_dev" == "$dest_dev" ]] +} + +# Exact case-insensitive command name match (no regex). +_sloth_find_ci_command() { + local needle="${1,,}" + local cand + while IFS= read -r cand; do + [[ -n "$cand" ]] || continue + if [[ "${cand,,}" == "$needle" ]]; then + printf '%s\n' "$cand" + return 0 + fi + done < <(compgen -c 2>/dev/null) + return 1 +} + +_sloth_flatpak_from_alias() { + local cmd="$1" + [[ -n "${FLATPAK_ALIASES[$cmd]:-}" ]] || return 1 + command -v flatpak >/dev/null 2>&1 || return 1 + if flatpak info "${FLATPAK_ALIASES[$cmd]}" > /dev/null 2>&1; then + printf '%s\n' "${FLATPAK_ALIASES[$cmd]}" + return 0 + fi + return 1 +} + +# Match Flatpak app id by exact case-insensitive token (no regex). +_sloth_flatpak_from_list() { + local cmd="$1" + local cmd_lower="${cmd,,}" + local app id_lower last + + command -v flatpak >/dev/null 2>&1 || return 1 + [[ "${cmd_lower}" == "kde" || "${cmd_lower}" == "gnome" ]] && return 1 + + while IFS= read -r app; do + [[ -n "$app" ]] || continue + [[ "${app,,}" == *platform* ]] && continue + id_lower="${app,,}" + last="${id_lower##*.}" + if [[ "$id_lower" == "$cmd_lower" || "$last" == "$cmd_lower" ]]; then + printf '%s\n' "$app" + return 0 + fi + done < <(_sloth_run_quick_lookup flatpak list --app --columns=application) + return 1 +} + +_sloth_is_distrobox() { + local cmd="$1" + local boxes + command -v distrobox >/dev/null 2>&1 || return 1 + boxes=$(_sloth_run_quick_lookup distrobox list | awk 'NR>1 {print $3}') + printf '%s\n' "$boxes" | grep -Fxq "$cmd" +} + +# Resolve cmd into: kind|payload +# kinds: local | flatpak | distrobox | none +_sloth_resolve_cmd() { + local cmd="$1" + local app + + if [[ -f "$cmd" && -x "$cmd" ]]; then + printf 'local|%s\n' "$cmd" + return 0 + fi + + if app=$(_sloth_flatpak_from_alias "$cmd"); then + printf 'flatpak|%s\n' "$app" + return 0 + fi + + if app=$(_sloth_flatpak_from_list "$cmd"); then + printf 'flatpak|%s\n' "$app" + return 0 + fi + + if _sloth_is_distrobox "$cmd"; then + printf 'distrobox|%s\n' "$cmd" + return 0 + fi + + printf 'none|\n' + return 1 +} + +_sloth_run_local() { + local cmd="$1"; shift + if [[ "$cmd" == /* || "$cmd" == ./* || "$cmd" == ../* || "$cmd" == */* ]]; then + "$cmd" "$@" + else + ./"$cmd" "$@" + fi +} + +_sloth_python_for() { + local script="$1" + local script_dir + script_dir=$(dirname -- "$script") + if [[ -x "$script_dir/venv/bin/python3" ]]; then + printf '%s\n' "$script_dir/venv/bin/python3" + elif [[ -x "$script_dir/.venv/bin/python3" ]]; then + printf '%s\n' "$script_dir/.venv/bin/python3" + else + printf 'python3\n' + fi +} + +# Remove empty ancestor directories left behind by rsync --remove-source-files. +_sloth_prune_empty_dirs() { + local path + for path in "$@"; do + [[ -d "$path" ]] || continue + find "$path" -depth -type d -empty -delete 2>/dev/null + [[ -d "$path" && -z "$(find "$path" -mindepth 1 -print -quit 2>/dev/null)" ]] && rmdir "$path" 2>/dev/null + done +} + +# ----------------------------------------------------------------------------- # bash file manager +# ----------------------------------------------------------------------------- shopt -s autocd -function cd { +cd() { if [[ $# -eq 0 ]]; then builtin cd && ls -F return $? @@ -16,119 +193,107 @@ function cd { esac builtin cd "$arg" "${@:2}" && ls -F } + # detach app wrapper detach() { + if [[ $# -lt 1 ]]; then + echo "detach: missing command" >&2 + return 1 + fi + local cmd="$1" - local resolved="$cmd" - if [[ -f "$cmd" && -x "$cmd" ]]; then - resolved="./$cmd" - else - if command -v distrobox &>/dev/null; then - local distroBoxes=$(distrobox list 2>/dev/null | awk 'NR>1 {print $3}') - if echo "$distroBoxes" | grep -q "^${cmd}$"; then - nohup distrobox enter "$cmd" -- "${@:2}" > /dev/null 2>&1 & - return $? + shift + local resolved kind payload + + resolved=$(_sloth_resolve_cmd "$cmd") || true + kind="${resolved%%|*}" + payload="${resolved#*|}" + + case "$kind" in + local) + if [[ "$payload" == /* || "$payload" == ./* || "$payload" == ../* || "$payload" == */* ]]; then + nohup "$payload" "$@" > /dev/null 2>&1 & + else + nohup ./"$payload" "$@" > /dev/null 2>&1 & fi - fi - if [[ -n "${FLATPAK_ALIASES[$cmd]}" ]]; then - if flatpak info "${FLATPAK_ALIASES[$cmd]}" > /dev/null 2>&1; then - resolved="flatpak run ${FLATPAK_ALIASES[$cmd]}" + ;; + flatpak) + nohup flatpak run "$payload" "$@" > /dev/null 2>&1 & + ;; + distrobox) + if [[ $# -gt 0 ]]; then + nohup distrobox enter "$payload" -- "$@" > /dev/null 2>&1 & + else + nohup distrobox enter "$payload" > /dev/null 2>&1 & fi - else - if [[ "${cmd,,}" != "kde" && "${cmd,,}" != "gnome" ]]; then - local match - match=$(flatpak list --app --columns=application 2>/dev/null \ - | grep -v -i "Platform" \ - | grep -iE "(^|\.)${cmd}(\.|$)" \ - | head -n 1) - if [[ -n "$match" ]]; then - resolved="flatpak run $match" - fi + ;; + *) + if command -v "$cmd" >/dev/null 2>&1; then + nohup "$cmd" "$@" > /dev/null 2>&1 & + else + echo "detach: command not found: $cmd" >&2 + return 127 fi - fi - fi - nohup $resolved "${@:2}" > /dev/null 2>&1 & -} - -_sloth_run_quick_lookup() { - if command -v timeout >/dev/null 2>&1; then - timeout 0.5s "$@" 2>/dev/null - else - "$@" 2>/dev/null - fi + ;; + esac } # python3 wrapper and flatpak matcher command_not_found_handle() { - local cmd_lower="${1,,}" - local match=$(compgen -c 2>/dev/null | grep -i "^${cmd_lower}$" | head -n 1) - if [[ -n "$match" ]]; then + local cmd="$1" + local match py resolved kind payload + + # Case-insensitive exact match against known commands + if match=$(_sloth_find_ci_command "$cmd"); then "$match" "${@:2}" return $? fi - local cmd="$1" - if [[ "$1" == *.py ]]; then - local script_dir - script_dir=$(dirname "$1") - if [[ -d "$script_dir/venv" ]]; then - source "$script_dir/venv/bin/activate" - elif [[ -d "$script_dir/.venv" ]]; then - source "$script_dir/.venv/bin/activate" + + # Local *.py: use venv python without sourcing activate + if [[ "$cmd" == *.py ]]; then + if [[ ! -f "$cmd" ]]; then + echo "bash: $cmd: No such file or directory" >&2 + return 127 fi - python3 "$@" + py=$(_sloth_python_for "$cmd") + "$py" "$@" return $? fi - if [[ -f "$1" ]]; then - if [[ -x "$1" ]]; then - ./"$1" "${@:2}" + + # Local file: run if executable, else open + if [[ -f "$cmd" ]]; then + if [[ -x "$cmd" ]]; then + _sloth_run_local "$cmd" "${@:2}" return $? else - xdg-open "$1" - return $? - fi - fi - shift - local args=("$@") - if command -v flatpak >/dev/null 2>&1 && [[ -n "${FLATPAK_ALIASES[$cmd]}" ]]; then - if flatpak info "${FLATPAK_ALIASES[$cmd]}" > /dev/null 2>&1; then - flatpak run "${FLATPAK_ALIASES[$cmd]}" "${args[@]}" + xdg-open "$cmd" return $? fi fi - if command -v flatpak >/dev/null 2>&1 && [[ "${cmd,,}" != "kde" && "${cmd,,}" != "gnome" ]]; then - local match - match=$(_sloth_run_quick_lookup flatpak list --app --columns=application \ - | grep -v -i "Platform" \ - | grep -iF -m 1 "$cmd") - if [[ -n "$match" ]]; then - match=$(printf '%s\n' "$match" | head -n 1) - fi - if [[ -z "$match" ]]; then - match=$(_sloth_run_quick_lookup flatpak list --app --columns=application \ - | grep -v -i "Platform" \ - | grep -iE "(^|\.)${cmd}(\.|$)" \ - | head -n 1) - fi - if [[ -n "$match" ]]; then - flatpak run "$match" "${args[@]}" + + resolved=$(_sloth_resolve_cmd "$cmd") || true + kind="${resolved%%|*}" + payload="${resolved#*|}" + + case "$kind" in + flatpak) + flatpak run "$payload" "${@:2}" return $? - fi - fi - if command -v distrobox &>/dev/null; then - local distroBoxes - distroBoxes=$(_sloth_run_quick_lookup distrobox list | awk 'NR>1 {print $3}') - if printf '%s\n' "$distroBoxes" | grep -Fxq "$cmd"; then - if [[ -n "${args[0]}" ]]; then - distrobox enter "$cmd" -- "${args[@]}" + ;; + distrobox) + if [[ $# -gt 1 ]]; then + distrobox enter "$payload" -- "${@:2}" else - distrobox enter "$cmd" + distrobox enter "$payload" fi return $? - fi - fi + ;; + esac + echo "bash: $cmd: command not found" >&2 return 127 } + # 'all' keyword expansion _expand_all_keyword() { local -a out=() @@ -159,12 +324,15 @@ _expand_all_keyword() { done printf '%s\0' "${out[@]}" } + _run_with_all() { local cmd="$1"; shift local -a expanded=() + local status=0 while IFS= read -r -d '' item; do expanded+=( "$item" ) done < <(_expand_all_keyword "$@") || return $? + if [[ "$cmd" == "rm" ]]; then local -a opts=() paths=() for item in "${expanded[@]}"; do @@ -175,57 +343,23 @@ _run_with_all() { fi done command rm -i "${opts[@]}" "${paths[@]}" + return $? elif [[ "$cmd" == "rd" ]]; then - local -a dirs_only=() + local item for item in "${expanded[@]}"; do - [[ -d "$item" && ! -L "$item" ]] && dirs_only+=( "$item" ) + if [[ -d "$item" && ! -L "$item" ]]; then + if ! command rmdir "$item"; then + status=1 + fi + fi done - command rm -r "${dirs_only[@]}" + return "$status" else command "$cmd" "${expanded[@]}" + return $? fi } -_sloth_has_option_arg() { - local arg - for arg in "$@"; do - if [[ "$arg" == -- ]]; then - return 1 - fi - if [[ "$arg" == -* ]]; then - return 0 - fi - done - return 1 -} - -_sloth_total_size_bytes() { - local total - total=$(du -sb "$@" 2>/dev/null | awk 'END {print $1}') - if [[ "$total" =~ ^[0-9]+$ ]]; then - printf '%s\n' "$total" - return 0 - fi - - total=$(du -sk "$@" 2>/dev/null | awk '{sum += $1} END {print sum * 1024}') - if [[ "$total" =~ ^[0-9]+$ ]]; then - printf '%s\n' "$total" - return 0 - fi - - return 1 -} - -_sloth_fzf_history_search() { - command -v fzf >/dev/null 2>&1 || return 1 - - local selected - selected=$(fc -lnr 1 | awk 'NF && !seen[$0]++' | fzf --height 40% --layout=reverse --border --prompt='history> ' --query "$READLINE_LINE") || return 0 - - READLINE_LINE="$selected" - READLINE_POINT=${#READLINE_LINE} -} - _sloth_enable_basic_colors() { if command -v dircolors >/dev/null 2>&1; then if [[ -r ~/.dircolors ]]; then @@ -247,6 +381,7 @@ _sloth_enable_basic_colors() { # cp and mv replacers cp() { + local a for a in "$@"; do if [[ "$a" == "all" || "$a" == */all ]]; then _run_with_all cp "$@"; return $? @@ -260,7 +395,26 @@ cp() { command cp "$@" return $? fi - local sources=("${@:1:$#-1}") + + # Without flags, refuse directories (match GNU cp) and never invent -r via rsync. + local -a sources=("${@:1:$#-1}") + local dest="${!#}" + local src + for src in "${sources[@]}"; do + if [[ -d "$src" && ! -L "$src" ]]; then + echo "cp: -r not specified; omitting directory '$src'" >&2 + return 1 + fi + done + + # Symlinks / special files: keep GNU cp semantics + for src in "${sources[@]}"; do + if [[ -L "$src" || ! -f "$src" ]]; then + command cp "$@" + return $? + fi + done + local total_size total_size=$(_sloth_total_size_bytes "${sources[@]}") || { command cp "$@" @@ -273,7 +427,9 @@ cp() { command rsync -ah --no-owner --no-group "$@" fi } + mv() { + local a for a in "$@"; do if [[ "$a" == "all" || "$a" == */all ]]; then _run_with_all mv "$@"; return $? @@ -287,27 +443,46 @@ mv() { command mv "$@" return $? fi - local dest="${@: -1}" - local sources=("${@:1:$#-1}") - local source_dir=$(dirname "${sources[0]}") - local dest_dir=$(dirname "$dest") - if [[ "$source_dir" == "$dest_dir" ]]; then + + local dest="${!#}" + local -a sources=("${@:1:$#-1}") + local source_dir + source_dir=$(dirname -- "${sources[0]}") + + # Same-directory rename: always real mv + if [[ "$source_dir" == "$(dirname -- "$dest")" ]]; then + command mv "$@" + return $? + fi + + # Same filesystem (including directories): real mv is atomic and complete + if _sloth_same_filesystem "${sources[0]}" "$dest"; then command mv "$@" return $? fi + local total_size total_size=$(_sloth_total_size_bytes "${sources[@]}") || { command mv "$@" return $? } local threshold=$((100 * 1024 * 1024)) + local status=0 if (( total_size > threshold )); then command rsync -ah --no-owner --no-group --info=progress2 --remove-source-files "$@" + status=$? else command rsync -ah --no-owner --no-group --remove-source-files "$@" + status=$? fi + if [[ $status -eq 0 ]]; then + _sloth_prune_empty_dirs "${sources[@]}" + fi + return "$status" } + rm() { + local a for a in "$@"; do if [[ "$a" == "all" || "$a" == */all ]]; then _run_with_all rm "$@"; return $? @@ -315,18 +490,26 @@ rm() { done command rm "$@" } + +# zsh-compatible: remove empty directories only rd() { + local a status=0 for a in "$@"; do if [[ "$a" == "all" || "$a" == */all ]]; then _run_with_all rd "$@"; return $? fi - if [ -d "$a" ]; then - command rm -r "$a" + if [[ -d "$a" && ! -L "$a" ]]; then + if ! command rmdir "$a"; then + status=1 + fi else echo "rd: not a directory: $a" >&2 + status=1 fi done + return "$status" } + # zsh features mkcd() { if [ "$#" -lt 1 ]; then @@ -335,6 +518,84 @@ mkcd() { fi mkdir -p "$1" && cd "$1" } + +_sloth_take_extract() { + local filename="$1" + local workdir + workdir=$(mktemp -d "./.sloth-take-XXXXXX") || return 1 + + ( + set -e + case "$filename" in + *.tar.gz) tar -xzf "$filename" -C "$workdir" ;; + *.tar.bz2) tar -xjf "$filename" -C "$workdir" ;; + *.tar) tar -xf "$filename" -C "$workdir" ;; + *.zip) unzip -q "$filename" -d "$workdir" ;; + *.rar) + # unrar extracts relative to cwd + ( cd "$workdir" && unrar x -o+ "../$filename" >/dev/null ) + ;; + *) + echo "take: unsupported archive: $filename" >&2 + exit 1 + ;; + esac + ) + local status=$? + if [[ $status -ne 0 ]]; then + rm -rf "$workdir" + return "$status" + fi + + # If archive had a single top-level entry, promote it; else keep the workdir. + local -a entries=( "$workdir"/* "$workdir"/.[!.]* "$workdir"/..?* ) + local -a real=() + local e + for e in "${entries[@]}"; do + [[ -e "$e" ]] && real+=( "$e" ) + done + + local target + if [[ "${#real[@]}" -eq 1 ]]; then + target=$(basename -- "${real[0]}") + if [[ -e "$target" ]]; then + target="${target}-taken" + fi + mv "${real[0]}" "$target" + rmdir "$workdir" 2>/dev/null || rm -rf "$workdir" + else + target="${filename%.*}" + [[ "$filename" == *.tar.gz ]] && target="${filename%.tar.gz}" + [[ "$filename" == *.tar.bz2 ]] && target="${filename%.tar.bz2}" + if [[ -e "$target" ]]; then + target="${workdir#./}" + else + mv "$workdir" "$target" + fi + fi + + rm -f "$filename" + cd "$target" +} + +_sloth_is_archive_url() { + case "$1" in + *.tar.gz|*.tar.bz2|*.tar|*.zip|*.rar) return 0 ;; + *) return 1 ;; + esac +} + +_sloth_is_git_url() { + local arg="$1" + _sloth_is_archive_url "$arg" && return 1 + [[ "$arg" == *.git ]] && return 0 + [[ "$arg" =~ ^git@ ]] && return 0 + [[ "$arg" =~ ^(ssh|git):// ]] && return 0 + # https://host/owner/repo[/] + [[ "$arg" =~ ^https?://[^/]+/[^/]+/[^/?#]+/?$ ]] && return 0 + return 1 +} + take() { if [ "$#" -lt 1 ]; then echo "take: missing argument" >&2 @@ -347,54 +608,58 @@ take() { elif [[ "$arg" =~ ^(ssh|git|ftp):// ]] || [[ "$arg" =~ ^[^/@:]+@[^/:]+:.+ ]]; then is_url=1 fi - if [[ "$is_url" -eq 1 ]]; then - if [[ "$arg" == *.git ]]; then - git clone "$arg" || return $? - local dir=$(basename "$arg" .git) - cd "$dir" - elif [[ "$arg" == *.tar.gz ]] || [[ "$arg" == *.tar.bz2 ]] || [[ "$arg" == *.tar ]] || [[ "$arg" == *.zip ]] || [[ "$arg" == *.rar ]]; then - local filename=$(basename "$arg") - wget "$arg" -O "$filename" || return $? - local base_dir - if [[ "$filename" == *.tar.gz ]]; then - base_dir="${filename%.tar.gz}" - mkdir -p "$base_dir" - tar -xzf "$filename" -C "$base_dir" || return $? - elif [[ "$filename" == *.tar.bz2 ]]; then - base_dir="${filename%.tar.bz2}" - mkdir -p "$base_dir" - tar -xjf "$filename" -C "$base_dir" || return $? - elif [[ "$filename" == *.tar ]]; then - base_dir="${filename%.tar}" - mkdir -p "$base_dir" - tar -xf "$filename" -C "$base_dir" || return $? - elif [[ "$filename" == *.zip ]]; then - base_dir="${filename%.zip}" - mkdir -p "$base_dir" - unzip "$filename" -d "$base_dir" || return $? - elif [[ "$filename" == *.rar ]]; then - base_dir="${filename%.rar}" - mkdir -p "$base_dir" - cd "$base_dir" - unrar x "../$filename" || return $? - cd .. - fi - rm "$filename" - cd "$base_dir" + + # Local archive file + if [[ "$is_url" -ne 1 && -f "$arg" ]] && _sloth_is_archive_url "$arg"; then + local filename + filename=$(basename -- "$arg") + if [[ "$arg" != "$filename" && "$arg" != "./$filename" ]]; then + cp -f "$arg" "./$filename" || return $? + fi + _sloth_take_extract "$filename" + return $? + fi + + if [[ "$is_url" -ne 1 ]]; then + mkcd "$arg" + return $? + fi + + if _sloth_is_archive_url "$arg"; then + local filename + filename=$(basename "$arg") + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$arg" -o "$filename" || return $? + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$filename" "$arg" || return $? else - echo "take: unsupported URL type" >&2 + echo "take: need curl or wget to download" >&2 return 1 fi - else - mkcd "$arg" + _sloth_take_extract "$filename" + return $? + fi + + if _sloth_is_git_url "$arg"; then + git clone "$arg" || return $? + local dir + dir=$(basename "${arg%/}") + dir="${dir%.git}" + cd "$dir" + return $? fi + + echo "take: unsupported URL type" >&2 + return 1 } + # interactive shell extras if [[ $- == *i* ]]; then _sloth_enable_basic_colors - # rolling tab + # rolling tab (+ reverse with Shift-Tab when supported) bind 'set show-all-if-ambiguous on' bind 'TAB:menu-complete' + bind '"\e[Z":menu-complete-backward' 2>/dev/null || true if command -v fzf >/dev/null 2>&1; then eval "$(fzf --bash)" fi @@ -402,8 +667,8 @@ if [[ $- == *i* ]]; then if command -v starship >/dev/null 2>&1; then eval "$(starship init bash)" fi - # zellij - if command -v zellij >/dev/null 2>&1; then + # zellij: opt-in only (set SLOTH_BASH_ZELLIJ=1) + if [[ "${SLOTH_BASH_ZELLIJ:-0}" == "1" ]] && command -v zellij >/dev/null 2>&1; then eval "$(zellij setup --generate-auto-start bash)" fi fi diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..8185851 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Functional tests for sloth-bash. Run from repo root: bash tests/run.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PASS=0 +FAIL=0 +failures=() + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + echo " OK $name" + else + FAIL=$((FAIL + 1)) + failures+=("$name: expected='$expected' actual='$actual'") + echo " FAIL $name (expected='$expected' actual='$actual')" + fi +} + +assert_ok() { + local name="$1"; shift + if "$@"; then + PASS=$((PASS + 1)) + echo " OK $name" + else + FAIL=$((FAIL + 1)) + failures+=("$name") + echo " FAIL $name" + fi +} + +assert_fail() { + local name="$1"; shift + if "$@"; then + FAIL=$((FAIL + 1)) + failures+=("$name (expected failure)") + echo " FAIL $name (expected failure)" + else + PASS=$((PASS + 1)) + echo " OK $name" + fi +} + +# Isolated home so alias-list / interactive bits stay quiet +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +export HOME="$TMP" +mkdir -p "$HOME" +# Minimal alias-list so associative array exists +cat > "$HOME/.alias-list" <<'EOF' +declare -A FLATPAK_ALIASES=( + [obs]="com.obsproject.Studio" +) +alias md='mkdir -p' +EOF + +# Non-interactive source +# shellcheck source=/dev/null +source "$ROOT/sloth-bash" + +WORKDIR=$(mktemp -d "$TMP/work-XXXX") +cd "$WORKDIR" + +echo "== cd ==" +mkdir -p nest/a/b +cd nest/a/b >/dev/null +cd ... >/dev/null +assert_eq "cd ... lands in nest" "$WORKDIR/nest" "$(pwd)" +cd "$WORKDIR" >/dev/null +cd >/dev/null +assert_eq "cd with no args goes HOME" "$HOME" "$(pwd)" +cd "$WORKDIR" >/dev/null + +echo "== executables / python ==" +printf '#!/bin/sh\necho RAN_REL\n' > rel.sh +chmod +x rel.sh +out=$(command_not_found_handle rel.sh) +assert_eq "relative executable runs" "RAN_REL" "$out" + +printf '#!/bin/sh\necho RAN_ABS\n' > "$WORKDIR/abs.sh" +chmod +x "$WORKDIR/abs.sh" +out=$(command_not_found_handle "$WORKDIR/abs.sh") +assert_eq "absolute executable runs" "RAN_ABS" "$out" + +mkdir -p pyproj/venv/bin +printf '#!/bin/sh\necho VENV_PY "$@"\n' > pyproj/venv/bin/python3 +chmod +x pyproj/venv/bin/python3 +printf 'print("hi")\n' > pyproj/script.py +# poison activate to detect accidental source +printf 'export VIRTUAL_ENV=LEAKED\necho ACTIVATED\n' > pyproj/venv/bin/activate +unset VIRTUAL_ENV || true +out=$(command_not_found_handle pyproj/script.py) +assert_eq "py uses venv python without activate" "VENV_PY pyproj/script.py" "$out" +assert_eq "VIRTUAL_ENV not leaked" "" "${VIRTUAL_ENV:-}" + +status=0 +command_not_found_handle pyproj/missing.py >/dev/null 2>&1 || status=$? +assert_eq "missing py returns 127" "127" "$status" +assert_eq "missing py does not leak venv" "" "${VIRTUAL_ENV:-}" + +echo "== case-insensitive exact match ==" +# Should find 'true' via True (builtin/command) +out=$(command_not_found_handle True && echo ok) +assert_eq "True resolves to true" "ok" "$out" +# Regex metachar must NOT match everything +status=0 +command_not_found_handle '.*' >/dev/null 2>&1 || status=$? +assert_eq "regex metachar is not a command" "127" "$status" + +echo "== rd (rmdir) ==" +mkdir empty_dir +rd empty_dir +assert_fail "empty_dir removed" test -d empty_dir + +mkdir -p full_dir/nested +echo x > full_dir/nested/f +status=0 +rd full_dir >/dev/null 2>&1 || status=$? +assert_ok "rd refuses non-empty" test "$status" -ne 0 +assert_ok "non-empty dir still there" test -d full_dir + +status=0 +rd definitely-missing >/dev/null 2>&1 || status=$? +assert_ok "rd missing is non-zero" test "$status" -ne 0 + +mkdir e1 e2 +# only empties succeed; non-empty dirs in cwd make rd all return non-zero +status=0 +rd all >/dev/null 2>&1 || status=$? +assert_fail "rd all removed e1" test -d e1 +assert_fail "rd all removed e2" test -d e2 +assert_ok "rd all left full_dir" test -d full_dir +assert_ok "rd all non-zero when some fail" test "$status" -ne 0 + +echo "== cp ==" +echo hello > cfile +mkdir -p cdest +cp cfile cdest/cfile +assert_eq "cp file content" "hello" "$(cat cdest/cfile)" + +mkdir csrc +echo x > csrc/f +status=0 +cp csrc cdest2 >/dev/null 2>&1 || status=$? +assert_ok "cp dir without -r fails" test "$status" -ne 0 +assert_fail "cp did not invent cdest2 tree" test -e cdest2 + +echo target > realfile +ln -s realfile linkfile +mkdir copydest +cp linkfile copydest/ +# GNU cp without -P follows symlink by default on Linux for file content +assert_ok "cp symlink produced a file" test -f copydest/linkfile + +echo "== mv ==" +echo a > rename-me +mv rename-me renamed +assert_ok "same-dir rename" test -f renamed +assert_fail "old name gone" test -e rename-me + +mkdir -p srcA/sub other +echo hello > srcA/sub/file.txt +mv srcA/sub other/ +assert_ok "mv dir completed" test -f other/sub/file.txt +assert_fail "mv dir left no empty source" test -e srcA/sub + +mkdir -p a b +echo hello > a/f +mv a/f b/f +assert_ok "mv file across dirs" test -f b/f +assert_fail "mv file removed source" test -e a/f + +echo "== all / rm ==" +# Expand 'all' into concrete paths then remove without relying on rm -i / tty +mkdir -p onlyfiles +cd onlyfiles >/dev/null +echo 1 > one +echo 2 > two +mapfile -d '' -t expanded < <(_expand_all_keyword all) +assert_eq "all expands 2 entries" "2" "${#expanded[@]}" +command rm -f -- "${expanded[@]}" +assert_fail "rm all removed one" test -e one +assert_fail "rm all removed two" test -e two +cd "$WORKDIR" >/dev/null + +echo "== mkcd / take ==" +mkcd taken_dir >/dev/null +assert_eq "mkcd enters dir" "$WORKDIR/taken_dir" "$(pwd)" +cd "$WORKDIR" >/dev/null + +mkdir -p payload +echo hi > payload/inside.txt +tar -czf sample.tar.gz payload +take sample.tar.gz >/dev/null +assert_ok "take local archive cds into content" test -f inside.txt +assert_fail "no double-wrapped payload" test -d payload +cd "$WORKDIR" >/dev/null +assert_ok "promoted payload dir remains" test -f payload/inside.txt +assert_fail "archive consumed" test -e sample.tar.gz + +echo "== helpers ==" +assert_ok "same filesystem detects self" _sloth_same_filesystem "$WORKDIR" "$WORKDIR/renamed" +out=$(_sloth_find_ci_command LS) +assert_eq "find_ci_command finds ls" "ls" "$out" + +echo "== install.sh syntax ==" +assert_ok "install.sh bash -n" bash -n "$ROOT/install.sh" +assert_ok "sloth-bash bash -n" bash -n "$ROOT/sloth-bash" + +echo +echo "Passed: $PASS Failed: $FAIL" +if [[ $FAIL -gt 0 ]]; then + printf ' - %s\n' "${failures[@]}" + exit 1 +fi +exit 0