feat(tui): 清单行表与单行替换(第一轮第五块·核心)

外科式改写的地基(ADR-0013):Get-BakNRetBackupListRow 把清单文本拆成带**行号**的行表,Get-BakNRetBackupListRow 之外的行一字节不动。行表刻意保留注释与空行 —— 只在表里放"真正的条目"会让行号错位,改一行就会连锁改掉别处。

解析器给的 Record.Raw 就是原始行文本,所以"没被编辑的行写回原样"是天然的:注释、对齐、$( ) 表达式、跨行拼接都不会被碰到。这也是它比"解析成对象再序列化"安全的地方。

行号越界**抛错**而不是静默不动:静默不动会制造"保存成功但其实没改",而用户看到的是一份以为改过、其实没改的配置。

判据是 spec 里那条往返断言的文本层形式:每行原样写回后**文本逐字节相同**;改一行时只有那一行不同、注释行数不变、原文本不被就地改动。

验收:断言 13 条;test.ps1 9/9 全绿(5.1 与 7)。
This commit is contained in:
Shuery committed 2026-09-27 16:18:27 +08:00
1 parent 835fb0cf2b
commit 54972ab341
5 files changed
+110

No files matched your search

+2
View File
@@ -19,6 +19,8 @@
CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @(
'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
+5
View File
@@ -157,7 +157,12 @@
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetMenu.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupListRow.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListRow.ps1')
Export-ModuleMember -Function @(
'Get-BakNRetBackupListRow',
'Set-BakNRetBackupListRow',
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
@@ -0,0 +1,32 @@
function Get-BakNRetBackupListRow {
<#
.SYNOPSIS
把清单文本拆成带**行号**的行表;能解析的行附上解析结果,注释与空行的 Record 是 $null。
.DESCRIPTION
为什么要保留注释行与空行:外科式改写(ADR-0013)要按行号改**一行**,而行号必须与磁盘上的
文件一一对应。如果只在表里放"真正的条目",行号就错位了,改一行会连锁改掉别处。
Raw 是原始行文本(原文一个字节都不动),解析器给的 Record.Raw 也是它 —— 编辑时只要没动这一行,
就写回原样,注释、对齐、`$( )` 表达式、跨行拼接都会原封不动地留下来。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$rows = @()
for ($i = 0; $i -lt $lines.Count; $i++) {
$raw = $lines[$i]
$record = $null
# 注释行与空行不解析(解析器对它们返回 $null,这里显式跳过以省掉一次无谓的调用)
$trimmed = $raw.Trim()
if ($trimmed -and -not $trimmed.StartsWith('#')) {
$record = ConvertFrom-BackupListLine -Line $raw
}
$rows += [pscustomobject]@{
Line = $i + 1
Raw = $raw
Record = $record
}
}
return @($rows)
}
@@ -0,0 +1,30 @@
function Set-BakNRetBackupListRow {
<#
.SYNOPSIS
把清单文本里**第 N 行**换成新文本,其余每一行逐字节保持;返回新文本(不改磁盘)。
.DESCRIPTION
外科式改写的核心(ADR-0013):只碰被编辑的那一行。注释、对齐、`$( )` 表达式、跨行拼接同属
别的行,所以它们不会被碰到 —— 这也是它比"解析成对象再序列化"安全的地方(后者会把注释与
表达式一起毁掉)。
行号越界时**抛错**而不是静默不动:静默不动会让"保存成功但其实没改"这种最坏的情况发生,
而用户看到的是一份以为改过、其实没改的配置。
编码与原子替换不在这里:本函数只处理字符串。写盘由调用方走 Write-BaknretAtomicText
(并且必须先校验,见 ADR-0013)。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text,
[Parameter(Mandatory = $true)][int]$Line,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$NewText
)
$lines = @($Text -split "`r?`n")
if ($Line -lt 1 -or $Line -gt $lines.Count) {
throw ("行号越界:文件共 {0} 行,请求改第 {1} 行。" -f $lines.Count, $Line)
}
$lines[$Line - 1] = $NewText
return ($lines -join "`n")
}
+41
View File
@@ -1556,6 +1556,47 @@ Test-Case '菜单循环:按数量分档验(0 项 / 1 项 / 3 项),单键
Assert-Equal 'empty' $empty.Reason '原因标为 empty'
}
Test-Case '清单行表与单行替换:只动那一行,注释与其它行逐字节不变' {
$text = @(
'# 注释一',
'Edge',
'',
'+WindowsTerminal :encrypt',
'# 注释二',
'-OldApp :- logs\'
) -join "`n"
# 行表必须包含注释与空行,否则行号会错位(外科式改写按行号动手)
$rows = Get-BakNRetBackupListRow -Text $text
Assert-Equal 6 $rows.Count '总行数'
Assert-Equal 1 $rows[0].Line '首行行号是 1'
Assert-True ($null -eq $rows[0].Record) '注释行不解析'
Assert-True ($null -eq $rows[2].Record) '空行不解析'
Assert-Equal 'Edge' $rows[1].Record.Path '第 2 行是条目'
Assert-Equal 'backup' $rows[3].Record.Direction '第 4 行方向是 backup'
Assert-Equal 'Edge' $rows[1].Raw 'Raw 是原始行文本'
# 往返:每一行原样写回 → 文本必须逐字节相同(这是"外科式"的最强形式)
$same = $text
foreach ($row in $rows) { $same = Set-BakNRetBackupListRow -Text $same -Line $row.Line -NewText $row.Raw }
Assert-Equal $text $same '每行原样写回后文本逐字节相同'
# 改一行:只有那一行不同,注释行数不变,原文本不被就地改动
$edited = Set-BakNRetBackupListRow -Text $text -Line 2 -NewText '-Edge'
$before = @($text -split "`n")
$after = @($edited -split "`n")
Assert-Equal $before.Count $after.Count '行数不变'
Assert-Equal '-Edge' $after[1] '第 2 行改成了 -Edge'
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal 2 @($after | Where-Object { $_.Trim().StartsWith('#') }).Count '注释行数不变'
Assert-Equal 'Edge' $before[1] '原文本没有被就地改动'
# 行号越界必须抛错:静默不动会让"保存成功但其实没改"发生
$threw = $false
try { Set-BakNRetBackupListRow -Text $text -Line 99 -NewText 'x' | Out-Null } catch { $threw = $true }
Assert-True $threw '行号越界必须抛错'
}
# ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue