aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-05-18 10:49:06 +0200
committerDanilo M. <danix@danix.xyz>2026-05-18 10:49:06 +0200
commit9f1ad23a187a493ffa812064e6641061553e6386 (patch)
treea164278161830d2f168f830f81fbafbb07670f9e
parente647c20e9f387b250469cda2b515cf767d2955a8 (diff)
downloadmkhintfile-9f1ad23a187a493ffa812064e6641061553e6386.tar.gz
mkhintfile-9f1ad23a187a493ffa812064e6641061553e6386.zip
feat: multiline DOWNLOAD support, test suite, updated docsHEADmaster
- Add parse_multiline_var, prompt_continuation_urls, build_multiline_value, _process_download_var; refactor update_checksums to handle multi-URL vars - First URL always re-downloaded; continuation URLs prompt user, skip re-download if unchanged - Add tests/mkhint_test.sh: 44 cases covering all commands and edge cases - Update README and CLAUDE.md: correct -N semantics, --new behavior, multiline DOWNLOAD flow, test suite docs
-rw-r--r--CLAUDE.md39
-rw-r--r--README.md88
-rwxr-xr-xmkhint148
-rwxr-xr-xtests/mkhint_test.sh347
4 files changed, 571 insertions, 51 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 61f8e89..e464d97 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,23 +26,52 @@ bash mkhint --help
bash mkhint --list
bash mkhint --version 2.0.1 --hintfile mypackage
bash mkhint --version 1.2.3 --new mypackage
-bash mkhint --new mypackage # empty hint, no version
-bash mkhint --version 2.0.1 --hintfile mypackage --no-dl # skip downloads, add NODOWNLOAD=yes
+bash mkhint --new mypackage # keep VERSION from .info, no downloads
+bash mkhint --version 2.0.1 --hintfile mypackage --no-dl # downloads + md5 + NODOWNLOAD=yes
bash mkhint --new mypackage --no-dl # create hint with NODOWNLOAD=yes
bash mkhint --delete mypackage
bash mkhint --clean
```
-No test suite exists. Test manually against real `REPO_DIR`/`HINT_DIR` or with mock directories.
+### Automated test suite
+
+Uses mock `REPO_DIR`/`HINT_DIR` and a fake `wget` — no real downloads, no real directories needed. Requires `/var/lib/sbopkg/SBo-git/` for `.info` file fixtures (read-only).
+
+```bash
+bash tests/mkhint_test.sh
+```
+
+Test coverage:
+
+| ID | Scenario |
+|-----|----------|
+| T1 | `--new` from `.info`, no version — template copied, VERSION kept from .info |
+| T2 | `--new -v` — version updated, md5 recalculated |
+| T3 | `--new -N` no version — NODOWNLOAD=yes added, no downloads |
+| T4 | `--new -v -N` — version + md5 + NODOWNLOAD=yes |
+| T5 | `--new` when hint exists — backup + empty skeleton |
+| T6 | `--hintfile -v` single URL — version + md5 updated, backup created |
+| T7 | `--hintfile -v -N` — version + md5 + NODOWNLOAD=yes |
+| T8 | `DOWNLOAD=UNSUPPORTED`, `DOWNLOAD_x86_64` has URL — 32-bit skipped, x86_64 md5 recalculated |
+| T9 | `-N` alone — exits 1 |
+| T10 | `--hintfile` on nonexistent file — exits 2 |
+| T11 | `--delete` — removes hint and .bak |
+| T12 | `--delete` nonexistent — exits 2 |
+| T13 | `--new` multiline `.info`, no version — template copied, original md5s kept |
+| T14 | `--new` multiline `.info` with `-v` — first URL+md5 updated, continuation md5 kept |
+| T15 | `--clean` — removes all .bak files |
+
+When adding new features, add a corresponding test case to `tests/mkhint_test.sh`.
## Key Behaviors
- `--hintfile` update: backs up to `.bak`, replaces old version string globally via `sed`, re-downloads both URLs to recalculate MD5 checksums. Skips download if value is `UNSUPPORTED` or `UNTESTED`.
-- `--new` with existing `.info`: copies `.info` as template, strips `PRGNAM`, `HOMEPAGE`, `MAINTAINER`, `EMAIL`, comments out `REQUIRES`, sets `ARCH="x86_64"`.
+- `--new` with existing `.info`: copies `.info` as template, strips `PRGNAM`, `HOMEPAGE`, `MAINTAINER`, `EMAIL`, comments out `REQUIRES`, sets `ARCH="x86_64"`. Keeps `VERSION` from `.info`. If `-v` given, updates version string and recalculates checksums.
- `--new` when hint already exists: backs up old, creates empty skeleton.
-- `--no-dl` / `-N`: skips all downloads; inserts `NODOWNLOAD=yes` after `MD5SUM_x86_64=` line. Works with `--hintfile` or `--new`. Error if used alone.
+- `--no-dl` / `-N`: downloads and recalculates checksums as normal, then appends `NODOWNLOAD=yes` after `MD5SUM_x86_64=`. Works with `--hintfile` or `--new`. Error if used alone.
- `--delete` / `-d`: removes hint file and `.bak` if present. Accepts multiple package names. Exits 2 on first missing file.
- Downloads go to `/tmp/mkhint/download` (single shared temp file, deleted after md5 calculation).
+- Multiline `DOWNLOAD`/`DOWNLOAD_x86_64`: parsed via `parse_multiline_var` (awk). First URL always re-downloaded. Continuation URLs (2+) prompt user interactively — changed URLs re-downloaded, unchanged URLs keep existing md5. Written back via `perl -i` with `\` continuation format preserved. Shared logic in `update_checksums` → `_process_download_var`.
## Exit Codes
diff --git a/README.md b/README.md
index b0e2a48..4ffdbca 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ REPO_DIR="/var/lib/sbopkg/SBo-danix/" # Repository containing .info files
HINT_DIR="/etc/slackrepo/SBo-danix/hintfiles/" # Directory where .hint files are stored
```
+Keep the same paths in sync in `mkhint.bash-completion` (lines 8–9).
+
## Usage
### Update an existing hint file
@@ -32,47 +34,60 @@ HINT_DIR="/etc/slackrepo/SBo-danix/hintfiles/" # Directory where .hint files ar
Updates the package version, downloads the archive, and recalculates the MD5 checksums:
```bash
-mkhint --version 2.0.1 --hintfile mypackage
+mkhint --hintfile mypackage --version 2.0.1
+mkhint -f mypackage -v 2.0.1
```
-Replaces the old version with 2.0.1 in mypackage.hint and re-downloads the URLs from DOWNLOAD and DOWNLOAD_x86_64 to update MD5SUM and MD5SUM_x86_64.
+Replaces the old version string with 2.0.1 everywhere in mypackage.hint, re-downloads the URLs from DOWNLOAD and DOWNLOAD_x86_64, and updates MD5SUM and MD5SUM_x86_64. Backs up the old file to mypackage.hint.bak first.
+
+URLs set to `UNSUPPORTED` or `UNTESTED` are skipped.
-### Update without downloading
+### Update and add NODOWNLOAD
-Updates the version string and adds `NODOWNLOAD=yes` to skip checksum verification in slackrepo:
+Downloads and recalculates checksums, then appends `NODOWNLOAD=yes` to tell slackrepo to skip checksum verification at build time:
```bash
-mkhint --version 2.0.1 --hintfile mypackage --no-dl
+mkhint -f mypackage -v 2.0.1 -N
+mkhint --hintfile mypackage --version 2.0.1 --no-dl
```
### Create a new hint file from .info
-Generates a new hint file from the corresponding repository .info file:
+Copies the corresponding .info file as a template, removes PRGNAM, HOMEPAGE, MAINTAINER, EMAIL, comments out REQUIRES, and sets ARCH to x86_64. If `-v` is given, the version string is updated and checksums are recalculated:
```bash
-mkhint --version 1.2.3 --new mypackage
+mkhint --new mypackage # copy .info as-is, keep VERSION from .info
+mkhint -n mypackage -v 1.2.3 # copy .info, update version + recalculate md5
+mkhint -n mypackage -v 1.2.3 -N # same, also add NODOWNLOAD=yes
+mkhint -n mypackage -N # copy .info, add NODOWNLOAD=yes, no downloads
```
-The .info file is copied as a template, unwanted variables (PRGNAM, HOMEPAGE, MAINTAINER, EMAIL) are removed, and ARCH is set to x86_64.
-
-### Create a new hint file with NODOWNLOAD
+If no .info file exists in REPO_DIR, a skeleton hint with empty variables is created instead.
-```bash
-mkhint --new mypackage --no-dl
-```
+If the hint file already exists it is backed up and a fresh empty skeleton is written.
-### Create an empty hint file
+### Multiline DOWNLOAD
-Creates a fresh hint file with empty variables:
+Some packages have multiple download URLs on continuation lines:
-```bash
-mkhint --new mypackage
```
+DOWNLOAD="https://example.com/foo-1.0.tar.gz \
+ https://example.com/extra-data.tar.gz"
+MD5SUM="aabbcc... \
+ ddeeff..."
+```
+
+When updating a hint with multiline DOWNLOAD, mkhint:
+
+- Always re-downloads the first URL (version changed → new content)
+- Prompts interactively for each continuation URL — enter a new URL or leave blank to keep the current one
+- Only re-downloads continuation URLs that were changed; unchanged URLs keep their existing md5
### List hint files
```bash
mkhint --list
+mkhint -l
```
### Delete a hint file
@@ -81,7 +96,7 @@ Removes the hint file and its .bak backup if present:
```bash
mkhint --delete mypackage
-mkhint --delete pkg1 pkg2 pkg3
+mkhint -d pkg1 pkg2 pkg3
```
### Clean backup files
@@ -90,34 +105,41 @@ Removes all .bak files from HINT_DIR:
```bash
mkhint --clean
+mkhint -c
```
### Help
```bash
mkhint --help
+mkhint -h
```
## Exit Codes
-- 0 - Success
-- 1 - Invalid arguments
-- 2 - File not found
-- 3 - File already exists
-- 4 - wget not available
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 1 | Invalid arguments |
+| 2 | File not found |
+| 3 | File already exists (unused — backup logic replaces this) |
+| 4 | wget not available |
## Hint File Variables
-- VERSION - Package version
-- ARCH - Architecture
-- DOWNLOAD - Download URL (generic/32-bit)
-- MD5SUM - MD5 checksum of the generic archive
-- DOWNLOAD_x86_64 - Download URL (x86_64 specific)
-- MD5SUM_x86_64 - MD5 checksum of the x86_64 archive
-- NODOWNLOAD - Set to `yes` to skip download/checksum verification in slackrepo
+| Variable | Description |
+|----------|-------------|
+| VERSION | Package version |
+| ARCH | Architecture (x86_64) |
+| DOWNLOAD | Download URL for generic/32-bit build |
+| MD5SUM | MD5 checksum of the generic archive |
+| DOWNLOAD_x86_64 | Download URL for x86_64-specific build |
+| MD5SUM_x86_64 | MD5 checksum of the x86_64 archive |
+| NODOWNLOAD | Set to `yes` to skip download/checksum verification in slackrepo |
## Notes
-- Old versions of hint files are backed up with a .bak extension before being modified.
-- If a download URL is set to UNSUPPORTED or UNTESTED, the link is skipped during update.
-- Bash completion for -f/--hintfile, -n/--new, and -d/--delete autocompletes package names from their respective directories.
+- Hint files are backed up to `.bak` before any modification.
+- If DOWNLOAD or DOWNLOAD_x86_64 is `UNSUPPORTED` or `UNTESTED`, that URL is skipped and its MD5SUM is left unchanged.
+- `--no-dl` / `-N` does **not** skip downloads — it downloads and recalculates checksums as normal, then appends `NODOWNLOAD=yes` to the hint file.
+- Bash completion for `-f`/`--hintfile`, `-n`/`--new`, and `-d`/`--delete` autocompletes package names from their respective directories. Short flags (`-v`, `-f`, `-n`, `-l`, `-c`, `-d`, `-N`, `-h`) are also completed.
diff --git a/mkhint b/mkhint
index 9d307f5..62e6d05 100755
--- a/mkhint
+++ b/mkhint
@@ -218,25 +218,147 @@ add_nodownload() {
fi
}
+# Parse multiline variable value (handles \ continuation lines)
+# Prints each whitespace-separated token on its own line
+parse_multiline_var() {
+ local varname="$1"
+ local file="$2"
+ # Join continuation lines, strip variable name and quotes, print one token per line
+ awk -v var="${varname}" '
+ BEGIN { found=0; buf="" }
+ !found && $0 ~ "^"var"=\"" {
+ found=1
+ buf=$0
+ sub("^"var"=\"", "", buf)
+ if (buf !~ /\\[[:space:]]*$/) {
+ gsub(/"[[:space:]]*$/, "", buf)
+ n=split(buf, arr, /[[:space:]]+/)
+ for (k=1;k<=n;k++) if(arr[k]!="") print arr[k]
+ found=0; buf=""
+ } else {
+ gsub(/\\[[:space:]]*$/, "", buf)
+ }
+ next
+ }
+ found {
+ if ($0 ~ /\\[[:space:]]*$/) {
+ line=$0; gsub(/\\[[:space:]]*$/, "", line)
+ buf=buf" "line
+ } else {
+ line=$0; gsub(/"[[:space:]]*$/, "", line)
+ buf=buf" "line
+ n=split(buf, arr, /[[:space:]]+/)
+ for (k=1;k<=n;k++) if(arr[k]!="") print arr[k]
+ found=0; buf=""
+ }
+ }
+ ' "$file"
+}
+
+# Prompt user for updated continuation URLs; returns updated URLs via nameref array
+# First URL is always kept as-is (already updated by version sed before this call)
+prompt_continuation_urls() {
+ local -n _urls="$1" # nameref: array of current URLs
+ local varname="$2"
+
+ local i
+ for (( i=1; i<${#_urls[@]}; i++ )); do
+ local current="${_urls[$i]}"
+ echo ""
+ echo " ${varname} line $((i+1)) (current): $current"
+ read -r -p " New URL (leave blank to keep): " new_url
+ if [[ -n "$new_url" ]]; then
+ _urls[$i]="$new_url"
+ fi
+ done
+}
+
+# Build multiline variable string for writing back to file
+# Usage: build_multiline_value urls_array -> prints quoted multiline value
+build_multiline_value() {
+ local -n _arr="$1"
+ local count=${#_arr[@]}
+ local i
+ for (( i=0; i<count; i++ )); do
+ if (( i == 0 )); then
+ printf '"%s' "${_arr[$i]}"
+ else
+ printf ' \\\n %s' "${_arr[$i]}"
+ fi
+ done
+ printf '"\n'
+}
+
# Download files and update MD5SUM/MD5SUM_x86_64 in hint file
update_checksums() {
local file="$1"
- local download
- download=$(grep '^DOWNLOAD=' "$file" | sed 's/DOWNLOAD="//;s/"$//')
- if [[ -n "$download" && $download != "UNSUPPORTED" && $download != "UNTESTED" ]]; then
- local sum
- sum=$(download_file "$download")
- sed -i "/^MD5SUM=/s/MD5SUM=\"[^\"]*\"/MD5SUM=\"$sum\"/" "$file"
- fi
+ _process_download_var "DOWNLOAD" "MD5SUM" "$file"
+ _process_download_var "DOWNLOAD_x86_64" "MD5SUM_x86_64" "$file"
+}
- local download_x64
- download_x64=$(grep '^DOWNLOAD_x86_64=' "$file" | sed 's/DOWNLOAD_x86_64="//;s/"$//')
- if [[ -n "$download_x64" && $download_x64 != "UNSUPPORTED" && $download_x64 != "UNTESTED" ]]; then
- local sum64
- sum64=$(download_file "$download_x64")
- sed -i "/^MD5SUM_x86_64=/s/MD5SUM_x86_64=\"[^\"]*\"/MD5SUM_x86_64=\"$sum64\"/" "$file"
+# Process one DOWNLOAD/MD5SUM variable pair in a hint file
+_process_download_var() {
+ local dl_var="$1"
+ local md5_var="$2"
+ local file="$3"
+
+ # Read current URLs into array
+ mapfile -t urls < <(parse_multiline_var "$dl_var" "$file")
+
+ [[ ${#urls[@]} -eq 0 ]] && return
+ [[ "${urls[0]}" == "UNSUPPORTED" || "${urls[0]}" == "UNTESTED" ]] && return
+
+ # Read current md5sums into array (parallel to urls)
+ mapfile -t md5s < <(parse_multiline_var "$md5_var" "$file")
+
+ # Save original URLs for change detection after prompt
+ local orig_urls=("${urls[@]}")
+
+ # Prompt user to update continuation URLs if present
+ if (( ${#urls[@]} > 1 )); then
+ echo ""
+ echo "Multiline ${dl_var} detected in $(basename "$file")."
+ prompt_continuation_urls urls "$dl_var"
fi
+
+ # Download and calculate md5 for each URL
+ local new_md5s=()
+ local i
+ for (( i=0; i<${#urls[@]}; i++ )); do
+ local url="${urls[$i]}"
+ if (( i == 0 )); then
+ # Always re-download first URL
+ echo "Downloading: $url"
+ new_md5s+=( "$(download_file "$url")" )
+ else
+ # Only re-download if URL changed from original
+ if [[ "$url" != "${orig_urls[$i]}" ]]; then
+ echo "Downloading (updated): $url"
+ new_md5s+=( "$(download_file "$url")" )
+ else
+ echo "Keeping existing md5 for: $url"
+ new_md5s+=( "${md5s[$i]}" )
+ fi
+ fi
+ done
+
+ # Rebuild and write back DOWNLOAD variable (may have updated continuation URLs)
+ local new_dl_value
+ new_dl_value=$(build_multiline_value urls)
+ # Strip surrounding quotes — perl wraps them in the substitution
+ new_dl_value="${new_dl_value#\"}"
+ new_dl_value="${new_dl_value%\"}"
+ perl -i -0pe 'BEGIN{$v=shift} s|^'"${dl_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${dl_var}"'="$v"|m' \
+ "$new_dl_value" "$file"
+
+ # Rebuild and write back MD5SUM variable
+ local new_md5_value
+ new_md5_value=$(build_multiline_value new_md5s)
+ new_md5_value="${new_md5_value#\"}"
+ new_md5_value="${new_md5_value%\"}"
+ perl -i -0pe 'BEGIN{$v=shift} s|^'"${md5_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${md5_var}"'="$v"|m' \
+ "$new_md5_value" "$file"
}
# Update existing hint file
diff --git a/tests/mkhint_test.sh b/tests/mkhint_test.sh
new file mode 100755
index 0000000..dbcc86a
--- /dev/null
+++ b/tests/mkhint_test.sh
@@ -0,0 +1,347 @@
+#!/bin/bash
+# mkhint test suite - uses mock dirs, no real downloads
+
+SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/mkhint"
+MOCK_BASE="/tmp/mkhint_test_$$"
+MOCK_REPO="$MOCK_BASE/repo"
+MOCK_HINT="$MOCK_BASE/hints"
+MOCK_TMP="$MOCK_BASE/tmp"
+
+PASS=0
+FAIL=0
+ERRORS=()
+
+setup() {
+ mkdir -p "$MOCK_REPO/network/curl" \
+ "$MOCK_REPO/development/protoc-gen-go-grpc" \
+ "$MOCK_REPO/development/clion" \
+ "$MOCK_HINT" \
+ "$MOCK_TMP"
+
+ # Standard single-URL .info
+ cat > "$MOCK_REPO/network/curl/curl.info" << 'EOF'
+PRGNAM="curl"
+VERSION="8.5.0"
+HOMEPAGE="https://curl.se/"
+DOWNLOAD="https://curl.se/download/curl-8.5.0.tar.gz"
+MD5SUM="abc123def456abc123def456abc123de"
+DOWNLOAD_x86_64=""
+MD5SUM_x86_64=""
+REQUIRES=""
+MAINTAINER="Test"
+EMAIL="test@test.com"
+EOF
+
+ # Multiline DOWNLOAD .info
+ cat > "$MOCK_REPO/development/protoc-gen-go-grpc/protoc-gen-go-grpc.info" << 'EOF'
+PRGNAM="protoc-gen-go-grpc"
+VERSION="1.3.0"
+HOMEPAGE="https://github.com/grpc/grpc-go"
+DOWNLOAD="https://github.com/grpc/grpc-go/archive/refs/tags/cmd/protoc-gen-go-grpc/v1.3.0/grpc-go-cmd-protoc-gen-go-grpc-v1.3.0.tar.gz \
+ https://github.com/protocolbuffers/protobuf-go/archive/v1.28.1/protobuf-go-1.28.1.tar.gz"
+MD5SUM="9d3abc100f411a59907528e55e772a10 \
+ e11cccd452bbf4296f72bf323d7b8690"
+DOWNLOAD_x86_64=""
+MD5SUM_x86_64=""
+REQUIRES="protoc-gen-go"
+MAINTAINER="Test"
+EMAIL="test@test.com"
+EOF
+
+ # DOWNLOAD=UNSUPPORTED, DOWNLOAD_x86_64 has URL
+ cat > "$MOCK_REPO/development/clion/clion.info" << 'EOF'
+PRGNAM="clion"
+VERSION="2025.3"
+HOMEPAGE="https://www.jetbrains.com/clion/"
+DOWNLOAD="UNSUPPORTED"
+MD5SUM=""
+DOWNLOAD_x86_64="https://download.jetbrains.com/cpp/CLion-2025.3.tar.gz"
+MD5SUM_x86_64="dff91fe793b8d3ee2446dd340288eef5"
+REQUIRES=""
+MAINTAINER="Test"
+EMAIL="test@test.com"
+EOF
+}
+
+teardown() {
+ rm -rf "$MOCK_BASE"
+}
+
+run_mkhint() {
+ local tmp_script
+ tmp_script=$(mktemp /tmp/mkhint_patched_XXXXXX)
+ sed \
+ -e "s|REPO_DIR=\".*\"|REPO_DIR=\"$MOCK_REPO\"|" \
+ -e "s|HINT_DIR=\".*\"|HINT_DIR=\"$MOCK_HINT\"|" \
+ -e "s|TMP_DIR=\".*\"|TMP_DIR=\"$MOCK_TMP\"|" \
+ "$SCRIPT" > "$tmp_script"
+ bash "$tmp_script" "$@"
+ local rc=$?
+ rm -f "$tmp_script"
+ return $rc
+}
+
+# Mock wget — writes fake content, md5 will be deterministic
+mock_wget() {
+ # Replace wget in PATH with a fake that writes URL as content
+ mkdir -p "$MOCK_BASE/bin"
+ cat > "$MOCK_BASE/bin/wget" << 'EOF'
+#!/bin/bash
+# fake wget: write URL to -O target
+url=""
+out=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -O) out="$2"; shift 2 ;;
+ *) url="$1"; shift ;;
+ esac
+done
+echo "FAKE_CONTENT_FOR_${url}" > "$out"
+exit 0
+EOF
+ chmod +x "$MOCK_BASE/bin/wget"
+ export PATH="$MOCK_BASE/bin:$PATH"
+}
+
+assert_contains() {
+ local desc="$1" file="$2" pattern="$3"
+ if grep -q "$pattern" "$file" 2>/dev/null; then
+ echo " PASS: $desc"
+ (( PASS++ ))
+ else
+ echo " FAIL: $desc"
+ echo " expected pattern: $pattern"
+ echo " file contents:"
+ cat "$file" 2>/dev/null | sed 's/^/ /'
+ (( FAIL++ ))
+ ERRORS+=("$desc")
+ fi
+}
+
+assert_not_contains() {
+ local desc="$1" file="$2" pattern="$3"
+ if ! grep -q "$pattern" "$file" 2>/dev/null; then
+ echo " PASS: $desc"
+ (( PASS++ ))
+ else
+ echo " FAIL: $desc"
+ echo " unexpected pattern found: $pattern"
+ (( FAIL++ ))
+ ERRORS+=("$desc")
+ fi
+}
+
+assert_file_exists() {
+ local desc="$1" file="$2"
+ if [[ -f "$file" ]]; then
+ echo " PASS: $desc"
+ (( PASS++ ))
+ else
+ echo " FAIL: $desc (file not found: $file)"
+ (( FAIL++ ))
+ ERRORS+=("$desc")
+ fi
+}
+
+assert_file_not_exists() {
+ local desc="$1" file="$2"
+ if [[ ! -f "$file" ]]; then
+ echo " PASS: $desc"
+ (( PASS++ ))
+ else
+ echo " FAIL: $desc (file should not exist: $file)"
+ (( FAIL++ ))
+ ERRORS+=("$desc")
+ fi
+}
+
+assert_exit_code() {
+ local desc="$1" expected="$2" actual="$3"
+ if [[ "$actual" -eq "$expected" ]]; then
+ echo " PASS: $desc (exit $actual)"
+ (( PASS++ ))
+ else
+ echo " FAIL: $desc (expected exit $expected, got $actual)"
+ (( FAIL++ ))
+ ERRORS+=("$desc")
+ fi
+}
+
+# ─── TESTS ────────────────────────────────────────────────────────────────────
+
+echo "========================================"
+echo " mkhint test suite"
+echo "========================================"
+
+setup
+mock_wget
+
+# ── T1: --new from .info, no version ──────────────────────────────────────────
+echo ""
+echo "T1: --new from .info template, no version"
+run_mkhint -n curl
+assert_file_exists "hint file created" "$MOCK_HINT/curl.hint"
+assert_contains "VERSION from .info" "$MOCK_HINT/curl.hint" 'VERSION="8.5.0"'
+assert_not_contains "no PRGNAM" "$MOCK_HINT/curl.hint" '^PRGNAM='
+assert_not_contains "no HOMEPAGE" "$MOCK_HINT/curl.hint" '^HOMEPAGE='
+assert_not_contains "no MAINTAINER" "$MOCK_HINT/curl.hint" '^MAINTAINER='
+assert_contains "ARCH set x86_64" "$MOCK_HINT/curl.hint" 'ARCH="x86_64"'
+assert_contains "REQUIRES commented out" "$MOCK_HINT/curl.hint" '#REQUIRES='
+assert_not_contains "no NODOWNLOAD" "$MOCK_HINT/curl.hint" 'NODOWNLOAD'
+
+# ── T2: --new from .info with version → updates version + md5 ─────────────────
+echo ""
+echo "T2: --new from .info with -v → version set, md5 recalculated"
+rm "$MOCK_HINT/curl.hint"
+run_mkhint -n curl -v 8.6.0
+assert_contains "VERSION updated" "$MOCK_HINT/curl.hint" 'VERSION="8.6.0"'
+assert_contains "URL has new version" "$MOCK_HINT/curl.hint" 'curl-8.6.0'
+# md5 should not be the original (was recalculated via mock wget)
+assert_not_contains "MD5SUM not original" "$MOCK_HINT/curl.hint" 'MD5SUM="abc123def456'
+
+# ── T3: --new with -N, no version → NODOWNLOAD added, no downloads ────────────
+echo ""
+echo "T3: --new -N no version → NODOWNLOAD=yes, no download"
+rm "$MOCK_HINT/curl.hint"
+run_mkhint -n curl -N
+assert_contains "NODOWNLOAD present" "$MOCK_HINT/curl.hint" 'NODOWNLOAD=yes'
+
+# ── T4: --new with -v -N → version set, downloads run, NODOWNLOAD added ───────
+echo ""
+echo "T4: --new -v -N → version + md5 updated + NODOWNLOAD=yes"
+rm "$MOCK_HINT/curl.hint"
+run_mkhint -n curl -v 8.7.0 -N
+assert_contains "VERSION updated" "$MOCK_HINT/curl.hint" 'VERSION="8.7.0"'
+assert_not_contains "MD5SUM not original" "$MOCK_HINT/curl.hint" 'MD5SUM="abc123def456'
+assert_contains "NODOWNLOAD present" "$MOCK_HINT/curl.hint" 'NODOWNLOAD=yes'
+
+# ── T5: --new when hint already exists → backup + empty skeleton ───────────────
+echo ""
+echo "T5: --new when hint exists → backup + empty skeleton"
+run_mkhint -n curl # creates again (hint already gone from T4 rm... wait, we didn't rm)
+assert_file_exists "backup created" "$MOCK_HINT/curl.hint.bak"
+assert_contains "skeleton VERSION" "$MOCK_HINT/curl.hint" 'VERSION='
+assert_contains "skeleton DOWNLOAD" "$MOCK_HINT/curl.hint" 'DOWNLOAD=""'
+
+# ── T6: --hintfile update single URL ──────────────────────────────────────────
+echo ""
+echo "T6: --hintfile -v update single URL → version + md5 updated"
+# Prepare a hint to update
+cat > "$MOCK_HINT/curl.hint" << 'EOF'
+VERSION="8.5.0"
+ARCH="x86_64"
+DOWNLOAD="https://curl.se/download/curl-8.5.0.tar.gz"
+MD5SUM="abc123def456abc123def456abc123de"
+DOWNLOAD_x86_64=""
+MD5SUM_x86_64=""
+EOF
+run_mkhint -f curl -v 8.9.0
+assert_contains "VERSION updated" "$MOCK_HINT/curl.hint" 'VERSION="8.9.0"'
+assert_contains "URL updated" "$MOCK_HINT/curl.hint" 'curl-8.9.0'
+assert_not_contains "MD5SUM updated" "$MOCK_HINT/curl.hint" 'abc123def456'
+assert_file_exists "backup created" "$MOCK_HINT/curl.hint.bak"
+
+# ── T7: --hintfile -v -N → version + md5 updated + NODOWNLOAD ─────────────────
+echo ""
+echo "T7: --hintfile -v -N → version + md5 + NODOWNLOAD=yes"
+cat > "$MOCK_HINT/curl.hint" << 'EOF'
+VERSION="8.5.0"
+ARCH="x86_64"
+DOWNLOAD="https://curl.se/download/curl-8.5.0.tar.gz"
+MD5SUM="abc123def456abc123def456abc123de"
+DOWNLOAD_x86_64=""
+MD5SUM_x86_64=""
+EOF
+run_mkhint -f curl -v 9.0.0 -N
+assert_contains "VERSION updated" "$MOCK_HINT/curl.hint" 'VERSION="9.0.0"'
+assert_not_contains "MD5SUM recalculated" "$MOCK_HINT/curl.hint" 'abc123def456'
+assert_contains "NODOWNLOAD present" "$MOCK_HINT/curl.hint" 'NODOWNLOAD=yes'
+
+# ── T8: DOWNLOAD=UNSUPPORTED, DOWNLOAD_x86_64 has URL ─────────────────────────
+echo ""
+echo "T8: DOWNLOAD=UNSUPPORTED → skip 32bit, recalc x86_64 md5"
+run_mkhint -n clion
+assert_contains "DOWNLOAD UNSUPPORTED kept" "$MOCK_HINT/clion.hint" 'DOWNLOAD="UNSUPPORTED"'
+
+# Update it
+run_mkhint -f clion -v 2025.4
+assert_contains "VERSION updated" "$MOCK_HINT/clion.hint" 'VERSION="2025.4"'
+assert_contains "DOWNLOAD still UNSUPPORTED" "$MOCK_HINT/clion.hint" 'DOWNLOAD="UNSUPPORTED"'
+assert_not_contains "x86_64 md5 updated" "$MOCK_HINT/clion.hint" 'dff91fe793b8d3ee2446dd340288eef5'
+
+# ── T9: --no-dl alone → error exit 1 ──────────────────────────────────────────
+echo ""
+echo "T9: --no-dl alone → exit 1"
+set +e
+run_mkhint -N 2>/dev/null
+code=$?
+set -e
+assert_exit_code "-N alone exits 1" 1 "$code"
+
+# ── T10: --hintfile missing file → exit 2 ─────────────────────────────────────
+echo ""
+echo "T10: --hintfile on nonexistent file → exit 2"
+set +e
+run_mkhint -f nonexistent -v 1.0 2>/dev/null
+code=$?
+set -e
+assert_exit_code "missing hintfile exits 2" 2 "$code"
+
+# ── T11: --delete existing hint ───────────────────────────────────────────────
+echo ""
+echo "T11: --delete removes hint and .bak"
+touch "$MOCK_HINT/curl.hint" "$MOCK_HINT/curl.hint.bak"
+run_mkhint -d curl
+assert_file_not_exists "hint deleted" "$MOCK_HINT/curl.hint"
+assert_file_not_exists "bak deleted" "$MOCK_HINT/curl.hint.bak"
+
+# ── T12: --delete nonexistent → exit 2 ────────────────────────────────────────
+echo ""
+echo "T12: --delete nonexistent → exit 2"
+set +e
+run_mkhint -d ghost_package 2>/dev/null
+code=$?
+set -e
+assert_exit_code "delete missing exits 2" 2 "$code"
+
+# ── T13: --new multiline hint, no version ─────────────────────────────────────
+echo ""
+echo "T13: --new multiline .info, no version → template copied, no md5 update"
+run_mkhint -n protoc-gen-go-grpc
+assert_file_exists "hint created" "$MOCK_HINT/protoc-gen-go-grpc.hint"
+assert_contains "first URL present" "$MOCK_HINT/protoc-gen-go-grpc.hint" 'grpc-go-cmd'
+assert_contains "second URL present" "$MOCK_HINT/protoc-gen-go-grpc.hint" 'protobuf-go'
+# md5s should be original (no version → no download)
+assert_contains "original md5 kept" "$MOCK_HINT/protoc-gen-go-grpc.hint" '9d3abc100f411a59907528e55e772a10'
+
+# ── T14: --new multiline .info, with version → first md5 recalculated ─────────
+echo ""
+echo "T14: --new multiline .info -v → first URL+md5 updated, second md5 kept (no prompt in test)"
+rm "$MOCK_HINT/protoc-gen-go-grpc.hint"
+# Pipe empty input so read -r gets blank (keep continuation URL)
+echo "" | run_mkhint -n protoc-gen-go-grpc -v 1.4.0
+assert_contains "VERSION updated" "$MOCK_HINT/protoc-gen-go-grpc.hint" 'VERSION="1.4.0"'
+assert_contains "first URL has new ver" "$MOCK_HINT/protoc-gen-go-grpc.hint" 'v1.4.0'
+assert_not_contains "first md5 recalculated" "$MOCK_HINT/protoc-gen-go-grpc.hint" '9d3abc100f411a59907528e55e772a10'
+assert_contains "second md5 unchanged" "$MOCK_HINT/protoc-gen-go-grpc.hint" 'e11cccd452bbf4296f72bf323d7b8690'
+
+# ── T15: --clean removes .bak files ───────────────────────────────────────────
+echo ""
+echo "T15: --clean removes all .bak files"
+touch "$MOCK_HINT/a.hint.bak" "$MOCK_HINT/b.hint.bak"
+run_mkhint -c
+assert_file_not_exists "a.bak removed" "$MOCK_HINT/a.hint.bak"
+assert_file_not_exists "b.bak removed" "$MOCK_HINT/b.hint.bak"
+
+# ─── SUMMARY ──────────────────────────────────────────────────────────────────
+teardown
+
+echo ""
+echo "========================================"
+echo " Results: $PASS passed, $FAIL failed"
+if [[ ${#ERRORS[@]} -gt 0 ]]; then
+ echo " Failed tests:"
+ for e in "${ERRORS[@]}"; do echo " - $e"; done
+fi
+echo "========================================"
+[[ $FAIL -eq 0 ]]