rust-switcher 1.0.9

Windows keyboard layout switcher and text conversion utility
Documentation
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

function Assert-Tool {
  param([Parameter(Mandatory = $true)][string]$Name)
  if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
    throw "Required tool not found in PATH: $Name"
  }
}

function Invoke-Checked {
  param(
    [Parameter(Mandatory = $true)][string]$Exe,
    [Parameter(Mandatory = $true)][string[]]$Args,
    [switch]$AllowFailure,
    [switch]$Quiet
  )

  $savedErrorActionPreference = $ErrorActionPreference
  if ($AllowFailure) {
    $ErrorActionPreference = 'Continue'
  }

  try {
    $out = & $Exe @Args 2>&1
    $code = $LASTEXITCODE
  }
  finally {
    $ErrorActionPreference = $savedErrorActionPreference
  }

  if (-not $Quiet) {
    if ($out) { $out | ForEach-Object { Write-Host $_ } }
  }

  if (-not $AllowFailure -and $code -ne 0) {
    $text = if ($out) { ($out -join "`n") } else { "" }
    throw ("Command failed (exit {0}): {1} {2}`n{3}" -f $code, $Exe, ($Args -join ' '), $text)
  }

  return @{
    ExitCode = $code
    Output   = if ($out) { ($out -join "`n") } else { "" }
  }
}

function Get-RepoRoot {
  $r = Invoke-Checked git @('rev-parse', '--show-toplevel') -Quiet
  return $r.Output.Trim()
}

function Get-CurrentBranch {
  $r = Invoke-Checked git @('branch', '--show-current') -Quiet
  return $r.Output.Trim()
}

function Assert-OnBranch {
  param([Parameter(Mandatory = $true)][string]$Expected)
  $cur = Get-CurrentBranch
  if ($cur -ne $Expected) {
    throw "This command must be run on branch '$Expected' (current: '$cur')."
  }
}

function Assert-CleanWorktree {
  $r = Invoke-Checked git @('status', '--porcelain') -Quiet
  $s = $r.Output.Trim()
  if ($s) {
    throw "Worktree is dirty. Commit or stash changes first.`n$s"
  }
}

function Assert-SemVer {
  param([Parameter(Mandatory = $true)][string]$Version)
  $v = ($Version ?? '').Trim()
  if ($v -notmatch '^\d+\.\d+\.\d+$') {
    throw "Invalid VERSION '$Version'. Expected strict SemVer: X.Y.Z"
  }
  return $v
}

function Parse-SemVer {
  param([Parameter(Mandatory = $true)][string]$Version)
  $v = Assert-SemVer $Version
  $p = $v.Split('.')
  return @([int]$p[0], [int]$p[1], [int]$p[2])
}

function Compare-SemVer {
  param(
    [Parameter(Mandatory = $true)][string]$A,
    [Parameter(Mandatory = $true)][string]$B
  )

  $pa = Parse-SemVer $A
  $pb = Parse-SemVer $B
  for ($i = 0; $i -lt 3; $i++) {
    if ($pa[$i] -lt $pb[$i]) { return -1 }
    if ($pa[$i] -gt $pb[$i]) { return 1 }
  }
  return 0
}

function Max-SemVer {
  param(
    [Parameter(Mandatory = $true)][string]$A,
    [Parameter(Mandatory = $true)][string]$B
  )

  if ((Compare-SemVer $A $B) -ge 0) { return $A }
  return $B
}

function Bump-Patch {
  param([Parameter(Mandatory = $true)][string]$Version)
  $p = Parse-SemVer $Version
  return ("{0}.{1}.{2}" -f $p[0], $p[1], ($p[2] + 1))
}

function Get-VersionFromTag {
  param([Parameter(Mandatory = $true)][string]$Tag)

  if ($Tag -notmatch '^v(\d+\.\d+\.\d+)$') {
    throw "Tag '$Tag' is not a release tag in the form vX.Y.Z"
  }

  return (Assert-SemVer $Matches[1])
}

function Get-LatestVersionTag {
  $tags = (Invoke-Checked git @('tag', '--list', 'v*', '--sort=-v:refname') -Quiet).Output.Split("`n") |
    ForEach-Object { $_.Trim() } |
    Where-Object { $_ -match '^v\d+\.\d+\.\d+$' }

  return ($tags | Select-Object -First 1)
}

