patchloom 0.23.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
#Requires -Version 5.1
<#
.SYNOPSIS
  Windows / PowerShell host smoke for patchloom (create, replace, doc, tx, paths).

.DESCRIPTION
  Fixrealloop-style dogfood under PowerShell: nested relative paths (backslash
  form on Windows; forward-slash nested form on macOS/Linux pwsh), absolute
  paths, JSON error_kind peels, insert-after, contain, multi-op tx, rename
  --force overwrite, and path-only binary rename (#2031).
  Not part of make check. CI runs on ci-windows; local pwsh on macOS/Linux is
  supported for peels/tx/rename dogfood (Windows-only path spelling is gated).

.PARAMETER Bin
  Path to patchloom.exe (default: target\debug\patchloom.exe relative to repo root).

.EXAMPLE
  pwsh -File scripts/windows-smoke.ps1 -Bin target\debug\patchloom.exe
#>
param(
    [string]$Bin = ""
)

$ErrorActionPreference = "Stop"
$script:Passes = 0
$script:Fails = 0

function Pass([string]$Msg) {
    Write-Host "OK: $Msg"
    $script:Passes++
}
function Fail([string]$Msg) {
    Write-Host "FAIL: $Msg" -ForegroundColor Red
    $script:Fails++
}

# Resolve binary
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
if (-not $Bin) {
    $candidates = @(
        (Join-Path $RepoRoot "target\debug\patchloom.exe"),
        (Join-Path $RepoRoot "target\release\patchloom.exe"),
        (Join-Path $RepoRoot "target\debug\patchloom"),
        (Join-Path $RepoRoot "target\release\patchloom")
    )
    foreach ($c in $candidates) {
        if (Test-Path -LiteralPath $c) { $Bin = $c; break }
    }
}
if (-not $Bin -or -not (Test-Path -LiteralPath $Bin)) {
    throw "patchloom binary not found. Build first (cargo build --all-features) or pass -Bin"
}
$Bin = (Resolve-Path -LiteralPath $Bin).Path
Write-Host "BIN=$Bin"

function Invoke-Pl {
    param(
        [Parameter(ValueFromRemainingArguments = $true)]
        [string[]]$Args
    )
    $out = & $Bin @Args 2>&1 | Out-String
    return @{
        ExitCode = $LASTEXITCODE
        Output   = $out
    }
}

function Get-JsonField {
    param([string]$JsonText, [string]$Name)
    try {
        $obj = $JsonText | ConvertFrom-Json
        $prop = $obj.PSObject.Properties[$Name]
        if ($null -eq $prop) { return $null }
        return $prop.Value
    } catch {
        return $null
    }
}

# Host OS: full backslash + drive-letter paths only on Windows. On macOS/Linux
# with pwsh (local dogfood of peels/tx/rename), use native nested paths so
# `make windows-smoke` is an honest pass without a Windows runner.
$IsWin = $false
if ($PSVersionTable.PSEdition -eq "Core") {
    $IsWin = [bool]$IsWindows
} else {
    $IsWin = ($env:OS -eq "Windows_NT")
}
Write-Host "HOST_WINDOWS=$IsWin"

$ws = Join-Path ([System.IO.Path]::GetTempPath()) ("pl-win-smoke-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
New-Item -ItemType Directory -Path $ws | Out-Null
try {
    Push-Location $ws
    git init -q 2>$null | Out-Null
    git commit --allow-empty -q -m init 2>$null | Out-Null

    # --- create / already_exists / force ---
    Set-Content -Path (Join-Path $ws "taken.txt") -Value "old`n" -NoNewline
    $r = Invoke-Pl --json --cwd $ws create taken.txt --content "new"
    $kind = Get-JsonField $r.Output "error_kind"
    $applied = Get-JsonField $r.Output "applied"
    if ($r.ExitCode -eq 1 -and $kind -eq "already_exists" -and ($applied -eq $false -or "$applied" -eq "False")) {
        Pass "create dest-exists already_exists (PowerShell)"
    } else {
        $snip = if ($r.Output.Length -gt 200) { $r.Output.Substring(0, 200) } else { $r.Output }
        Fail "create dest-exists exit=$($r.ExitCode) kind=$kind applied=$applied out=$snip"
    }
    if ($r.Output -match "force") {
        Pass "already_exists message mentions force"
    } else {
        Fail "already_exists message missing force hint: $($r.Output)"
    }
    $r = Invoke-Pl --json --cwd $ws create taken.txt --content "forced`n" --force --apply
    $got = Get-Content -LiteralPath (Join-Path $ws "taken.txt") -Raw
    if ($r.ExitCode -eq 0 -and $got -match "forced") {
        Pass "create --force --apply"
    } else {
        Fail "create force exit=$($r.ExitCode) content=$got"
    }

    # --- nested relative path under --cwd (backslash form is Windows-agent style) ---
    $sub = Join-Path $ws (Join-Path "sub" "nested")
    New-Item -ItemType Directory -Path $sub -Force | Out-Null
    Set-Content -LiteralPath (Join-Path $sub "app.rs") -Value "use std::io;`nfn main() {}`n"
    if ($IsWin) {
        $rel = "sub\nested\app.rs"
        $pathLabel = "backslash relative path"
    } else {
        $rel = "sub/nested/app.rs"
        $pathLabel = "nested relative path (non-Windows pwsh)"
    }
    $r = Invoke-Pl --cwd $ws replace "use std::io;" --insert-after "use std::fs;" $rel --apply
    $body = Get-Content -LiteralPath (Join-Path $sub "app.rs") -Raw
    if ($r.ExitCode -eq 0 -and $body -match "use std::io;" -and $body -match "use std::fs;") {
        Pass "insert-after with $pathLabel"
    } else {
        Fail "insert-after exit=$($r.ExitCode) body=$body out=$($r.Output)"
    }

    # --- absolute path (native to this host) ---
    $absFile = Join-Path $ws "abs_target.txt"
    Set-Content -LiteralPath $absFile -Value "hello world`n"
    $r = Invoke-Pl replace "world" --new "windows" $absFile --apply
    $body = Get-Content -LiteralPath $absFile -Raw
    if ($r.ExitCode -eq 0 -and $body -match "hello windows") {
        Pass "replace via absolute path"
    } else {
        Fail "abs replace exit=$($r.ExitCode) body=$body"
    }

    # --- doc set on JSON ---
    $cfg = Join-Path $ws "config.json"
    Set-Content -LiteralPath $cfg -Value '{"server":{"port":8080}}' -NoNewline
    $r = Invoke-Pl --cwd $ws doc set config.json server.port 9090 --apply
    $cfgBody = Get-Content -LiteralPath $cfg -Raw
    if ($r.ExitCode -eq 0 -and $cfgBody -match "9090") {
        Pass "doc set"
    } else {
        Fail "doc set exit=$($r.ExitCode) body=$cfgBody"
    }

    # --- tx plan with key alias (ops + key) ---
    $plan = Join-Path $ws "plan.json"
    @'
{"ops":[{"op":"doc.set","path":"config.json","key":"server.port","value":7070}]}
'@ | Set-Content -LiteralPath $plan -NoNewline
    $r = Invoke-Pl --cwd $ws tx plan.json --apply
    $cfgBody = Get-Content -LiteralPath $cfg -Raw
    if ($r.ExitCode -eq 0 -and $cfgBody -match "7070") {
        Pass "tx plan key alias"
    } else {
        Fail "tx exit=$($r.ExitCode) body=$cfgBody out=$($r.Output)"
    }

    # --- multi-op tx create two files ---
    $plan2 = Join-Path $ws "plan2.json"
    @{
        version    = 1
        operations = @(
            @{ op = "file.create"; path = "t1.txt"; content = "a`n" }
            @{ op = "file.create"; path = "t2.txt"; content = "b`n" }
        )
    } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $plan2
    $r = Invoke-Pl --cwd $ws --json tx plan2.json --apply
    if ($r.ExitCode -eq 0 -and (Test-Path (Join-Path $ws "t1.txt")) -and (Test-Path (Join-Path $ws "t2.txt"))) {
        Pass "tx multi file.create"
    } else {
        Fail "tx multi exit=$($r.ExitCode) out=$($r.Output)"
    }

    # --- preview exit 2, no write ---
    Set-Content -LiteralPath (Join-Path $ws "prev.txt") -Value "alpha`n"
    $r = Invoke-Pl --cwd $ws replace alpha --new beta prev.txt
    $still = Get-Content -LiteralPath (Join-Path $ws "prev.txt") -Raw
    if ($r.ExitCode -eq 2 -and $still -match "alpha") {
        Pass "preview exit 2 no write"
    } else {
        Fail "preview exit=$($r.ExitCode) content=$still"
    }

    # --- --contain rejects escape (absolute path outside workspace) ---
    Set-Content -LiteralPath (Join-Path $ws "in.txt") -Value "x`n"
    # Path must exist so we hit PathGuard, not a not_found peel before the guard.
    if ($IsWin) {
        $escape = "C:\Windows\System32\drivers\etc\hosts"
    } else {
        $escape = "/etc/hosts"
        if (-not (Test-Path -LiteralPath $escape)) {
            $escape = "/"
        }
    }
    $r = Invoke-Pl --json --cwd $ws --contain read $escape
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "guard_rejected") {
        Pass "contain escape guard_rejected"
    } else {
        $snip = if ($r.Output.Length -gt 250) { $r.Output.Substring(0, 250) } else { $r.Output }
        Fail "contain kind=$kind exit=$($r.ExitCode) out=$snip"
    }

    # --- not_found delete ---
    $r = Invoke-Pl --json --cwd $ws delete missing-nope.txt --apply
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "not_found" -and $r.ExitCode -eq 1) {
        Pass "delete missing not_found"
    } else {
        Fail "delete kind=$kind exit=$($r.ExitCode)"
    }

    # --- rename dest already_exists ---
    Set-Content -LiteralPath (Join-Path $ws "from.txt") -Value "a`n"
    Set-Content -LiteralPath (Join-Path $ws "to.txt") -Value "b`n"
    $r = Invoke-Pl --json --cwd $ws rename from.txt to.txt --apply
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "already_exists") {
        Pass "rename dest already_exists"
    } else {
        Fail "rename kind=$kind out=$($r.Output)"
    }

    # --- rename --force overwrites destination ---
    $r = Invoke-Pl --json --cwd $ws rename from.txt to.txt --force --apply
    $to = Get-Content -LiteralPath (Join-Path $ws "to.txt") -Raw
    $fromExists = Test-Path -LiteralPath (Join-Path $ws "from.txt")
    if ($r.ExitCode -eq 0 -and $to -match "a" -and -not $fromExists) {
        Pass "rename --force overwrites dest"
    } else {
        Fail "rename force exit=$($r.ExitCode) to=$to fromExists=$fromExists out=$($r.Output)"
    }

    # --- path-only binary rename (NUL byte; #2031 host contract on Windows) ---
    $binSrc = Join-Path $ws "blob.bin"
    $binDst = Join-Path $ws "blob-moved.bin"
    [System.IO.File]::WriteAllBytes($binSrc, [byte[]](0x00, 0x01, 0x02, 0xff))
    $r = Invoke-Pl --json --cwd $ws rename blob.bin blob-moved.bin --apply
    $dstBytes = if (Test-Path -LiteralPath $binDst) { [System.IO.File]::ReadAllBytes($binDst) } else { @() }
    $srcGone = -not (Test-Path -LiteralPath $binSrc)
    if ($r.ExitCode -eq 0 -and $srcGone -and $dstBytes.Length -eq 4 -and $dstBytes[0] -eq 0) {
        Pass "binary rename path-only preserves bytes"
    } else {
        $snip = if ($r.Output.Length -gt 200) { $r.Output.Substring(0, 200) } else { $r.Output }
        Fail "binary rename exit=$($r.ExitCode) srcGone=$srcGone len=$($dstBytes.Length) out=$snip"
    }

    # --- batch PATH OLD NEW ---
    Set-Content -LiteralPath (Join-Path $ws "ver.txt") -Value "v1`n"
    $ops = Join-Path $ws "ops.txt"
    "replace ver.txt v1 v2" | Set-Content -LiteralPath $ops
    $r = Invoke-Pl --cwd $ws batch ops.txt --apply
    $v = Get-Content -LiteralPath (Join-Path $ws "ver.txt") -Raw
    if ($r.ExitCode -eq 0 -and $v -match "v2") {
        Pass "batch replace PATH OLD NEW"
    } else {
        Fail "batch exit=$($r.ExitCode) ver=$v"
    }

    # --- search no_matches exit 3 ---
    Set-Content -LiteralPath (Join-Path $ws "only.txt") -Value "only`n"
    $r = Invoke-Pl --json --cwd $ws search nomatch only.txt
    if ($r.ExitCode -eq 3) {
        Pass "search no_matches exit 3"
    } else {
        Fail "search exit=$($r.ExitCode)"
    }

    # --- CRLF file insert-after keeps CR LF ---
    $crlf = Join-Path $ws "crlf.txt"
    [System.IO.File]::WriteAllBytes($crlf, [byte[]](0x6c, 0x31, 0x0d, 0x0a, 0x6c, 0x32, 0x0d, 0x0a)) # l1\r\nl2\r\n
    $r = Invoke-Pl --cwd $ws replace "l1" --insert-after "// post" crlf.txt --apply
    $bytes = [System.IO.File]::ReadAllBytes($crlf)
    $hex = ($bytes | ForEach-Object { $_.ToString("x2") }) -join " "
    # Expect l1\r\n// post\r\n...
    $asText = [System.Text.Encoding]::UTF8.GetString($bytes)
    if ($r.ExitCode -eq 0 -and $asText -match "l1\r\n// post\r\n") {
        Pass "CRLF insert-after preserves line endings"
    } elseif ($r.ExitCode -eq 0 -and $asText -match "// post") {
        # Still applied; mixed endings would be a product bug
        if ($asText -match "l1\n// post" -and $asText -notmatch "l1\r\n// post") {
            Fail "CRLF insert used bare LF: $hex"
        } else {
            Pass "CRLF insert-after applied ($hex)"
        }
    } else {
        Fail "CRLF exit=$($r.ExitCode) hex=$hex"
    }

    # --- version ---
    $r = Invoke-Pl --version
    if ($r.ExitCode -eq 0 -and $r.Output -match "patchloom") {
        Pass "version"
    } else {
        Fail "version: $($r.Output)"
    }

} finally {
    Pop-Location
    Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue
}

Write-Host "==== windows-smoke: passes=$script:Passes fails=$script:Fails ===="
if ($script:Fails -gt 0) {
    exit 1
}
exit 0