feat(tui): 校验后原子保存 + 时间戳备份(第一轮第五块·地基)

三个配置编辑器共用的这块地基(ADR-0013)。顺序是刻意的:① 先校验(调用方给的 -Validate 回调);② **只有校验通过才备份与写盘** —— 校验没过还留下备份会让人以为"动过了";③ 原子替换走 Write-BaknretAtomicText。返回结果对象而不是抛异常:TUI 要把错误显示出来让人继续改,而不是把界面炸掉("TUI 异常不改退出码"这条同样适用)。

给 Write-BaknretAtomicText 加了一个可选 -Encoding(不传时沿用原行为,现有调用点不受影响)。原因:它原先固定用日志编码(无 BOM),而三个配置文件都是带 BOM 的 —— 丢掉 BOM 会让 5.1 把整个文件按 GBK 读,中文全成乱码。这类"编码悄悄变了"的错不会报错,只会让文件在某一个 PowerShell 版本上读出来是乱码。

判据(断言 10 条):校验不过时不写盘、不产生备份、错误原文能带回去;校验通过后新内容落盘、备份里是**原文**、且写回**保留 UTF-8 BOM**(逐字节验 EF BB BF)。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
This commit is contained in:
Shuery committed 2026-09-27 16:22:21 +08:00
1 parent 54972ab341
commit 59ec5a21d4
5 files changed
+91 -2

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Save-BakNRetConfigFile',
'Get-BakNRetBackupListRow', 'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow', 'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu', 'Invoke-BakNRetMenu',
+3
View File
@@ -160,7 +160,10 @@
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupListRow.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupListRow.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListRow.ps1') . (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListRow.ps1')
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetConfigFile.ps1')
Export-ModuleMember -Function @( Export-ModuleMember -Function @(
'Save-BakNRetConfigFile',
'Get-BakNRetBackupListRow', 'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow', 'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu', 'Invoke-BakNRetMenu',
+55
View File
@@ -0,0 +1,55 @@
function Save-BakNRetConfigFile {
<#
.SYNOPSIS
把新内容保存回配置文件:先校验,过了才做时间戳备份 + 原子替换;不过就原样不动并带回错误。
.DESCRIPTION
三个配置编辑器(清单 / 配置 / 名录)共用这块地基(ADR-0013)。顺序是刻意安排的:
1. **先校验**(调用方给的 -Validate 回调返回错误字符串);
2. **只有校验通过才备份与写盘** —— 校验没过还留下备份,会让人以为"动过了";
3. 原子替换走 Write-BaknretAtomicText(临时文件 + 替换,失败时原文件完好)。
返回值是结果对象而不是抛异常:TUI 需要把错误**显示出来**让人继续改,而不是把界面炸掉。
"TUI 的异常绝不改退出码"(ADR-0010)这条同样适用。
编码默认 UTF-8 **带 BOM** —— 仓库里这三个文件都是带 BOM 的(.editorconfig 与验收的 Encode
层都这么要求),丢掉 BOM 会让 5.1 把整个文件按 GBK 读,中文全成乱码。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text,
[string]$BackupDirectory,
[scriptblock]$Validate,
[System.Text.Encoding]$Encoding,
[switch]$WhatIf
)
if (-not $Encoding) { $Encoding = New-Object System.Text.UTF8Encoding($true) }
$errors = @()
if ($Validate) { $errors = @(& $Validate $Text | Where-Object { $_ }) }
if ($errors.Count -gt 0) {
return [pscustomobject]@{ Saved = $false; BackupPath = $null; Error = ($errors -join ';') }
}
if (-not $BackupDirectory) {
$BackupDirectory = Join-Path (Split-Path -Parent $Path) 'logs\config-backups'
}
$backupPath = $null
if ((Test-Path -LiteralPath $Path) -and -not $WhatIf) {
if (-not (Test-Path -LiteralPath $BackupDirectory)) {
New-Item -ItemType Directory -Path $BackupDirectory -Force | Out-Null
}
$stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
$backupPath = Join-Path $BackupDirectory ('{0}-{1}.bak' -f [System.IO.Path]::GetFileName($Path), $stamp)
Copy-Item -LiteralPath $Path -Destination $backupPath -Force
}
if (-not $WhatIf) {
Write-BaknretAtomicText -Path $Path -Text $Text -Encoding $Encoding | Out-Null
}
return [pscustomobject]@{ Saved = $true; BackupPath = $backupPath; Error = '' }
}
+5 -2
View File
@@ -5,7 +5,10 @@
#> #>
param( param(
[Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Path,
[AllowEmptyString()][string]$Text = '' [AllowEmptyString()][string]$Text = '',
# 默认沿用日志编码(无 BOM)。配置文件要传带 BOM 的 UTF8Encoding —— 丢掉 BOM 会让
# Windows PowerShell 5.1 把整个文件按 GBK 读,中文全成乱码。
[System.Text.Encoding]$Encoding
) )
$directory = Split-Path -Parent $Path $directory = Split-Path -Parent $Path
@@ -14,7 +17,7 @@
} }
$temp = "$Path.tmp" $temp = "$Path.tmp"
[System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) [System.IO.File]::WriteAllText($temp, $Text, $(if ($Encoding) { $Encoding } else { $script:LogEncoding }))
if (-not (Test-Path -LiteralPath $Path)) { if (-not (Test-Path -LiteralPath $Path)) {
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $Path Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $Path
+27
View File
@@ -1597,6 +1597,33 @@ Test-Case '清单行表与单行替换:只动那一行,注释与其它行逐
Assert-True $threw '行号越界必须抛错' Assert-True $threw '行号越界必须抛错'
} }
Test-Case '保存配置:校验不过什么都不动,过了才备份 + 原子替换,且保留 BOM' {
$dir = Join-Path $env:TEMP ("bnr-save-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'BackupList.txt'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($file, "Edge`n", $utf8Bom)
try {
# 1) 校验不过:文件不动、也不产生备份(留备份会让人以为"动过了")
$bad = Save-BakNRetConfigFile -Path $file -Text "坏行`n" -Validate { param($t) @('第 1 行有问题') } -BackupDirectory (Join-Path $dir 'bak')
Assert-Equal $false $bad.Saved '校验不过不能保存'
Assert-True ($bad.Error -like '*第 1 行有问题*') '错误原文要带回来(TUI 要显示它)'
Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($file)) '文件内容不动'
Assert-True (-not (Test-Path (Join-Path $dir 'bak'))) '校验不过不该产生备份'
# 2) 校验通过:写进去 + 备份里是原文 + 保留 BOM
$ok = Save-BakNRetConfigFile -Path $file -Text "+WindowsTerminal`n" -Validate { param($t) @() } -BackupDirectory (Join-Path $dir 'bak')
Assert-Equal $true $ok.Saved '校验通过应当保存'
Assert-Equal "+WindowsTerminal`n" ([System.IO.File]::ReadAllText($file)) '新内容写进去了'
Assert-True (Test-Path $ok.BackupPath) '应当产生时间戳备份'
Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($ok.BackupPath)) '备份里是原文'
$bytes = [System.IO.File]::ReadAllBytes($file)
Assert-True ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) '写回必须保留 UTF-8 BOM'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================ # ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue