From 0586072ebcf8712712c8786ec168c95932030b22 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 20:00:28 +0100 Subject: [PATCH 1/7] Log updated missing config keys after upgrade After running the configuration upgrade, log an informational message when missing keys were added. Adds a conditional that checks sessionLogger and cfgUpgradeResult and logs the count and comma-separated list of cfgUpgradeResult.MissingKeys to help surface which keys were introduced during the upgrade. --- cmd/proxsave/upgrade.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 6a35f7b..0cc886f 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -159,6 +159,9 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra bootstrap.Warning("Upgrade: configuration upgrade failed: %v", cfgUpgradeErr) } } + if sessionLogger != nil && cfgUpgradeResult != nil && len(cfgUpgradeResult.MissingKeys) > 0 { + sessionLogger.Info("Upgrade: configuration updated with %d missing key(s): %s", len(cfgUpgradeResult.MissingKeys), strings.Join(cfgUpgradeResult.MissingKeys, ", ")) + } // Refresh docs/symlinks/cron/identity (configuration upgrade is handled separately) logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "refreshing docs and symlinks") From 2cee5f867ff29bb0e62d3617690f0f7b76811321 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 20:51:53 +0100 Subject: [PATCH 2/7] Insert missing template keys respecting user order Improve config upgrade logic to preserve user key positions and insert missing template entries near related user keys. parseEnvValues now records key ranges (keyRange) for each user entry and returns them; computeConfigUpgrade collects template entries, computes missing entries, and builds ordered insert operations (using sort) to merge template lines into the original file without unnecessary rewrites. Also adjust handling for block (multi-line) values, preserve extra keys and casing warnings, and update tests (rename and expectations) to reflect preserved extra keys and original casing for blocks. --- internal/config/upgrade.go | 249 ++++++++++++++++++++------------ internal/config/upgrade_test.go | 9 +- 2 files changed, 162 insertions(+), 96 deletions(-) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index c2779c8..6f9fa5a 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "sort" "strings" "time" @@ -23,6 +24,11 @@ type envValue struct { comment string } +type keyRange struct { + start int + end int +} + // UpgradeResult describes the outcome of a configuration upgrade. type UpgradeResult struct { // BackupPath is the path of the backup created from the previous config. @@ -158,37 +164,37 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er originalLines := strings.Split(normalizedOriginal, "\n") // 1. Collect user values: for each KEY we store all VALUE entries in order. - userValues, userKeyOrder, caseMap, caseConflicts, warnings, err := parseEnvValues(originalLines) + userValues, userKeyOrder, caseMap, caseConflicts, warnings, userRanges, err := parseEnvValues(originalLines) if err != nil { return result, "", originalContent, fmt.Errorf("failed to parse config %s: %w", configPath, err) } - // 2. Walk the template line-by-line, merging values. + // 2. Walk the template line-by-line and collect template entries. template := DefaultEnvTemplate() normalizedTemplate := strings.ReplaceAll(template, "\r\n", "\n") templateLines := strings.Split(normalizedTemplate, "\n") - templateKeys := make(map[string]bool) + type templateEntry struct { + key string + upper string + lines []string + } + + templateEntries := make([]templateEntry, 0) templateKeyByUpper := make(map[string]string) - missingKeys := make([]string, 0) - newLines := make([]string, 0, len(templateLines)+len(userValues)) - processedUserKeys := make(map[string]bool) // Track which user keys (original case) have been used for i := 0; i < len(templateLines); i++ { line := templateLines[i] trimmed := strings.TrimSpace(line) if utils.IsComment(trimmed) { - newLines = append(newLines, line) continue } key, _, _, ok := splitKeyValueRaw(line) if !ok || key == "" { - newLines = append(newLines, line) continue } - templateKeys[key] = true upperKey := strings.ToUpper(key) if existing, ok := templateKeyByUpper[upperKey]; ok { if existing != key { @@ -198,120 +204,180 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateKeyByUpper[upperKey] = key } - // Logic to find the user's values for this key. - // 1. Try exact match - targetUserKey := key - if _, ok := userValues[key]; !ok { - // 2. Try case-insensitive match - if mappedKey, ok := caseMap[strings.ToUpper(key)]; ok { - targetUserKey = mappedKey - } - } - - // Handle block values if blockValueKeys[upperKey] && trimmed == fmt.Sprintf("%s=\"", key) { blockEnd, err := findClosingQuoteLine(templateLines, i+1) if err != nil { return result, "", originalContent, fmt.Errorf("template %s block invalid: %w", key, err) } - - if values, ok := userValues[targetUserKey]; ok && len(values) > 0 { - processedUserKeys[targetUserKey] = true - for _, v := range values { - // Use TEMPLATE Key casing to enforce consistency - newLines = append(newLines, renderEnvValue(key, v)...) - } - } else { - missingKeys = append(missingKeys, key) - newLines = append(newLines, templateLines[i:blockEnd+1]...) - } - + templateEntries = append(templateEntries, templateEntry{ + key: key, + upper: upperKey, + lines: templateLines[i : blockEnd+1], + }) i = blockEnd continue } + templateEntries = append(templateEntries, templateEntry{ + key: key, + upper: upperKey, + lines: []string{line}, + }) + } - if values, ok := userValues[targetUserKey]; ok && len(values) > 0 { - processedUserKeys[targetUserKey] = true - for _, v := range values { - // Use TEMPLATE Key casing to enforce consistency - newLines = append(newLines, renderEnvValue(key, v)...) + // 3. Compute missing and extra keys. + missingKeys := make([]string, 0) + missingEntries := make([]templateEntry, 0) + for _, entry := range templateEntries { + targetUserKey := entry.key + if _, ok := userValues[entry.key]; !ok { + if mappedKey, ok := caseMap[entry.upper]; ok { + targetUserKey = mappedKey } - } else { - // Key missing in user config: keep template default and record it. - missingKeys = append(missingKeys, key) - newLines = append(newLines, line) } + if values, ok := userValues[targetUserKey]; ok && len(values) > 0 { + continue + } + missingKeys = append(missingKeys, entry.key) + missingEntries = append(missingEntries, entry) } - // 3. Append extra keys (present only in user config) in a dedicated section. extraKeys := make([]string, 0) - extraLines := make([]string, 0) - for _, key := range userKeyOrder { - if processedUserKeys[key] { + upperKey := strings.ToUpper(key) + if _, ok := templateKeyByUpper[upperKey]; !ok { + extraKeys = append(extraKeys, key) continue } - // If exact match was in template keys (should have been processed above), skip - if templateKeys[key] { + if caseConflicts[upperKey] && caseMap[upperKey] != key { + extraKeys = append(extraKeys, key) continue } - // If case-insensitive match was in template keys (should have been processed), skip - // Check by upper casing the user key and seeing if it exists in templateKeys? - // But wait, templateKeys stores exact keys. - // If user has "Backup_Enabled", and template has "BACKUP_ENABLED". - // We processed "BACKUP_ENABLED", found "Backup_Enabled" via caseMap, and marked "Backup_Enabled" as processed. - // So `processedUserKeys["Backup_Enabled"]` is true. We skip. - // Correct. + if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key && !caseConflicts[upperKey] { + warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved as custom entry", key, templateKey)) + } + } - upperKey := strings.ToUpper(key) - if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key { - if !caseConflicts[upperKey] { - warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved as custom entry", key, templateKey)) - } + // Count preserved values + preserved := 0 + for key, values := range userValues { + if _, ok := templateKeyByUpper[strings.ToUpper(key)]; ok { + preserved += len(values) } + } - values := userValues[key] - if len(values) == 0 { - continue + // If nothing is missing, do not rewrite the file. + if len(missingKeys) == 0 { + result.Changed = false + result.Warnings = warnings + result.ExtraKeys = extraKeys + result.PreservedValues = preserved + return result, "", originalContent, nil + } + + type insertOp struct { + index int + lines []string + order int + } + + hasTrailingNewline := strings.HasSuffix(normalizedOriginal, "\n") + appendIndex := len(originalLines) + if hasTrailingNewline && len(originalLines) > 0 && originalLines[len(originalLines)-1] == "" { + appendIndex = len(originalLines) - 1 + } + normalizeInsertIndex := func(idx int) int { + if idx < 0 { + return 0 + } + if idx > appendIndex { + return appendIndex } - extraKeys = append(extraKeys, key) - for _, v := range values { - // Preserve USER's original key casing for extras - extraLines = append(extraLines, renderEnvValue(key, v)...) + if hasTrailingNewline && idx == len(originalLines) { + return appendIndex } + return idx } - if len(extraLines) > 0 { - newLines = append(newLines, - "", - "# ----------------------------------------------------------------------", - "# Custom keys preserved from previous configuration (not present in template)", - "# ----------------------------------------------------------------------", - ) - newLines = append(newLines, extraLines...) + resolveUserKey := func(entry templateEntry) (string, bool) { + if values, ok := userValues[entry.key]; ok && len(values) > 0 { + return entry.key, true + } + if mappedKey, ok := caseMap[entry.upper]; ok { + if values, ok := userValues[mappedKey]; ok && len(values) > 0 { + return mappedKey, true + } + } + return "", false } - // Count preserved values - preserved := 0 - for key := range processedUserKeys { - preserved += len(userValues[key]) + findPrevAnchor := func(entryIndex int) (int, bool) { + for i := entryIndex - 1; i >= 0; i-- { + if userKey, ok := resolveUserKey(templateEntries[i]); ok { + ranges := userRanges[userKey] + if len(ranges) == 0 { + continue + } + return ranges[len(ranges)-1].end + 1, true + } + } + return 0, false } - // If nothing changed (no missing keys and no extras), we can return early. - // BUT checking "nothing changed" is harder now because we might have renamed keys. - // If we renamed a key, the content CHANGED. - // So we should compare normalized content? - // Or just assume if we parsed everything and re-rendered, and it matches original string... + findNextAnchor := func(entryIndex int) (int, bool) { + for i := entryIndex + 1; i < len(templateEntries); i++ { + if userKey, ok := resolveUserKey(templateEntries[i]); ok { + ranges := userRanges[userKey] + if len(ranges) == 0 { + continue + } + return ranges[0].start, true + } + } + return 0, false + } - newContent := strings.Join(newLines, lineEnding) - // Preserve trailing newline if template had one. - if strings.HasSuffix(normalizedTemplate, "\n") && !strings.HasSuffix(newContent, lineEnding) { - newContent += lineEnding + ops := make([]insertOp, 0, len(missingEntries)) + for idx, entry := range missingEntries { + insertIndex := appendIndex + if prev, ok := findPrevAnchor(idx); ok { + insertIndex = prev + } else if next, ok := findNextAnchor(idx); ok { + insertIndex = next + } + insertIndex = normalizeInsertIndex(insertIndex) + ops = append(ops, insertOp{ + index: insertIndex, + lines: entry.lines, + order: idx, + }) } + sort.SliceStable(ops, func(i, j int) bool { + if ops[i].index != ops[j].index { + return ops[i].index < ops[j].index + } + return ops[i].order < ops[j].order + }) + + newLines := make([]string, 0, len(originalLines)+len(ops)) + opIdx := 0 + for i := 0; i < len(originalLines); i++ { + for opIdx < len(ops) && ops[opIdx].index == i { + newLines = append(newLines, ops[opIdx].lines...) + opIdx++ + } + newLines = append(newLines, originalLines[i]) + } + for opIdx < len(ops) { + newLines = append(newLines, ops[opIdx].lines...) + opIdx++ + } + + newContent := strings.Join(newLines, lineEnding) if newContent == string(originalContent) { result.Changed = false result.Warnings = warnings + result.ExtraKeys = extraKeys result.PreservedValues = preserved return result, "", originalContent, nil } @@ -324,12 +390,13 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er return result, newContent, originalContent, nil } -func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string]string, map[string]bool, []string, error) { +func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string]string, map[string]bool, []string, map[string][]keyRange, error) { userValues := make(map[string][]envValue) userKeyOrder := make([]string, 0) caseMap := make(map[string]string) // UPPER -> original caseConflicts := make(map[string]bool) warnings := make([]string, 0) + userRanges := make(map[string][]keyRange) for i := 0; i < len(lines); i++ { line := lines[i] @@ -358,7 +425,7 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string blockLines := make([]string, 0) blockEnd, err := findClosingQuoteLine(lines, i+1) if err != nil { - return nil, nil, nil, nil, nil, fmt.Errorf("unterminated multi-line value for %s starting at line %d", key, i+1) + return nil, nil, nil, nil, nil, nil, fmt.Errorf("unterminated multi-line value for %s starting at line %d", key, i+1) } blockLines = append(blockLines, lines[i+1:blockEnd]...) @@ -366,6 +433,7 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string userKeyOrder = append(userKeyOrder, key) } userValues[key] = append(userValues[key], envValue{kind: envValueKindBlock, blockLines: blockLines}) + userRanges[key] = append(userRanges[key], keyRange{start: i, end: blockEnd}) i = blockEnd continue @@ -375,9 +443,10 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, map[string userKeyOrder = append(userKeyOrder, key) } userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue, comment: comment}) + userRanges[key] = append(userRanges[key], keyRange{start: i, end: i}) } - return userValues, userKeyOrder, caseMap, caseConflicts, warnings, nil + return userValues, userKeyOrder, caseMap, caseConflicts, warnings, userRanges, nil } func splitKeyValueRaw(line string) (string, string, string, bool) { diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index eb9bfa9..eb62c0c 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -79,7 +79,7 @@ func TestPlanUpgradeTracksExtraKeys(t *testing.T) { }) } -func TestUpgradeConfigCreatesBackupAndCustomSection(t *testing.T) { +func TestUpgradeConfigCreatesBackupAndPreservesExtraKeys(t *testing.T) { withTemplate(t, upgradeTemplate, func() { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "backup.env") @@ -108,9 +108,6 @@ func TestUpgradeConfigCreatesBackupAndCustomSection(t *testing.T) { t.Fatalf("failed to read upgraded config: %v", err) } content := string(updated) - if !strings.Contains(content, "Custom keys preserved") { - t.Fatalf("expected custom section header, got: %s", content) - } if !strings.Contains(content, "EXTRA_KEY=value") { t.Fatalf("expected EXTRA_KEY preserved, got: %s", content) } @@ -415,9 +412,9 @@ Custom_Backup_Paths=" } content := strings.ReplaceAll(string(data), "\r\n", "\n") - expectedBlock := "CUSTOM_BACKUP_PATHS=\"\n/etc/custom.conf\n\"\n" + expectedBlock := "Custom_Backup_Paths=\"\n/etc/custom.conf\n\"\n" if !strings.Contains(content, expectedBlock) { - t.Fatalf("upgraded config missing preserved block with fixed casing:\nGot:\n%s\nWant contains:\n%s", content, expectedBlock) + t.Fatalf("upgraded config missing preserved block with original casing:\nGot:\n%s\nWant contains:\n%s", content, expectedBlock) } }) } From 2b1963b655e891e942af28bb85fcc3ffc67abfd5 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 20:55:35 +0100 Subject: [PATCH 3/7] Group unanchored missing keys into upgrade section Add an index field to templateEntry to track template order and use it for insert ordering. When missing template entries have no nearby anchor, collect them and append a single "# Added by upgrade" section (with a separating blank line if appropriate) at the append index instead of trying to insert them in-place. Adjust insertOp ordering accordingly. Add a test (TestUpgradeConfigAddsMissingKeysUnderUpgradeSectionWhenNoAnchor) to verify legacy keys are preserved and missing keys are added under the upgrade section. --- internal/config/upgrade.go | 43 +++++++++++++++++++-------------- internal/config/upgrade_test.go | 32 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 6f9fa5a..6a2e4e7 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -178,6 +178,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er key string upper string lines []string + index int } templateEntries := make([]templateEntry, 0) @@ -213,6 +214,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er key: key, upper: upperKey, lines: templateLines[i : blockEnd+1], + index: len(templateEntries), }) i = blockEnd continue @@ -221,6 +223,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er key: key, upper: upperKey, lines: []string{line}, + index: len(templateEntries), }) } @@ -323,32 +326,36 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er return 0, false } - findNextAnchor := func(entryIndex int) (int, bool) { - for i := entryIndex + 1; i < len(templateEntries); i++ { - if userKey, ok := resolveUserKey(templateEntries[i]); ok { - ranges := userRanges[userKey] - if len(ranges) == 0 { - continue - } - return ranges[0].start, true - } - } - return 0, false - } - ops := make([]insertOp, 0, len(missingEntries)) - for idx, entry := range missingEntries { + unanchored := make([]templateEntry, 0) + for _, entry := range missingEntries { insertIndex := appendIndex - if prev, ok := findPrevAnchor(idx); ok { + if prev, ok := findPrevAnchor(entry.index); ok { insertIndex = prev - } else if next, ok := findNextAnchor(idx); ok { - insertIndex = next + } else { + unanchored = append(unanchored, entry) + continue } insertIndex = normalizeInsertIndex(insertIndex) ops = append(ops, insertOp{ index: insertIndex, lines: entry.lines, - order: idx, + order: entry.index, + }) + } + + if len(unanchored) > 0 { + section := []string{"# Added by upgrade"} + if appendIndex > 0 && strings.TrimSpace(originalLines[appendIndex-1]) != "" { + section = append([]string{""}, section...) + } + for _, entry := range unanchored { + section = append(section, entry.lines...) + } + ops = append(ops, insertOp{ + index: normalizeInsertIndex(appendIndex), + lines: section, + order: len(templateEntries), }) } diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index eb62c0c..1045e66 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -418,3 +418,35 @@ Custom_Backup_Paths=" } }) } + +func TestUpgradeConfigAddsMissingKeysUnderUpgradeSectionWhenNoAnchor(t *testing.T) { + template := "KEY1=default\nKEY2=default\n" + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + legacy := "EXTRA=value\n" + if err := os.WriteFile(configPath, []byte(legacy), 0600); err != nil { + t.Fatalf("failed to write legacy config: %v", err) + } + + result, err := UpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("UpgradeConfigFile returned error: %v", err) + } + if !result.Changed { + t.Fatal("expected result.Changed=true when keys are missing") + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read upgraded config: %v", err) + } + content := strings.ReplaceAll(string(data), "\r\n", "\n") + if !strings.Contains(content, "EXTRA=value") { + t.Fatalf("expected EXTRA to remain, got:\n%s", content) + } + if !strings.Contains(content, "# Added by upgrade\nKEY1=default\nKEY2=default\n") { + t.Fatalf("expected missing keys under upgrade section, got:\n%s", content) + } + }) +} From ef937df2ce1186069c8b7ca872511a98f02b1c7e Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 21:06:25 +0100 Subject: [PATCH 4/7] Improve casing conflict warning wording Update the warning emitted in computeConfigUpgrade when a config key differs only by case from a template key. The message now says the entry is "preserved with original casing" instead of "preserved as custom entry" to more accurately describe the behavior and reduce confusion. Affects internal/config/upgrade.go. --- internal/config/upgrade.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 6a2e4e7..1b31963 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -256,7 +256,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er continue } if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key && !caseConflicts[upperKey] { - warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved as custom entry", key, templateKey)) + warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved with original casing", key, templateKey)) } } From f2f1740a058f281c4c054ddc6671e81c4454407c Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 21:18:47 +0100 Subject: [PATCH 5/7] Track/preserve keys that differ only by case Add handling for keys that differ only by case from template keys. Introduce CaseConflictKeys on UpgradeResult and populate it in computeConfigUpgrade, update warnings to note case-only differences, and preserve those keys in-place. Update CLI output (cmd/proxsave) to print case-conflict summaries for dry-run and upgrade flows. Adjust tests to assert CaseConflictKeys instead of treating them as ExtraKeys. Also clarify ExtraKeys are preserved in-place in comments. --- cmd/proxsave/main.go | 8 ++++++++ cmd/proxsave/upgrade.go | 3 +++ internal/config/upgrade.go | 27 +++++++++++++++++++-------- internal/config/upgrade_test.go | 4 ++-- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 0daa06d..5f5aa19 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -356,6 +356,10 @@ func run() int { bootstrap.Printf("Custom keys that would be preserved (not present in template) (%d): %s", len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("Keys that differ only by case from the template (%d): %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } bootstrap.Println("Dry run only: no files were modified. Use --upgrade-config to apply these changes.") return types.ExitSuccess.Int() } @@ -468,6 +472,10 @@ func run() int { bootstrap.Printf("- Kept %d custom key(s) not present in the template: %s", len(result.ExtraKeys), strings.Join(result.ExtraKeys, ", ")) } + if len(result.CaseConflictKeys) > 0 { + bootstrap.Printf("- Preserved %d key(s) that differ only by case: %s", + len(result.CaseConflictKeys), strings.Join(result.CaseConflictKeys, ", ")) + } if result.BackupPath != "" { bootstrap.Printf("- Backup saved to: %s", result.BackupPath) } diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 0cc886f..befe961 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -581,6 +581,9 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram if len(cfgUpgradeResult.ExtraKeys) > 0 { fmt.Printf(" Preserved %d custom key(s) not present in the template.\n", len(cfgUpgradeResult.ExtraKeys)) } + if len(cfgUpgradeResult.CaseConflictKeys) > 0 { + fmt.Printf(" Preserved %d key(s) that differ only by case from the template.\n", len(cfgUpgradeResult.CaseConflictKeys)) + } } if cfgUpgradeResult.BackupPath != "" { fmt.Printf(" Backup saved to: %s\n", cfgUpgradeResult.BackupPath) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 1b31963..dbe39f8 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -37,8 +37,11 @@ type UpgradeResult struct { // user's config; template defaults were added for these. MissingKeys []string // ExtraKeys are keys that were present in the user's config but not in the - // template. They are preserved in a dedicated "Custom keys" section. + // template. They are preserved in-place. ExtraKeys []string + // CaseConflictKeys are keys that differ only by case from template keys. + // They are preserved in-place with their original casing. + CaseConflictKeys []string // Warnings includes non-fatal parsing or merge issues detected while upgrading. Warnings []string // PreservedValues is the number of existing key=value pairs from the user's @@ -123,7 +126,8 @@ func UpgradeConfigFile(configPath string) (*UpgradeResult, error) { // // It returns an UpgradeResult populated with: // - MissingKeys: keys that would be added from the template. -// - ExtraKeys: keys that would be preserved in the custom section. +// - ExtraKeys: keys not present in the template (preserved in-place). +// - CaseConflictKeys: keys that differ only by case from template keys. // - Changed: true if an upgrade would actually modify the file. // // BackupPath is always empty in dry-run mode. @@ -138,7 +142,7 @@ func PlanUpgradeConfigFile(configPath string) (*UpgradeResult, error) { } // computeConfigUpgrade performs the core merge logic and returns: -// - UpgradeResult (MissingKeys, ExtraKeys, Changed) +// - UpgradeResult (MissingKeys, ExtraKeys, CaseConflictKeys, Changed) // - newContent: the upgraded file content (only meaningful if Changed=true) // - originalContent: the original file content as read from disk func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, error) { @@ -227,7 +231,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er }) } - // 3. Compute missing and extra keys. + // 3. Compute missing, extra, and case-conflict keys. missingKeys := make([]string, 0) missingEntries := make([]templateEntry, 0) for _, entry := range templateEntries { @@ -245,18 +249,23 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er } extraKeys := make([]string, 0) + caseConflictKeys := make([]string, 0) for _, key := range userKeyOrder { upperKey := strings.ToUpper(key) - if _, ok := templateKeyByUpper[upperKey]; !ok { + templateKey, ok := templateKeyByUpper[upperKey] + if !ok { extraKeys = append(extraKeys, key) continue } if caseConflicts[upperKey] && caseMap[upperKey] != key { - extraKeys = append(extraKeys, key) + caseConflictKeys = append(caseConflictKeys, key) continue } - if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key && !caseConflicts[upperKey] { - warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved with original casing", key, templateKey)) + if templateKey != key { + caseConflictKeys = append(caseConflictKeys, key) + if !caseConflicts[upperKey] { + warnings = append(warnings, fmt.Sprintf("Key %q differs only by case from template key %q; preserved with original casing", key, templateKey)) + } } } @@ -273,6 +282,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er result.Changed = false result.Warnings = warnings result.ExtraKeys = extraKeys + result.CaseConflictKeys = caseConflictKeys result.PreservedValues = preserved return result, "", originalContent, nil } @@ -391,6 +401,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er result.MissingKeys = missingKeys result.ExtraKeys = extraKeys + result.CaseConflictKeys = caseConflictKeys result.PreservedValues = preserved result.Warnings = warnings result.Changed = true diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index 1045e66..e85d617 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -348,8 +348,8 @@ func TestPlanUpgradeConfigWarnsOnCaseCollision(t *testing.T) { if err != nil { t.Fatalf("PlanUpgradeConfigFile returned error: %v", err) } - if len(result.ExtraKeys) != 1 || result.ExtraKeys[0] != "backup_path" { - t.Fatalf("ExtraKeys = %v; want [backup_path]", result.ExtraKeys) + if len(result.CaseConflictKeys) != 1 || result.CaseConflictKeys[0] != "backup_path" { + t.Fatalf("CaseConflictKeys = %v; want [backup_path]", result.CaseConflictKeys) } warnings := strings.Join(result.Warnings, "\n") if !strings.Contains(warnings, "Duplicate keys differ only by case") { From 2cef3b675d63e63139267dedc8ef75f54f6a9798 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 21:25:43 +0100 Subject: [PATCH 6/7] Populate MissingKeys when none found Ensure UpgradeResult.MissingKeys is assigned in the branch where no missing keys are detected. Previously MissingKeys was left unset when len(missingKeys) == 0, which could lead to inconsistent result state for callers; this change sets it to the empty slice alongside other result fields in computeConfigUpgrade (internal/config/upgrade.go). --- internal/config/upgrade.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index dbe39f8..71d54ee 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -281,6 +281,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er if len(missingKeys) == 0 { result.Changed = false result.Warnings = warnings + result.MissingKeys = missingKeys result.ExtraKeys = extraKeys result.CaseConflictKeys = caseConflictKeys result.PreservedValues = preserved From 4dc10b540e30fbc93d61bacd586eb1b0d2ccf16e Mon Sep 17 00:00:00 2001 From: tis24dev Date: Wed, 4 Feb 2026 21:46:10 +0100 Subject: [PATCH 7/7] Translate comments and docs from Italian to English Translate Italian comments and user-facing text to English across the codebase and docs. Changes touch docs/RESTORE_GUIDE.md, internal/* files (backup/archiver.go, config/config.go, config/templates/backup.env, orchestrator/deps_test.go, orchestrator/extensions.go, pbs/namespaces.go, types/exit_codes.go). These are comment/text updates and template comment translations only; no functional logic was modified. --- docs/RESTORE_GUIDE.md | 8 ++++---- internal/backup/archiver.go | 6 +++--- internal/config/config.go | 16 ++++++++-------- internal/config/templates/backup.env | 8 ++++---- internal/orchestrator/deps_test.go | 2 +- internal/orchestrator/extensions.go | 8 ++++---- internal/pbs/namespaces.go | 6 +++--- internal/types/exit_codes.go | 2 +- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md index b2d25f3..7e0bcd9 100644 --- a/docs/RESTORE_GUIDE.md +++ b/docs/RESTORE_GUIDE.md @@ -2723,10 +2723,10 @@ A: Yes, in two ways: # Use BACKUP_PATH / SECONDARY_PATH or browse the mount directly ``` - In questo caso puoi: - - copiare i bundle dal mount (`/mnt/cloud/...`) nella cartella di backup locale; - - oppure indicare il path montato quando il tool chiede il percorso dei backup - (CLI) o sfogliare la directory montata prima di lanciare ProxSave. + In this case you can: + - copy the bundles from the mount (`/mnt/cloud/...`) into the local backup directory; + - or provide the mounted path when the tool asks for the backup location + (CLI) or browse the mounted directory before launching ProxSave. --- diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go index 1369edb..5a1c233 100644 --- a/internal/backup/archiver.go +++ b/internal/backup/archiver.go @@ -34,8 +34,8 @@ func defaultArchiverDeps() ArchiverDeps { } } -// WithLookPathOverride temporaneamente sostituisce lookPath (per i test) e -// restituisce una funzione di ripristino da invocare con defer. +// WithLookPathOverride temporarily replaces lookPath (for tests) and +// returns a restore function to call with defer. func WithLookPathOverride(fn func(string) (string, error)) func() { original := lookPath lookPath = fn @@ -71,7 +71,7 @@ type ArchiverConfig struct { ExcludePatterns []string } -// CompressionError rappresenta un errore di compressione esterna (xz/zstd) +// CompressionError represents an external compression error (xz/zstd). type CompressionError struct { Algorithm string Err error diff --git a/internal/config/config.go b/internal/config/config.go index 5f02609..9229672 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,7 +26,7 @@ var ( } ) -// Config contiene tutta la configurazione del sistema di backup +// Config contains the full backup system configuration. type Config struct { // General settings BackupEnabled bool @@ -254,7 +254,7 @@ type Config struct { raw map[string]string } -// LoadConfig legge il file di configurazione backup.env +// LoadConfig reads the backup.env configuration file. func LoadConfig(configPath string) (*Config, error) { if !utils.FileExists(configPath) { return nil, fmt.Errorf("configuration file not found: %s", configPath) @@ -326,10 +326,10 @@ func (c *Config) loadEnvOverrides() { } } -// parse interpreta i valori raw della configurazione -// Supporta sia il formato legacy che quello nuovo del backup.env -// parse interpreta i valori raw della configurazione -// Supporta sia il formato legacy che quello nuovo del backup.env +// parse interprets raw configuration values. +// It supports both legacy and new backup.env formats. +// parse interprets raw configuration values. +// It supports both legacy and new backup.env formats. func (c *Config) parse() error { c.parseGeneralSettings() c.parseCompressionSettings() @@ -1034,13 +1034,13 @@ func sanitizeMinDisk(value float64) float64 { return value } -// Get restituisce un valore raw dalla configurazione +// Get returns a raw value from the configuration. func (c *Config) Get(key string) (string, bool) { val, ok := c.raw[key] return val, ok } -// Set imposta un valore nella configurazione +// Set sets a value in the configuration. func (c *Config) Set(key, value string) { c.raw[key] = value } diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env index 1981b03..98cbcd0 100644 --- a/internal/config/templates/backup.env +++ b/internal/config/templates/backup.env @@ -353,10 +353,10 @@ BACKUP_BLACKLIST=" # ---------------------------------------------------------------------- # Security and permissions # ---------------------------------------------------------------------- -SKIP_PERMISSION_CHECK=false # true = non eseguire i controlli di permesso (solo test) -BACKUP_USER=backup # Utente proprietario delle directory di backup/log -BACKUP_GROUP=backup # Gruppo proprietario delle directory di backup/log -SET_BACKUP_PERMISSIONS=false # true = applica chown/chmod stile Bash su backup/log +SKIP_PERMISSION_CHECK=false # true = skip permission checks (test only) +BACKUP_USER=backup # Owner user for backup/log directories +BACKUP_GROUP=backup # Owner group for backup/log directories +SET_BACKUP_PERMISSIONS=false # true = apply Bash-style chown/chmod on backup/log # ============================================================================== # End file diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index 6676914..ba4d404 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -200,7 +200,7 @@ func commandKey(name string, args []string) string { return fmt.Sprintf("%s %s", name, strings.Join(args, " ")) } -// FakePrompter simula le scelte utente. +// FakePrompter simulates user choices. type FakePrompter struct { Mode RestoreMode Categories []Category diff --git a/internal/orchestrator/extensions.go b/internal/orchestrator/extensions.go index 0b2253c..a9e14cf 100644 --- a/internal/orchestrator/extensions.go +++ b/internal/orchestrator/extensions.go @@ -11,18 +11,18 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) -// StorageTarget rappresenta una destinazione esterna (es. storage secondario, cloud). +// StorageTarget represents an external destination (e.g., secondary storage, cloud). type StorageTarget interface { Sync(ctx context.Context, stats *BackupStats) error } -// NotificationChannel rappresenta un canale di notifica (es. Telegram, email). +// NotificationChannel represents a notification channel (e.g., Telegram, email). type NotificationChannel interface { Name() string Notify(ctx context.Context, stats *BackupStats) error } -// RegisterStorageTarget aggiunge una destinazione da eseguire dopo il backup. +// RegisterStorageTarget adds a destination to run after the backup. func (o *Orchestrator) RegisterStorageTarget(target StorageTarget) { if target == nil { return @@ -30,7 +30,7 @@ func (o *Orchestrator) RegisterStorageTarget(target StorageTarget) { o.storageTargets = append(o.storageTargets, target) } -// RegisterNotificationChannel aggiunge un canale di notifica da eseguire dopo il backup. +// RegisterNotificationChannel adds a notification channel to run after the backup. func (o *Orchestrator) RegisterNotificationChannel(channel NotificationChannel) { if channel == nil { return diff --git a/internal/pbs/namespaces.go b/internal/pbs/namespaces.go index 1a86d2f..92bf869 100644 --- a/internal/pbs/namespaces.go +++ b/internal/pbs/namespaces.go @@ -11,7 +11,7 @@ import ( var execCommand = exec.Command -// Namespace rappresenta un singolo namespace PBS. +// Namespace represents a single PBS namespace. type Namespace struct { Ns string `json:"ns"` Path string `json:"path"` @@ -24,8 +24,8 @@ type listNamespacesResponse struct { Data []Namespace `json:"data"` } -// ListNamespaces prova prima a usare la CLI PBS e, se fallisce, -// effettua il fallback su filesystem per dedurre i namespace. +// ListNamespaces tries the PBS CLI first and, if it fails, +// falls back to the filesystem to infer namespaces. func ListNamespaces(datastoreName, datastorePath string) ([]Namespace, bool, error) { if namespaces, err := listNamespacesViaCLI(datastoreName); err == nil { return namespaces, false, nil diff --git a/internal/types/exit_codes.go b/internal/types/exit_codes.go index 77bd125..439c5f5 100644 --- a/internal/types/exit_codes.go +++ b/internal/types/exit_codes.go @@ -89,7 +89,7 @@ func (e ExitCode) String() string { } } -// Int restituisce il codice di uscita come intero +// Int returns the exit code as an integer. func (e ExitCode) Int() int { return int(e) }