diff --git a/flake.nix b/flake.nix index 5d261e5..7396ebe 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,17 @@ text = builtins.readFile ./gitlab-job-status; }; + + migrate-workspace-deps = pkgs.writeShellApplication { + name = "migrate-workspace-deps"; + runtimeInputs = [ + pkgs.jq + pkgs.git + pkgs.yq + ]; + + text = builtins.readFile ./migrate-workspace-deps; + }; }); }; } diff --git a/gitlab-job-status b/gitlab-job-status index 71945db..d11b318 100755 --- a/gitlab-job-status +++ b/gitlab-job-status @@ -1,5 +1,4 @@ #!/usr/bin/env bash - set -euo pipefail # Configuration diff --git a/migrate-workspace-deps b/migrate-workspace-deps new file mode 100644 index 0000000..05d2143 --- /dev/null +++ b/migrate-workspace-deps @@ -0,0 +1,511 @@ +#!/usr/bin/env bash +set -uo pipefail + +# Migrate Cargo.toml dependencies to workspace-level declarations +# Only modifies dependency sections - preserves all other formatting + +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' + +SKIP_GIT_CHECK=false +SKIP_DIRTY_CHECK=false + +warn() { echo -e "${YELLOW}warning:${NC} $*" >&2; } +error() { echo -e "${RED}error:${NC} $*" >&2; } +success() { echo -e "${GREEN}$*${NC}"; } + +usage() { + echo "Usage: $0 [--no-git] [--allow-dirty]" + echo " --no-git Skip git repository check" + echo " --allow-dirty Allow dirty working directory" + exit 0 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --no-git) SKIP_GIT_CHECK=true; shift ;; + --allow-dirty) SKIP_DIRTY_CHECK=true; shift ;; + -h|--help) usage ;; + *) error "Unknown option: $1"; exit 1 ;; + esac +done + +# Check required tools +for tool in tomlq jq git; do + command -v "$tool" &>/dev/null || { error "Required tool '$tool' not found"; exit 1; } +done + +# Find workspace root +find_workspace_root() { + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/Cargo.toml" ]] && tomlq -e '.workspace' "$dir/Cargo.toml" &>/dev/null; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + error "No workspace root found (Cargo.toml with [workspace] section)" + exit 1 +} + +WORKSPACE_ROOT="$(find_workspace_root)" +echo "Workspace root: $WORKSPACE_ROOT" +cd "$WORKSPACE_ROOT" || exit 1 + +# Git checks +if [[ "$SKIP_GIT_CHECK" == false ]]; then + git rev-parse --git-dir &>/dev/null || { error "Not a git repo. Use --no-git to skip."; exit 1; } + if [[ "$SKIP_DIRTY_CHECK" == false ]]; then + if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then + error "Dirty working directory. Use --allow-dirty to skip." + exit 1 + fi + fi +fi + +# Temp files +COLLECTED=$(mktemp) +SKIPPED=$(mktemp) +MODIFIED_FILES=$(mktemp) +trap 'rm -f "$COLLECTED" "$SKIPPED" "$MODIFIED_FILES"' EXIT + +# Existing workspace deps +EXISTING_WS_DEPS=$(tomlq -r '.workspace.dependencies // {} | keys[]' Cargo.toml 2>/dev/null || true) + +# Version comparison +compare_versions() { + local v1="$1" v2="$2" + local v1_maj v1_min v1_pat v2_maj v2_min v2_pat + + IFS='.' read -r v1_maj v1_min v1_pat <<< "$v1" + IFS='.' read -r v2_maj v2_min v2_pat <<< "$v2" + v1_min="${v1_min:-0}"; v1_pat="${v1_pat:-0}" + v2_min="${v2_min:-0}"; v2_pat="${v2_pat:-0}" + + if [[ "$v1_maj" == "0" || "$v2_maj" == "0" ]]; then + [[ "$v1_maj" == "$v2_maj" && "$v1_min" == "$v2_min" ]] || return 1 + [[ "$v1_pat" -ge "$v2_pat" ]] && echo "$v1" || echo "$v2" + return 0 + fi + + [[ "$v1_maj" == "$v2_maj" ]] || return 1 + + if [[ "$v1_min" -gt "$v2_min" ]]; then echo "$v1" + elif [[ "$v1_min" -lt "$v2_min" ]]; then echo "$v2" + elif [[ "$v1_pat" -ge "$v2_pat" ]]; then echo "$v1" + else echo "$v2" + fi +} + +is_simple_version() { + [[ "$1" =~ ^[\^=]?[0-9]+(\.[0-9]+)*$ ]] +} + +normalize_version() { + local v="$1" + v="${v#^}"; v="${v#=}" + echo "$v" +} + +list_crate_tomls() { + tomlq -r '.workspace.members // [] | .[]' Cargo.toml 2>/dev/null | while read -r pattern; do + for dir in $pattern; do + [[ -f "$dir/Cargo.toml" ]] && echo "$dir/Cargo.toml" + done + done +} + +# Process one dependency (collection phase - read only) +process_dep() { + local crate_toml="$1" dep_name="$2" dep_json="$3" + + echo "$dep_json" | jq -e '.workspace == true' &>/dev/null && return 0 + echo "$EXISTING_WS_DEPS" | grep -qxF "$dep_name" && return 0 + + local version="" path="" git="" branch="" tag="" rev="" + + if echo "$dep_json" | jq -e 'type == "string"' &>/dev/null; then + version=$(echo "$dep_json" | jq -r '.') + else + version=$(echo "$dep_json" | jq -r '.version // empty') + path=$(echo "$dep_json" | jq -r '.path // empty') + git=$(echo "$dep_json" | jq -r '.git // empty') + branch=$(echo "$dep_json" | jq -r '.branch // empty') + tag=$(echo "$dep_json" | jq -r '.tag // empty') + rev=$(echo "$dep_json" | jq -r '.rev // empty') + fi + + # Check for package rename + local package="" + if ! echo "$dep_json" | jq -e 'type == "string"' &>/dev/null; then + package=$(echo "$dep_json" | jq -r '.package // empty') + fi + + local ws_value="" ws_type="" + + if [[ -n "$path" ]]; then + local crate_dir abs_path rel_path + crate_dir=$(dirname "$crate_toml") + abs_path=$(cd "$crate_dir" && cd "$path" && pwd) + rel_path=$(realpath --relative-to="$WORKSPACE_ROOT" "$abs_path") + ws_value="{ path = \"$rel_path\"" + [[ -n "$package" ]] && ws_value="$ws_value, package = \"$package\"" + ws_value="$ws_value }" + ws_type="path" + elif [[ -n "$git" ]]; then + ws_value="{ git = \"$git\"" + [[ -n "$branch" ]] && ws_value="$ws_value, branch = \"$branch\"" + [[ -n "$tag" ]] && ws_value="$ws_value, tag = \"$tag\"" + [[ -n "$rev" ]] && ws_value="$ws_value, rev = \"$rev\"" + [[ -n "$package" ]] && ws_value="$ws_value, package = \"$package\"" + ws_value="$ws_value }" + ws_type="git" + elif [[ -n "$version" ]]; then + if ! is_simple_version "$version"; then + warn "skipping '$dep_name' in $crate_toml: complex version '$version'" + echo "$dep_name" >> "$SKIPPED" + return 0 + fi + version=$(normalize_version "$version") + if [[ -n "$package" ]]; then + ws_value="{ version = \"$version\", package = \"$package\" }" + else + ws_value="\"$version\"" + fi + ws_type="version" + else + warn "skipping '$dep_name' in $crate_toml: no version/path/git" + return 0 + fi + + local existing + existing=$(grep "^${dep_name} " "$COLLECTED" 2>/dev/null || true) + + if [[ -n "$existing" ]]; then + local existing_value existing_type + existing_value=$(echo "$existing" | cut -f2) + existing_type=$(echo "$existing" | cut -f3) + + if [[ "$ws_type" == "version" && "$existing_type" == "version" ]]; then + local existing_ver new_ver unified_ver + existing_ver=$(echo "$existing_value" | tr -d '"') + new_ver="$version" + + if unified_ver=$(compare_versions "$existing_ver" "$new_ver"); then + ws_value="\"$unified_ver\"" + grep -v "^${dep_name} " "$COLLECTED" > "$COLLECTED.tmp" || true + mv "$COLLECTED.tmp" "$COLLECTED" + printf '%s\t%s\t%s\n' "$dep_name" "$ws_value" "$ws_type" >> "$COLLECTED" + else + warn "skipping '$dep_name': incompatible versions '$existing_ver' vs '$new_ver'" + echo "$dep_name" >> "$SKIPPED" + fi + return 0 + fi + + if [[ "$ws_value" != "$existing_value" ]]; then + warn "skipping '$dep_name': conflicting definitions" + echo "$dep_name" >> "$SKIPPED" + fi + return 0 + fi + + printf '%s\t%s\t%s\n' "$dep_name" "$ws_value" "$ws_type" >> "$COLLECTED" +} + +process_section() { + local crate_toml="$1" section="$2" + local deps_json + deps_json=$(tomlq ".\"$section\" // {}" "$crate_toml" 2>/dev/null) + + echo "$deps_json" | jq -r 'keys[]' 2>/dev/null | while read -r dep_name; do + [[ -z "$dep_name" ]] && continue + local dep_json + dep_json=$(echo "$deps_json" | jq ".\"$dep_name\"") + process_dep "$crate_toml" "$dep_name" "$dep_json" + done +} + +check_target_deps() { + local crate_toml="$1" + local targets + targets=$(tomlq -r '.target // {} | keys[]' "$crate_toml" 2>/dev/null || true) + + for target in $targets; do + [[ -z "$target" ]] && continue + local deps + deps=$(tomlq -r ".target.\"$target\" | (.dependencies // {}) + (.[\"dev-dependencies\"] // {}) + (.[\"build-dependencies\"] // {}) | keys[]" "$crate_toml" 2>/dev/null || true) + for dep in $deps; do + [[ -n "$dep" ]] && warn "skipping target-specific '$dep' in $crate_toml [$target]" + done + done +} + +# ============================================================ +# SURGICAL EDITING FUNCTIONS +# ============================================================ + +# Append deps to [workspace.dependencies] section +append_workspace_deps() { + local ws_toml="$1" + shift + local deps_to_add=("$@") + + [[ ${#deps_to_add[@]} -eq 0 ]] && return 0 + + # Check if [workspace.dependencies] section exists + if ! grep -q '^\[workspace\.dependencies\]' "$ws_toml"; then + echo "" >> "$ws_toml" + echo "[workspace.dependencies]" >> "$ws_toml" + fi + + # Find line number of [workspace.dependencies] + local section_line + section_line=$(grep -n '^\[workspace\.dependencies\]' "$ws_toml" | cut -d: -f1) + + # Find the next section (or end of file) + local next_section_line + next_section_line=$(tail -n +"$((section_line + 1))" "$ws_toml" | grep -n '^\[' | head -1 | cut -d: -f1) + + local insert_line + if [[ -n "$next_section_line" ]]; then + insert_line=$((section_line + next_section_line - 1)) + else + insert_line=$(wc -l < "$ws_toml") + fi + + # Build the text to insert + local insert_text="" + for dep_entry in "${deps_to_add[@]}"; do + insert_text="${insert_text}${dep_entry}"$'\n' + done + + # Insert using head/tail + if [[ -n "$insert_text" ]]; then + head -n "$insert_line" "$ws_toml" > "$ws_toml.tmp" + printf '%s' "$insert_text" >> "$ws_toml.tmp" + tail -n +"$((insert_line + 1))" "$ws_toml" >> "$ws_toml.tmp" 2>/dev/null || true + mv "$ws_toml.tmp" "$ws_toml" + echo "$ws_toml" >> "$MODIFIED_FILES" + fi +} + +# Replace a dependency line in a crate Cargo.toml +replace_dep_with_workspace() { + local crate_toml="$1" + local dep_name="$2" + local dep_json="$3" + local section="$4" + + # Get preserved attributes + local features="" optional="" default_features="" + + if ! echo "$dep_json" | jq -e 'type == "string"' &>/dev/null; then + if echo "$dep_json" | jq -e '.features' &>/dev/null; then + features=$(echo "$dep_json" | jq -c '.features' | sed 's/,/, /g') + fi + echo "$dep_json" | jq -e '.optional == true' &>/dev/null && optional="true" + echo "$dep_json" | jq -e '.["default-features"] == false' &>/dev/null && default_features="false" + fi + + # Build new value + local new_value="{ workspace = true" + [[ -n "$features" ]] && new_value="$new_value, features = $features" + [[ -n "$optional" ]] && new_value="$new_value, optional = true" + [[ -n "$default_features" ]] && new_value="$new_value, default-features = false" + new_value="$new_value }" + + # Escape dep_name for regex + local dep_name_escaped + # shellcheck disable=SC2016 # Single quotes intentional - literal regex pattern + dep_name_escaped=$(printf '%s' "$dep_name" | sed 's/[][\.*^$()+?{}|]/\\&/g') + + # Also create a pattern for quoted keys: "dep-name" or 'dep-name' + local dep_pattern="${dep_name_escaped}" + local dep_pattern_quoted="[\"']${dep_name_escaped}[\"']" + + # Pattern 1: Simple string - dep = "version" (with optional quotes around key) + if grep -qE "^${dep_pattern}[[:space:]]*=[[:space:]]*\"" "$crate_toml"; then + sed -i "s/^${dep_pattern}[[:space:]]*=[[:space:]]*\"[^\"]*\"/${dep_name} = ${new_value}/" "$crate_toml" + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + if grep -qE "^${dep_pattern_quoted}[[:space:]]*=[[:space:]]*\"" "$crate_toml"; then + sed -i "s/^${dep_pattern_quoted}[[:space:]]*=[[:space:]]*\"[^\"]*\"/${dep_name} = ${new_value}/" "$crate_toml" + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + + # Pattern 2: Inline table - dep = { ... } (with optional quotes around key) + if grep -qE "^${dep_pattern}[[:space:]]*=[[:space:]]*\{" "$crate_toml"; then + sed -i "s/^${dep_pattern}[[:space:]]*=[[:space:]]*{[^}]*}/${dep_name} = ${new_value}/" "$crate_toml" + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + if grep -qE "^${dep_pattern_quoted}[[:space:]]*=[[:space:]]*\{" "$crate_toml"; then + sed -i "s/^${dep_pattern_quoted}[[:space:]]*=[[:space:]]*{[^}]*}/${dep_name} = ${new_value}/" "$crate_toml" + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + + # Pattern 3: Multi-line table [section.dep_name] + local section_patterns=("dependencies" "dev-dependencies" "build-dependencies") + for sec in "${section_patterns[@]}"; do + local table_header_escaped + # shellcheck disable=SC2016 # Single quotes intentional - literal regex pattern + table_header_escaped=$(printf '%s' "[$sec.$dep_name]" | sed 's/[][\.*^$()+?{}|]/\\&/g') + + if grep -q "^${table_header_escaped}$" "$crate_toml"; then + local start_line end_line + start_line=$(grep -n "^${table_header_escaped}$" "$crate_toml" | cut -d: -f1) + + end_line=$(tail -n +"$((start_line + 1))" "$crate_toml" | grep -n '^\[' | head -1 | cut -d: -f1) + + if [[ -n "$end_line" ]]; then + end_line=$((start_line + end_line - 1)) + else + end_line=$(wc -l < "$crate_toml") + fi + + # Step 1: Delete the multi-line table entirely + head -n "$((start_line - 1))" "$crate_toml" > "$crate_toml.tmp" + tail -n +"$((end_line + 1))" "$crate_toml" >> "$crate_toml.tmp" 2>/dev/null || true + mv "$crate_toml.tmp" "$crate_toml" + + # Step 2: Find or create [section] and insert inline dep there + local sec_escaped + # shellcheck disable=SC2016 # Single quotes intentional - literal regex pattern + sec_escaped=$(printf '%s' "[$sec]" | sed 's/[][\.*^$()+?{}|]/\\&/g') + + if grep -q "^${sec_escaped}$" "$crate_toml"; then + # Insert right after [section] header + local sec_line + sec_line=$(grep -n "^${sec_escaped}$" "$crate_toml" | cut -d: -f1) + head -n "$sec_line" "$crate_toml" > "$crate_toml.tmp" + echo "${dep_name} = ${new_value}" >> "$crate_toml.tmp" + tail -n +"$((sec_line + 1))" "$crate_toml" >> "$crate_toml.tmp" 2>/dev/null || true + mv "$crate_toml.tmp" "$crate_toml" + else + # Section doesn't exist, create it at end of file + { + echo "" + echo "[$sec]" + echo "${dep_name} = ${new_value}" + } >> "$crate_toml" + fi + + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + done + + # Pattern 4: Dotted keys - dep.version = "...", dep.path = "...", etc. + if grep -qE "^${dep_name_escaped}\." "$crate_toml"; then + # Delete all lines starting with dep_name. + sed -i "/^${dep_name_escaped}\./d" "$crate_toml" + + # Find or create [section] and insert inline dep there + local sec_escaped + # shellcheck disable=SC2016 # Single quotes intentional - literal regex pattern + sec_escaped=$(printf '%s' "[$section]" | sed 's/[][\.*^$()+?{}|]/\\&/g') + + if grep -q "^${sec_escaped}$" "$crate_toml"; then + # Insert right after [section] header + local sec_line + sec_line=$(grep -n "^${sec_escaped}$" "$crate_toml" | cut -d: -f1) + head -n "$sec_line" "$crate_toml" > "$crate_toml.tmp" + echo "${dep_name} = ${new_value}" >> "$crate_toml.tmp" + tail -n +"$((sec_line + 1))" "$crate_toml" >> "$crate_toml.tmp" 2>/dev/null || true + mv "$crate_toml.tmp" "$crate_toml" + else + # Section doesn't exist, create it at end of file + { + echo "" + echo "[$section]" + echo "${dep_name} = ${new_value}" + } >> "$crate_toml" + fi + + echo "$crate_toml" >> "$MODIFIED_FILES" + return 0 + fi + + warn "Could not locate '$dep_name' in $crate_toml for replacement" +} + +# ============================================================ +# MAIN LOGIC +# ============================================================ + +echo "Scanning crates..." + +while read -r crate_toml; do + [[ -z "$crate_toml" ]] && continue + echo " $crate_toml" + + # Check if tomlq can parse the file (fails on TOML 1.1 features like multi-line inline tables) + if ! tomlq '.' "$crate_toml" &>/dev/null; then + warn "could not fully parse $crate_toml (may contain TOML 1.1 features like multi-line inline tables)" + fi + + process_section "$crate_toml" "dependencies" + process_section "$crate_toml" "dev-dependencies" + process_section "$crate_toml" "build-dependencies" + check_target_deps "$crate_toml" +done < <(list_crate_tomls) + +SKIP_LIST=$(sort -u "$SKIPPED" 2>/dev/null || true) + +echo "" +echo "Adding to workspace Cargo.toml..." + +# Build array of deps to add +declare -a ws_deps_to_add=() + +while IFS=$'\t' read -r dep_name ws_value ws_type; do + [[ -z "$dep_name" ]] && continue + echo "$SKIP_LIST" | grep -qxF "$dep_name" 2>/dev/null && continue + + echo " $dep_name = $ws_value" + ws_deps_to_add+=("${dep_name} = ${ws_value}") +done < "$COLLECTED" + +append_workspace_deps "Cargo.toml" "${ws_deps_to_add[@]}" + +echo "" +echo "Updating crate Cargo.toml files..." + +update_crate_section() { + local crate_toml="$1" section="$2" + local deps_json + deps_json=$(tomlq ".\"$section\" // {}" "$crate_toml" 2>/dev/null) + + echo "$deps_json" | jq -r 'keys[]' 2>/dev/null | while read -r dep_name; do + [[ -z "$dep_name" ]] && continue + echo "$SKIP_LIST" | grep -qxF "$dep_name" 2>/dev/null && continue + + local dep_json + dep_json=$(echo "$deps_json" | jq ".\"$dep_name\"") + + echo "$dep_json" | jq -e '.workspace == true' &>/dev/null && continue + + grep -q "^${dep_name} " "$COLLECTED" 2>/dev/null || \ + echo "$EXISTING_WS_DEPS" | grep -qxF "$dep_name" || continue + + replace_dep_with_workspace "$crate_toml" "$dep_name" "$dep_json" "$section" + done +} + +while read -r crate_toml; do + [[ -z "$crate_toml" ]] && continue + echo " $crate_toml" + update_crate_section "$crate_toml" "dependencies" + update_crate_section "$crate_toml" "dev-dependencies" + update_crate_section "$crate_toml" "build-dependencies" +done < <(list_crate_tomls) + +echo "" +success "Done! Run 'cargo check' to verify."