function Find-ReleaseTagForSourceCommit {
  param([Parameter(Mandatory = $true)][string]$SourceCommit)

  $needle = $SourceCommit.Trim()
  if (-not $needle) {
    throw 'Source commit must not be empty.'
  }

  $tags = (Invoke-Checked git @('tag', '--list', 'v*', '--sort=-v:refname') -Quiet).Output.Split("`n") |
    ForEach-Object { $_.Trim() } |
    Where-Object { $_ -match '^v\d+\.\d+\.\d+$' }

  foreach ($tag in $tags) {
    $body = (Invoke-Checked git @('log', '-1', '--format=%B', $tag) -Quiet).Output
    if ($body -match ("(?m)^release-source:\s*" + [regex]::Escape($needle) + "\s*$")) {
      return $tag
    }
  }

  return $null
}

function Get-CargoPackageVersion {
  param([Parameter(Mandatory = $true)][string]$Package)

  $raw = Invoke-Checked cargo @('metadata', '--no-deps', '--format-version', '1') -Quiet
  $meta = $raw.Output | ConvertFrom-Json
  $pkg = $meta.packages | Where-Object { $_.name -eq $Package } | Select-Object -First 1
  if (-not $pkg) {
    throw "Package '$Package' not found in cargo metadata."
  }
  $v = [string]$pkg.version
  Assert-SemVer $v | Out-Null
  return $v
}

function Update-CargoTomlPackageVersion {
  param(
    [Parameter(Mandatory = $true)][string]$Path,
    [Parameter(Mandatory = $true)][string]$NewVersion
  )

  if (-not (Test-Path $Path)) {
    throw "File not found: $Path"
  }

  $NewVersion = Assert-SemVer $NewVersion

  $lines = Get-Content -Path $Path -Encoding UTF8
  $out = New-Object System.Collections.Generic.List[string]
  $inPackage = $false
  $foundVersion = $false
  $changed = $false

  foreach ($line in $lines) {
    if ($line -match '^\[package\]\s*$') {
      $inPackage = $true
      $out.Add($line)
      continue
    }
    if ($line -match '^\[[^\]]+\]\s*$' -and $line -notmatch '^\[package\]\s*$') {
      $inPackage = $false
      $out.Add($line)
      continue
    }

    if ($inPackage -and $line -match '^\s*version\s*=\s*"([^"]+)"\s*$') {
      $cur = $Matches[1]
      $foundVersion = $true
      if ($cur -ne $NewVersion) {
        $out.Add("version = `"$NewVersion`"")
        $changed = $true
      } else {
        $out.Add($line)
      }
      continue
    }

    $out.Add($line)
  }

  if (-not $foundVersion) {
    throw "Did not find a [package] version line in $Path"
  }

  if ($changed) {
    Set-Content -Path $Path -Value $out -Encoding UTF8
  }

  return $changed
}

function Update-DependencyVersionInCargoToml {
  param(
    [Parameter(Mandatory = $true)][string]$Path,
    [Parameter(Mandatory = $true)][string]$DependencyName,
    [Parameter(Mandatory = $true)][string]$NewVersion
  )

  if (-not (Test-Path $Path)) {
    throw "File not found: $Path"
  }
  $NewVersion = Assert-SemVer $NewVersion

  $toml = Get-Content -Path $Path -Raw -Encoding UTF8
  $pattern = "(?m)^(\s*" + [regex]::Escape($DependencyName) + "\s*=\s*\{[^\r\n]*?\bversion\s*=\s*`")([^\`"]+)(`"[^\r\n]*\})"
  $regex = [regex]::new($pattern)
  $updated = $regex.Replace(
    $toml,
    [System.Text.RegularExpressions.MatchEvaluator]{
      param($match)
      $match.Groups[1].Value + $NewVersion + $match.Groups[3].Value
    },
    1
  )
  if ($updated -ne $toml) {
    Set-Content -Path $Path -Value $updated -Encoding UTF8
    return $true
  }
  return $false
}

function Get-TagSha {
  param([Parameter(Mandatory = $true)][string]$Tag)
  $r = Invoke-Checked git @('rev-parse', '-q', '--verify', "refs/tags/$Tag") -AllowFailure -Quiet
  if ($r.ExitCode -ne 0) { return $null }
  return $r.Output.Trim()
}

function Ensure-Tag-Immutable {
  param(
    [Parameter(Mandatory = $true)][string]$Tag,
    [Parameter(Mandatory = $true)][string]$ExpectedSha
  )

  $ExpectedSha = $ExpectedSha.Trim()
  $tagSha = Get-TagSha $Tag
  if ($tagSha) {
    if ($tagSha -ne $ExpectedSha) {
      throw "Tag '$Tag' already exists but points to $tagSha while HEAD is $ExpectedSha. Refusing to move tag."
    }
    return $false
  }

  Invoke-Checked git @('tag', $Tag, $ExpectedSha) -Quiet
  return $true
}