Payload/scripts/kaden/utils/strip-bom.ps1

<#
.SYNOPSIS
    Rewrite files to UTF-8 without a BOM.

.DESCRIPTION
    Repo PowerShell is UTF-8 without a BOM (PSScriptAnalyzerSettings.psd1 excludes
    PSUseBOMForUnicodeEncodedFile for that reason; lint-powershell.ps1 fails when
    EF BB BF is present). This script strips a UTF-8 BOM, and converts UTF-16 LE
    or BE (BOM) to UTF-8 without a BOM. Files that already have no BOM are left
    unchanged.

    Pass one or more file paths. Relative paths resolve from the current
    directory, then from the repository root. Rewrites stay inside the
    repository unless -AllowOutsideRepo is set.

    pwsh -File passes each path as its own argument. Use bare paths, or -Path
    for the first path and the rest bare. A direct call inside pwsh can use
    -Path a.ps1, b.ps1.

.PARAMETER Path
    One or more file paths. Alias: Paths. Further paths may follow.

.PARAMETER AllowOutsideRepo
    Rewrite a file that resolves outside this repository.

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 scripts\kaden\harness\lib\HarnessContract.psm1 tests\harness\Some.Tests.ps1

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 -Path scripts\a.ps1 tests\harness\Some.Tests.ps1

.EXAMPLE
    Get-Help .\scripts\kaden\utils\strip-bom.ps1 -Parameter Path
#>


#Requires -Version 5.1
[CmdletBinding(DefaultParameterSetName = 'Files')]
param(
    [Parameter(ParameterSetName = 'Files', Position = 0)]
    [Alias('Paths')]
    [string[]]$Path = @(),

    [Parameter(ParameterSetName = 'Files', ValueFromRemainingArguments = $true, DontShow = $true)]
    [string[]]$ExtraPath,

    [Parameter(ParameterSetName = 'Files')]
    [switch]$AllowOutsideRepo,

    [Parameter(ParameterSetName = 'Help')]
    [Alias('h', 'help')]
    [switch]$ShowHelp
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit\PsCliUiKit.psd1"
if (-not (Test-Path -LiteralPath $cliKitManifest)) {
    throw "PsCliUiKit manifest not found: $cliKitManifest"
}
Import-Module -Name $cliKitManifest -Force

function Resolve-StripBomRepoRoot {
    $current = $PSScriptRoot
    while ($true) {
        $marker = Join-Path $current "scripts\kaden\harness-invoke.ps1"
        if (Test-Path -LiteralPath $marker) {
            return $current
        }
        $parent = Split-Path $current -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) {
            return $null
        }
        $current = $parent
    }
}

function Resolve-StripBomInputPath {
    param(
        [Parameter(Mandatory = $true)][string]$InputPath,
        [string]$RepoRoot = ''
    )

    if ([string]::IsNullOrWhiteSpace($InputPath)) {
        throw "Path is empty."
    }
    if (Test-Path -LiteralPath $InputPath) {
        $resolved = (Resolve-Path -LiteralPath $InputPath).Path
    } elseif (-not [string]::IsNullOrWhiteSpace($RepoRoot) -and -not [System.IO.Path]::IsPathRooted($InputPath)) {
        $candidate = Join-Path $RepoRoot $InputPath
        if (-not (Test-Path -LiteralPath $candidate)) {
            throw "File not found: $InputPath"
        }
        $resolved = (Resolve-Path -LiteralPath $candidate).Path
    } else {
        throw "File not found: $InputPath"
    }

    $item = Get-Item -LiteralPath $resolved
    if ($item.PSIsContainer) {
        throw "Not a file: $resolved"
    }
    return $resolved
}

function Test-StripBomPathInsideRepo {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][string]$RepoRoot
    )

    $rootFull = [System.IO.Path]::GetFullPath($RepoRoot)
    $separator = [System.IO.Path]::DirectorySeparatorChar
    if (-not $rootFull.EndsWith($separator)) {
        $rootFull += $separator
    }
    $full = [System.IO.Path]::GetFullPath($Path)
    return $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)
}

function ConvertTo-StripBomUtf8 {
    param([Parameter(Mandatory = $true)][string]$LiteralPath)

    $bytes = [System.IO.File]::ReadAllBytes($LiteralPath)
    $kind = "none"
    $text = $null
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $kind = "utf8-bom"
        $utf8Strict = New-Object System.Text.UTF8Encoding $false, $true
        $text = $utf8Strict.GetString($bytes, 3, $bytes.Length - 3)
    } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        if (($bytes.Length % 2) -ne 0) {
            throw "UTF-16 LE byte length is odd: $LiteralPath"
        }
        $kind = "utf16-le-bom"
        $utf16 = New-Object System.Text.UnicodeEncoding $false, $true, $true
        $text = $utf16.GetString($bytes, 2, $bytes.Length - 2)
    } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
        if (($bytes.Length % 2) -ne 0) {
            throw "UTF-16 BE byte length is odd: $LiteralPath"
        }
        $kind = "utf16-be-bom"
        $utf16be = New-Object System.Text.UnicodeEncoding $true, $true, $true
        $text = $utf16be.GetString($bytes, 2, $bytes.Length - 2)
    }

    if ($kind -eq "none") {
        return [pscustomobject]@{
            Path   = $LiteralPath
            Result = "unchanged"
            Detail = ""
        }
    }

    $utf8 = New-Object System.Text.UTF8Encoding $false
    $tmp = "$LiteralPath.nobom"
    try {
        [System.IO.File]::WriteAllText($tmp, $text, $utf8)
        Move-Item -LiteralPath $tmp -Destination $LiteralPath -Force
    } finally {
        if (Test-Path -LiteralPath $tmp) {
            Remove-Item -LiteralPath $tmp -Force
        }
    }

    $after = [System.IO.File]::ReadAllBytes($LiteralPath)
    $stillBom = ($after.Length -ge 3 -and $after[0] -eq 0xEF -and $after[1] -eq 0xBB -and $after[2] -eq 0xBF)
    if ($stillBom) {
        throw "Rewrite left a UTF-8 BOM on $LiteralPath"
    }
    $detail = ""
    $result = "removed"
    if ($kind -eq "utf16-le-bom") {
        $result = "converted"
        $detail = "utf-16 le"
    } elseif ($kind -eq "utf16-be-bom") {
        $result = "converted"
        $detail = "utf-16 be"
    }
    return [pscustomobject]@{
        Path   = $LiteralPath
        Result = $result
        Detail = $detail
    }
}

try {
    if ($ShowHelp) {
        (Get-Help -Name $PSCommandPath -Full | Out-String -Width 100) | Out-Host
        return
    }

    $requested = New-Object System.Collections.Generic.List[string]
    foreach ($entry in @($Path)) {
        if (-not [string]::IsNullOrWhiteSpace($entry)) {
            [void]$requested.Add($entry)
        }
    }

    if ($null -ne $ExtraPath) {
        foreach ($entry in @($ExtraPath)) {
            if (-not [string]::IsNullOrWhiteSpace($entry)) {
                [void]$requested.Add($entry)
            }
        }
    }

    if ($requested.Count -eq 0) {
        throw "No files given. Get-Help `"$PSCommandPath`" -Parameter Path"
    }

    $repoRoot = Resolve-StripBomRepoRoot
    $resolvedPaths = New-Object System.Collections.Generic.List[string]
    foreach ($inputPath in $requested) {
        $full = Resolve-StripBomInputPath -InputPath $inputPath -RepoRoot $repoRoot
        [void]$resolvedPaths.Add($full)
    }
    if (-not $AllowOutsideRepo) {
        if ([string]::IsNullOrWhiteSpace($repoRoot)) {
            throw "Could not locate the repository root. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo"
        }
        foreach ($full in $resolvedPaths) {
            if (-not (Test-StripBomPathInsideRepo -Path $full -RepoRoot $repoRoot)) {
                throw "Refusing to rewrite a file outside the repository: $full. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo"
            }
        }
    }

    Write-CliSection -Title "Strip BOM"

    $rewritten = 0
    $unchanged = 0
    foreach ($full in $resolvedPaths) {
        $result = ConvertTo-StripBomUtf8 -LiteralPath $full
        if ($result.Result -eq "unchanged") {
            $unchanged++
            Write-CliKeyValue -Label "unchanged" -LabelWidth 10 -Value "[muted]$($result.Path)[-]"
        } elseif ($result.Result -eq "converted") {
            $rewritten++
            Write-CliKeyValue -Label "converted" -LabelWidth 10 -Value "[success]$($result.Path)[-] [muted]($($result.Detail))[-]"
        } else {
            $rewritten++
            Write-CliKeyValue -Label "removed" -LabelWidth 10 -Value "[success]$($result.Path)[-]"
        }
    }

    $summaryState = if ($rewritten -gt 0) { "Success" } else { "Info" }
    Write-CliStatus -State $summaryState -Message "$rewritten rewritten, $unchanged unchanged"
} catch {
    Write-CliSection -Title "Strip BOM failed" -Style "error"
    Write-CliStatus -State "Error" -Message $_.Exception.Message
    exit 2
} finally {
    Write-CliEnd
}