lib/wtsettings.ps1

# wtsettings.ps1 -- reading, merging and writing Windows Terminal's settings.json.
#
# Dot-sourced by tstyles.ps1, so everything here shares its $script: scope.
#
# One of exactly two config writers in the project (the other is
# New-AppleTerminalProfile in terminals.ps1); everything else is escape
# sequences. Three concerns live here and each has a sharp edge:
#
# JSONC -- Windows Terminal ships settings.json with comments and trailing
# commas, which Windows PowerShell 5.1's ConvertFrom-Json rejects.
# Hand-written string state machines strip them.
# Merging -- Merge-StyleIntoSettings decides what a style may change and,
# via Test-ManagedBackgroundPath, whose background it is allowed
# to clear.
# Writing -- atomic replace through a sibling temp file, at depth 100,
# because the default depth silently stringifies a deep file.

function Remove-JsonComment {
    # Strip // line comments and /* */ block comments from a JSON/JSONC string,
    # leaving comment-like sequences INSIDE string literals (URLs, globs, paths)
    # intact. Windows Terminal's default settings.json ships with // comments,
    # which Windows PowerShell 5.1's ConvertFrom-Json rejects; this normalizes the
    # text so 5.1 and pwsh 7 parse identically. Round-tripping back through
    # ConvertTo-Json drops comments anyway, so stripping costs no fidelity.
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    $sb = [System.Text.StringBuilder]::new($Text.Length)
    $inString = $false
    $escaped  = $false
    $i = 0
    $n = $Text.Length
    while ($i -lt $n) {
        $c = $Text[$i]
        if ($inString) {
            [void]$sb.Append($c)
            if     ($escaped)     { $escaped = $false }
            elseif ($c -eq '\')   { $escaped = $true }
            elseif ($c -eq '"')   { $inString = $false }
            $i++
            continue
        }
        if ($c -eq '"') {
            $inString = $true
            [void]$sb.Append($c)
            $i++
            continue
        }
        if ($c -eq '/' -and ($i + 1) -lt $n) {
            $next = $Text[$i + 1]
            if ($next -eq '/') {
                # Line comment: skip to (but keep) the newline.
                $i += 2
                while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ }
                continue
            }
            if ($next -eq '*') {
                # Block comment: skip through the closing */.
                $i += 2
                while ($i -lt $n -and -not ($Text[$i] -eq '*' -and ($i + 1) -lt $n -and $Text[$i + 1] -eq '/')) { $i++ }
                $i += 2
                continue
            }
        }
        [void]$sb.Append($c)
        $i++
    }
    $sb.ToString()
}

function Remove-JsonTrailingComma {
    # Drop trailing commas (a ',' whose next non-whitespace char is '}' or ']')
    # OUTSIDE string literals. Windows Terminal and hand edits leave these; pwsh 7
    # tolerates them but Windows PowerShell 5.1's ConvertFrom-Json rejects them
    # with "extra trailing ','", crashing every mutating command. Commas inside
    # string values (e.g. "a,b") are preserved. Run AFTER Remove-JsonComment so a
    # comment between the comma and the bracket can't hide the trailing comma.
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    $sb = [System.Text.StringBuilder]::new($Text.Length)
    $inString = $false
    $escaped  = $false
    $i = 0
    $n = $Text.Length
    while ($i -lt $n) {
        $c = $Text[$i]
        if ($inString) {
            [void]$sb.Append($c)
            if     ($escaped)     { $escaped = $false }
            elseif ($c -eq '\')   { $escaped = $true }
            elseif ($c -eq '"')   { $inString = $false }
            $i++
            continue
        }
        if ($c -eq '"') {
            $inString = $true
            [void]$sb.Append($c)
            $i++
            continue
        }
        if ($c -eq ',') {
            # Peek past whitespace: a comma immediately preceding } or ] is trailing.
            $j = $i + 1
            while ($j -lt $n -and [char]::IsWhiteSpace($Text[$j])) { $j++ }
            if ($j -lt $n -and ($Text[$j] -eq '}' -or $Text[$j] -eq ']')) {
                $i++   # skip the comma; leave the whitespace/bracket intact
                continue
            }
        }
        [void]$sb.Append($c)
        $i++
    }
    $sb.ToString()
}

function ConvertFrom-WTJson {
    # Parse a Windows Terminal settings.json string, tolerating the // and /* */
    # comments and trailing commas WT writes by default. On Windows PowerShell 5.1
    # ConvertFrom-Json rejects both outright, so a fresh WT install (or a hand edit)
    # would otherwise abort every mutating command with a raw parse error. Strips
    # comments first, then trailing commas, then parses; throws one actionable
    # message if the text still isn't valid JSON.
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Json)

    $clean = Remove-JsonComment -Text $Json
    $clean = Remove-JsonTrailingComma -Text $clean
    try {
        $clean | ConvertFrom-Json
    } catch {
        throw ("TerminalStyles: could not parse Windows Terminal settings.json. " +
               "On Windows PowerShell 5.1, JSON comments other than // and /* */ are not supported -- " +
               "open WT Settings and Save once, or remove the offending text. " +
               "Underlying error: $($_.Exception.Message)")
    }
}

