From 7733b1bf6dd7d0c433ce0866f3262e5162c3f2db Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 21:00:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E9=85=8D=E7=BD=AE=E6=A0=87?= =?UTF-8?q?=E9=87=8F=E7=9A=84=E8=AF=BB=E6=94=B9=E6=A0=B8=E5=BF=83=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E8=BD=AE=E7=AC=AC=E4=B8=80=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先侦察事实:BackupConfig.psd1 共 99 行 —— 52 行注释、18 行单行标量、3 处嵌套哈希、1 处数组、1 处行内空哈希(SidMap = @{})。所以"外科式改一个值"的边界很明确:**只认单行标量**。值跨行或本身是集合时,"改一行"这个动作没有明确含义 —— 那种就不该在界面上提供(如实显示为只读)。 路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 有两处),只用键名会把它们混成一个。非标量(行内哈希、数组)显式排除。读出来的是**含引号的原文** —— 原样写回才逐字节等价。 判据 12 条,核心是往返:**把每个标量设成它当前的值,文本必须逐字节相同**(含键名对齐与全部注释);真改一个时只有那一行不同、注释行数不变、改完还能读出新值;找不到路径或不是标量必须抛错而不是静默不动。 真实配置上的实测:认出 18 个标量(含 3 组嵌套的点号路径,排除了 SidMap 与 DefaultExcludes),**往返后逐字节相同 = True**。 过程里插入的测试代码带过一行手滑的垃圾(被 Parse 层当场抓住 —— 它是这个仓库最便宜的一道闸),以及一条期望值写错(忘了 Value 含引号)。 验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。 --- BakNRet/BakNRet.psd1 | 2 + BakNRet/BakNRet.psm1 | 5 ++ BakNRet/Public/Get-BakNRetConfigSetting.ps1 | 67 +++++++++++++++++++++ BakNRet/Public/Set-BakNRetConfigSetting.ps1 | 38 ++++++++++++ tests/Run-Tests.ps1 | 38 ++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 BakNRet/Public/Get-BakNRetConfigSetting.ps1 create mode 100644 BakNRet/Public/Set-BakNRetConfigSetting.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index a65f652..b2aeeb0 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,8 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Get-BakNRetConfigSetting', + 'Set-BakNRetConfigSetting', 'Invoke-BakNRetEntryScript', 'Invoke-BakNRetBackupListEditor', 'Set-BakNRetBackupListDirection', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 0458bac..52567d7 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -168,7 +168,12 @@ . (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BakNRetConfigSetting.ps1') +. (Join-Path $PSScriptRoot 'Public\Set-BakNRetConfigSetting.ps1') + Export-ModuleMember -Function @( + 'Get-BakNRetConfigSetting', + 'Set-BakNRetConfigSetting', 'Invoke-BakNRetEntryScript', 'Invoke-BakNRetBackupListEditor', 'Set-BakNRetBackupListDirection', diff --git a/BakNRet/Public/Get-BakNRetConfigSetting.ps1 b/BakNRet/Public/Get-BakNRetConfigSetting.ps1 new file mode 100644 index 0000000..43f495f --- /dev/null +++ b/BakNRet/Public/Get-BakNRetConfigSetting.ps1 @@ -0,0 +1,67 @@ +function Get-BakNRetConfigSetting { + <# + .SYNOPSIS + 把配置文本拆成"一行一个标量设置"的表:行号、点号路径、键、原值、行尾注释。 + + .DESCRIPTION + 为什么只认**单行标量**:这份配置文件里还有三处嵌套哈希、一处数组、一处行内空哈希 + (SidMap = @{})与大量多行注释。外科式改写(ADR-0013)只碰"一行就是一个值"的那些 —— + 值跨行或本身是集合时,"改一行"这个动作没有明确含义,宁可不在界面上提供(如实显示为只读)。 + + 路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 就有两处), + 只用键名会把它们混成一个。 + + `Raw` 是原始行文本:编辑时若没动这一行,就写回原样,键名与对齐的空格一字节都不变。 + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + $lines = $Text -split "`r?`n" + $stack = New-Object System.Collections.Generic.List[string] + $rows = @() + + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = $lines[$index] + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + + # 进入一层嵌套哈希 + if ($trimmed -match '^([\w'']+)\s*=\s*@\{\s*$') { + $stack.Add($Matches[1]) + continue + } + + # 离开一层:闭括号可能带后续内容,只关心它是否以 } 开头 + if ($trimmed.StartsWith('}')) { + if ($stack.Count -gt 0) { $stack.RemoveAt($stack.Count - 1) } + continue + } + + # 单行标量:有 = ,且值不以 @ 开头(集合与嵌套不是标量) + if ($trimmed -match '^([\w'']+)\s*=\s*(.+)$') { + $key = $Matches[1] + $rest = $Matches[2] + if ($rest.StartsWith('@')) { continue } + + $value = $rest + $comment = '' + $commentIndex = $rest.IndexOf(' #') + if ($commentIndex -ge 0) { + $value = $rest.Substring(0, $commentIndex).Trim() + $comment = $rest.Substring($commentIndex + 1).Trim() + } + + $path = if ($stack.Count -gt 0) { ($stack -join '.') + '.' + $key } else { $key } + + $rows += [pscustomobject]@{ + Line = $index + 1 + Path = $path + Key = $key + Value = $value + Comment = $comment + Raw = $line + } + } + } + + return @($rows) +} diff --git a/BakNRet/Public/Set-BakNRetConfigSetting.ps1 b/BakNRet/Public/Set-BakNRetConfigSetting.ps1 new file mode 100644 index 0000000..78ccc3f --- /dev/null +++ b/BakNRet/Public/Set-BakNRetConfigSetting.ps1 @@ -0,0 +1,38 @@ +function Set-BakNRetConfigSetting { + <# + .SYNOPSIS + 改**一个**标量设置的值:只动那一行、且只动 `=` 之后的部分,其余逐字节保持。 + + .DESCRIPTION + 键名与对齐的空格一律不动(那一列对齐是给人读的,改了它 diff 会变得没法看),行尾注释也原样 + 保留。写回时 `=` 后固定留一个空格 —— 与文件里既有的写法一致。 + + 找不到路径、或该路径不是"单行标量"(值跨行 / 是集合)时**抛错**:静默不动会制造 + "保存成功但其实没改",而用户看到的是一份以为改过、其实没改的配置。 + + 本函数只处理字符串;写盘由调用方走 Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。 + #> + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value + ) + + $row = @(Get-BakNRetConfigSetting -Text $Text | Where-Object { $_.Path -eq $Path }) | Select-Object -First 1 + + if (-not $row) { + throw ('配置里找不到单行标量设置:{0}(它可能不存在,也可能值跨行或本身是集合)' -f $Path) + } + + $lines = @($Text -split "`r?`n") + $original = $lines[$row.Line - 1] + $equalsIndex = $original.IndexOf('=') + if ($equalsIndex -lt 0) { throw ('这一行没有 = :{0}' -f $original) } + + $head = $original.Substring(0, $equalsIndex + 1) + ' ' + $tail = '' + if ($row.Comment) { $tail = ' # ' + $row.Comment.TrimStart('#').Trim() } + + $lines[$row.Line - 1] = $head + $Value + $tail + return ($lines -join "`n") +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 70c047a..966cd36 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1736,6 +1736,44 @@ Test-Case '主入口:-Quiet 不画界面、菜单能渲染、进不去界面 } } +Test-Case '配置标量:只认单行标量;改一个值只动那一行;设成当前值则逐字节不变' { + $configPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1' + $text = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 + + $rows = @(Get-BakNRetConfigSetting -Text $text) + Assert-True ($rows.Count -ge 15) ('真实配置里应当认出足够多的标量,实际 ' + $rows.Count) + Assert-Equal "'Backups'" ($rows | Where-Object { $_.Path -eq 'BackupDir' }).Value '顶层标量(读出来的是含引号的原文 —— 原样写回才逐字节等价)' + Assert-Equal 'Snapshot.Enabled' (($rows | Where-Object { $_.Path -eq 'Snapshot.Enabled' }).Path) '嵌套里的键要用点号路径(Enabled 有两处)' + Assert-Equal '$true' (($rows | Where-Object { $_.Path -eq 'Encryption.EncryptHeaders' }).Value) '布尔值按原文读出($true 本身就是原文)' + + # 非标量必须被排除:行内空哈希与数组都不是"一行一个值" + Assert-Equal 0 @($rows | Where-Object { $_.Path -eq 'Security.SidMap' }).Count '行内哈希不算标量' + Assert-Equal 0 @($rows | Where-Object { $_.Path -like 'DefaultExcludes*' }).Count '数组不算标量' + + # 往返:把每个标量设成它当前的值,文本必须**逐字节相同**(外科式改写的最强形式) + $roundTripped = $text + foreach ($row in $rows) { $roundTripped = Set-BakNRetConfigSetting -Text $roundTripped -Path $row.Path -Value $row.Value } + Assert-Equal $text $roundTripped '每个标量设成当前值后文本逐字节相同' + + # 真改一个:只有那一行不同,注释行数不变,并且改完还能读出新值 + $changed = Set-BakNRetConfigSetting -Text $text -Path 'BackupDir' -Value "'Backups2'" + $before = @($text -split "`n") + $after = @($changed -split "`n") + Assert-Equal $before.Count $after.Count '行数不变' + Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同' + $commentCount = @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count + Assert-Equal $commentCount @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变' + Assert-Equal "'Backups2'" ((Get-BakNRetConfigSetting -Text $changed | Where-Object { $_.Path -eq 'BackupDir' }).Value) '改完还能读出同一个值' + + # 找不到 / 不是标量:必须抛错,不能静默不动 + $threw = $false + try { Set-BakNRetConfigSetting -Text $text -Path 'No.Such.Key' -Value '1' | Out-Null } catch { $threw = $true } + Assert-True $threw '找不到的路径必须抛错' + $threw2 = $false + try { Set-BakNRetConfigSetting -Text $text -Path 'Security.SidMap' -Value '@{}' | Out-Null } catch { $threw2 = $true } + Assert-True $threw2 '非标量(行内哈希)也必须抛错' +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue