From 54972ab34108e422ac6105bb64fb270b936c7776 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 16:18:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E6=B8=85=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E5=8D=95=E8=A1=8C=E6=9B=BF=E6=8D=A2=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E8=BD=AE=E7=AC=AC=E4=BA=94=E5=9D=97=C2=B7?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外科式改写的地基(ADR-0013):Get-BakNRetBackupListRow 把清单文本拆成带**行号**的行表,Get-BakNRetBackupListRow 之外的行一字节不动。行表刻意保留注释与空行 —— 只在表里放"真正的条目"会让行号错位,改一行就会连锁改掉别处。 解析器给的 Record.Raw 就是原始行文本,所以"没被编辑的行写回原样"是天然的:注释、对齐、$( ) 表达式、跨行拼接都不会被碰到。这也是它比"解析成对象再序列化"安全的地方。 行号越界**抛错**而不是静默不动:静默不动会制造"保存成功但其实没改",而用户看到的是一份以为改过、其实没改的配置。 判据是 spec 里那条往返断言的文本层形式:每行原样写回后**文本逐字节相同**;改一行时只有那一行不同、注释行数不变、原文本不被就地改动。 验收:断言 13 条;test.ps1 9/9 全绿(5.1 与 7)。 --- BakNRet/BakNRet.psd1 | 2 + BakNRet/BakNRet.psm1 | 5 +++ BakNRet/Public/Get-BakNRetBackupListRow.ps1 | 32 ++++++++++++++++ BakNRet/Public/Set-BakNRetBackupListRow.ps1 | 30 +++++++++++++++ tests/Run-Tests.ps1 | 41 +++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 BakNRet/Public/Get-BakNRetBackupListRow.ps1 create mode 100644 BakNRet/Public/Set-BakNRetBackupListRow.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index 51799a8..bd4ddb3 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,8 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Get-BakNRetBackupListRow', + 'Set-BakNRetBackupListRow', 'Invoke-BakNRetMenu', 'Update-BakNRetMenuState', 'Format-BakNRetPaddedText', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 86846aa..ca0944a 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -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', diff --git a/BakNRet/Public/Get-BakNRetBackupListRow.ps1 b/BakNRet/Public/Get-BakNRetBackupListRow.ps1 new file mode 100644 index 0000000..ca0eab7 --- /dev/null +++ b/BakNRet/Public/Get-BakNRetBackupListRow.ps1 @@ -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) +} diff --git a/BakNRet/Public/Set-BakNRetBackupListRow.ps1 b/BakNRet/Public/Set-BakNRetBackupListRow.ps1 new file mode 100644 index 0000000..b3cd1da --- /dev/null +++ b/BakNRet/Public/Set-BakNRetBackupListRow.ps1 @@ -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") +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index a52ef03..6cffc0e 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -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