function Test-ManagedBackgroundPath {
    # True when a profile's backgroundImage points at a file TerminalStyles put
    # there itself: a bundled styles\<name>\background.* under the module root,
    # or a lazily-fetched copy under the data root's cache\. Anything else --
    # the user's own image, or Windows Terminal keywords like
    # 'desktopWallpaper' -- is theirs, and the merge leaves it alone.
    #
    # This is what lets a bundle-less style clear the PREVIOUS style's
    # background without also clobbering a background the user chose.
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return $false }

    $roots = [System.Collections.Generic.List[string]]::new()
    foreach ($r in @($script:TStylesModuleRoot, $script:TStylesDataRoot)) {
        if (-not [string]::IsNullOrWhiteSpace($r)) { $roots.Add($r) }
    }
    # PSResourceGet installs each version to its own sibling dir
    # (...\Modules\TerminalStyles\<version>\), so a background written by an
    # earlier version sits OUTSIDE the current module root. Treat the whole
    # ...\Modules\TerminalStyles\ tree as ours -- but only when the parent is
    # literally named TerminalStyles, so this can't swallow a neighbouring
    # module's files.
    $parent = Split-Path $script:TStylesModuleRoot -Parent
    if ($parent -and (Split-Path $parent -Leaf) -eq 'TerminalStyles') {
        $roots.Add($parent)
    }

    foreach ($root in $roots) {
        if ([string]::IsNullOrWhiteSpace($root)) { continue }
        try {
            # GetFullPath normalises separators/casing-insensitive comparison and
            # resolves any '..'; it does not require the file to exist. A WT
            # keyword like 'desktopWallpaper' resolves against the CWD and so
            # never lands under one of our roots.
            $full     = [System.IO.Path]::GetFullPath($Path)
            $fullRoot = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') +
                        [System.IO.Path]::DirectorySeparatorChar
        } catch {
            continue   # unparseable path (invalid chars) -- treat as not ours
        }
        # Compare against the root WITH a trailing separator so a sibling like
        # 'TerminalStylesEvil\x.gif' can't match the 'TerminalStyles' root.
        if ($full.StartsWith($fullRoot, [StringComparison]::OrdinalIgnoreCase)) {
            return $true
        }
    }
    return $false
}

function Get-StyleSettingsPayload {
    <#
    .SYNOPSIS
    Does this style have anything to write into settings.json?

    .DESCRIPTION
    The style half of the rule the -Target guard covers for profiles: find out
    BEFORE touching the user's file whether the operation can do anything.

    Merge-StyleIntoSettings returns the settings object UNTOUCHED for a style
    with no theme.json -- correctly, because a colour scheme is only reachable
    through a profile's colorScheme key, which theme.json carries, so writing
    the scheme anyway would strand it where Reset can never remove it. But
    every caller then wrote the returned object regardless, and that write is
    not a no-op: re-serializing what ConvertFrom-WTJson parsed drops every //
    and /* */ comment the user wrote. So applying a scheme-only style destroyed
    the comments in settings.json, applied nothing, and reported "Style
    applied" in green.

    A style with scheme.json and no theme.json is LEGAL -- README documents
    theme.json as optional, and off Windows Terminal scheme.json is the whole
    style -- so this is not an error, it is "nothing for THIS writer to do".
    A missing scheme.json is different: the directory changed under us, and
    the caller should say so rather than throw from inside the merge.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$StyleDir)

    if (-not (Test-Path -LiteralPath (Join-Path $StyleDir 'scheme.json'))) {
        return [pscustomobject]@{ Ok = $false; Missing = 'scheme.json' }
    }
    if (-not (Test-Path -LiteralPath (Join-Path $StyleDir 'theme.json'))) {
        return [pscustomobject]@{ Ok = $false; Missing = 'theme.json' }
    }
    return [pscustomobject]@{ Ok = $true; Missing = $null }
}

function Resolve-WTProfileTarget {
    <#
    .SYNOPSIS
    Resolve a -Target name against settings.json. Reports nothing; decides only.

    .DESCRIPTION
    One rule, four callers. Apply, reset, font and the picker each resolved the
    target themselves, and each did it at a different point relative to the
    damage -- which is how a mistyped -Target came to destroy the rolling
    backup on two of them while erroring cleanly on a third.

    Ok vs Entry is a real distinction, not a convenience. 'defaults' is always
    ADDRESSABLE (an apply creates the block lazily) so Ok is $true, but it has
    no Entry until the block exists -- and reset has nothing to strip from a
    profile that is not there. Callers that write want Ok; callers that modify
    an existing entry want Entry.

    Available is for the caller's error message, so every one of them can name
    the same set of real profiles.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowNull()]$Settings,
        [Parameter(Mandatory)][AllowEmptyString()][string]$TargetName
    )

    $list = @()
    try { $list = @($Settings.profiles.list) } catch { $list = @() }
    $available = @('defaults') + @($list | ForEach-Object { $_.name } | Where-Object { $_ })

    if ($TargetName -eq 'defaults') {
        $entry = $null
        try {
            if ($Settings.profiles.PSObject.Properties.Match('defaults').Count) {
                $entry = $Settings.profiles.defaults
            }
        } catch { }
        return [pscustomobject]@{ Ok = $true; Entry = $entry; IsDefaults = $true; Available = $available }
    }

    $entry = $list | Where-Object name -eq $TargetName | Select-Object -First 1
    return [pscustomobject]@{
        Ok = [bool]$entry; Entry = $entry; IsDefaults = $false; Available = $available
    }
}

function Save-SettingsBackup {
    <#
    .SYNOPSIS
    Take the rolling settings.json.bak -- only once the operation is known possible.

    .DESCRIPTION
    Taking this backup is itself DESTRUCTIVE: there is one .bak, and writing it
    consumes the user's undo of their last real apply. So a command that turns
    out to do nothing must not take it.

    That is why -ResolvedTarget is MANDATORY and is checked here. It is not
    defensive typing -- it is the invariant made structural. A caller cannot
    take the backup before resolving the target, because it has nothing to pass
    until it has. `tstyles reset -Target <typo>` and `tstyles font <name>
    -Target <typo>` both used to copy settings.json over the backup and only
    then discover the profile did not exist, printing "nothing to reset" over
    the wreckage of the user's one-line undo.

    Copy-Item rather than a read/write round-trip, so the bytes are preserved
    exactly -- a BOM included.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        # The result of Resolve-WTProfileTarget for the operation about to run.
        [Parameter(Mandatory)][AllowNull()]$ResolvedTarget,
        # The picker and the tuner take this as crash-recovery behind a menu
        # that redraws every frame; announcing it would be noise.
        [switch]$Quiet
    )

    if (-not $ResolvedTarget -or -not $ResolvedTarget.Ok) {
        throw "Save-SettingsBackup called before the target was known to be valid. This is a bug: the backup consumes the user's undo, so it must be taken only once the operation is known to be possible."
    }

    Copy-Item -LiteralPath $Path -Destination "$Path.bak" -Force -ErrorAction Stop
    if (-not $Quiet) {
        Write-Host "Backed up settings to: $Path.bak" -ForegroundColor Gray
    }
}

function Merge-StyleIntoSettings {
    param(
        $Settings,
        [string]$StyleDir,
        [string]$TargetName,
        [string]$BackgroundImage,
        [bool]$BackgroundImageProvided
    )

    $scheme = [System.IO.File]::ReadAllText((Join-Path $StyleDir 'scheme.json'), [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json

    # Resolve / validate the target FIRST. A non-existent named profile must not
    # cause us to inject the color scheme -- that would leave an orphan scheme in
    # settings.json that Reset's cleanup can never remove (no profile references
    # it). 'defaults' is created lazily below, only when there's a theme to write.
    $namedEntry = $null
    if ($TargetName -ne 'defaults') {
        $namedEntry = $Settings.profiles.list | Where-Object name -eq $TargetName | Select-Object -First 1
        if (-not $namedEntry) { return $Settings }   # missing named target: leave settings untouched
    }

    # Everything that could still make us bail happens BEFORE the scheme is
    # upserted. A scheme is only reachable through a profile's colorScheme key,
    # which lives in theme.json -- so writing the scheme and then discovering
    # there is no theme.json, or no profile entry to write it to, leaves a
    # scheme nothing references. Reset-StyleDirect cleans up the scheme named by
    # the profile it is resetting, so an unreferenced one can never be removed
    # and accumulates in settings.json on every apply.
    #
    # That is precisely the failure the target guard above was added to prevent;
    # the missing-theme.json route into it was left open next to it.
    $themePath = Join-Path $StyleDir 'theme.json'
    if (-not (Test-Path -LiteralPath $themePath)) { return $Settings }
    $theme = [System.IO.File]::ReadAllText($themePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json

    $entry = if ($TargetName -eq 'defaults') {
        if (-not $Settings.profiles.PSObject.Properties.Match('defaults').Count) {
            $Settings.profiles | Add-Member -NotePropertyName defaults -NotePropertyValue ([pscustomobject]@{})
        }
        $Settings.profiles.defaults
    } else {
        $namedEntry
    }
    if (-not $entry) { return $Settings }

    if (-not $Settings.PSObject.Properties.Match('schemes').Count) {
        $Settings | Add-Member -NotePropertyName schemes -NotePropertyValue @()
    }
    $Settings.schemes = @($Settings.schemes | Where-Object { $_.name -ne $scheme.name }) + $scheme

    # Resolve effective background:
    # 1. User passed -BackgroundImage <path> -> use that
    # 2. User passed -BackgroundImage "" -> strip (remove fields entirely)
    # 3. Style ships a bundled background.* -> use that
    # 4. Otherwise -> leave user's existing bg alone
    $effectiveBg = $BackgroundImage
    $applyBg = $BackgroundImageProvided
    if (-not $applyBg) {
        $bundled = Get-StyleBundledBackground -StyleDir $StyleDir
        if ($bundled) {
            $effectiveBg = $bundled
            $applyBg = $true
        }
    }

    # Three actions for bg fields:
    # skip : don't touch them
    # remove : strip them from the profile (explicit empty path => disable)
    # apply : substitute the placeholder and write all bg fields
    #
    # When the new style ships no background, what happens depends on WHOSE
    # background is currently on the profile. One we wrote for the previously
    # applied style gets cleared -- otherwise it bleeds through and the new
    # style is shown behind the old style's GIF. A background the user set
    # themselves is left alone, which is what the skip is for.
    $existingBg = if ($entry.PSObject.Properties.Match('backgroundImage').Count -gt 0) {
        [string]$entry.backgroundImage
    } else { $null }

    $bgAction = if ($applyBg) {
                    if ([string]::IsNullOrEmpty($effectiveBg)) { 'remove' } else { 'apply' }
                }
                elseif (Test-ManagedBackgroundPath -Path $existingBg) { 'remove' }
                else { 'skip' }

    $bgFields = $script:TStylesBgFields

    # 'remove' is driven by what is already ON the profile, not by what the new
    # style's theme.json happens to mention -- and a style that ships no
    # background has no reason to mention background fields at all. Running this
    # inside the property loop below meant the clear only fired for styles whose
    # theme.json named the fields, so switching to one that omitted them left the
    # PREVIOUS style's image showing through the new palette.
    if ($bgAction -eq 'remove') {
        foreach ($bgField in $bgFields) {
            if ($entry.PSObject.Properties.Match($bgField).Count -gt 0) {
                $entry.PSObject.Properties.Remove($bgField)
            }
        }
    }

    foreach ($prop in $theme.PSObject.Properties) {
        $name  = $prop.Name
        $value = $prop.Value

        if ($name -in $bgFields) {
            # skip: leave the user's own background alone.
            # remove: already stripped above; re-adding it here would undo that.
            if ($bgAction -ne 'apply') { continue }
            if ($name -eq 'backgroundImage' -and $value -eq '{{BACKGROUND_IMAGE}}') {
                $value = $effectiveBg
            }
        }

        if ($entry.PSObject.Properties.Match($name).Count -gt 0) {
            $entry.$name = $value
        } else {
            $entry | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force
        }
    }

    return $Settings
}

function Write-SettingsAtomic {
    # Write settings.json durably: serialize to a sibling temp file, then
    # atomically replace the live file. WriteAllText truncates-then-writes, so a
    # crash/kill or a concurrent reader (Windows Terminal watches settings.json
    # and reloads on change) can observe a half-written/empty file. A same-volume
    # rename is atomic on NTFS, so the live file is only ever the old bytes or the
    # complete new bytes -- never a truncated middle. Falls back to a direct copy
    # if Replace/Move is unsupported (e.g. an odd filesystem). UTF-8 no BOM.
    param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Json)
    $enc = [System.Text.UTF8Encoding]::new($false)
    $tmp = "$Path.tstmp"
    [System.IO.File]::WriteAllText($tmp, $Json, $enc)
    try {
        if (Test-Path -LiteralPath $Path) {
            # backupFileName = [NullString]::Value: replace without keeping a
            # copy. A bare $null is coerced by PowerShell to '' here, which makes
            # Replace throw "path is empty" on PS7 and silently fall back to the
            # non-atomic in-place write below; [NullString]::Value passes a true
            # null so the atomic same-volume rename actually runs.
            [System.IO.File]::Replace($tmp, $Path, [NullString]::Value)
        } else {
            [System.IO.File]::Move($tmp, $Path)
        }
    } catch {
        # Best-effort fallback (cross-volume temp, Replace unsupported, etc.).
        # The cleanup is in a finally because the fallback can throw too -- a
        # read-only or locked settings.json fails BOTH the Replace and the
        # WriteAllText, and the exception then escaped past the cleanup line,
        # leaving a .tstmp beside the user's settings.json on every attempt.
        try {
            [System.IO.File]::WriteAllText($Path, $Json, $enc)
        } finally {
            if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
        }
    }
}

function Write-SettingsFile {
    param([string]$Path, $Settings)
    # Depth 100 (the JSON max) rather than 32: a deeply-nested user settings.json
    # over depth 32 is silently stringified (corrupted) by ConvertTo-Json --
    # without warning on Windows PowerShell 5.1.
    $json = $Settings | ConvertTo-Json -Depth 100
    Write-SettingsAtomic -Path $Path -Json $json
}