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 6a35f7b..befe961 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") @@ -578,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/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/config/upgrade.go b/internal/config/upgrade.go index c2779c8..71d54ee 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. @@ -31,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 @@ -117,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. @@ -132,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) { @@ -158,37 +168,38 @@ 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 + index int + } + + 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,138 +209,213 @@ 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], + index: len(templateEntries), + }) i = blockEnd continue } + templateEntries = append(templateEntries, templateEntry{ + key: key, + upper: upperKey, + lines: []string{line}, + index: len(templateEntries), + }) + } - 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, extra, and case-conflict 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) - + caseConflictKeys := make([]string, 0) for _, key := range userKeyOrder { - if processedUserKeys[key] { + upperKey := strings.ToUpper(key) + templateKey, ok := templateKeyByUpper[upperKey] + if !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 { + caseConflictKeys = append(caseConflictKeys, 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. - - upperKey := strings.ToUpper(key) - if templateKey, ok := templateKeyByUpper[upperKey]; ok && templateKey != key { + 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 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)) } } + } - values := userValues[key] - if len(values) == 0 { - continue + // Count preserved values + preserved := 0 + for key, values := range userValues { + if _, ok := templateKeyByUpper[strings.ToUpper(key)]; ok { + preserved += len(values) } - extraKeys = append(extraKeys, key) - for _, v := range values { - // Preserve USER's original key casing for extras - extraLines = append(extraLines, renderEnvValue(key, v)...) + } + + // If nothing is missing, do not rewrite the file. + if len(missingKeys) == 0 { + result.Changed = false + result.Warnings = warnings + result.MissingKeys = missingKeys + result.ExtraKeys = extraKeys + result.CaseConflictKeys = caseConflictKeys + 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 + } + 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... + ops := make([]insertOp, 0, len(missingEntries)) + unanchored := make([]templateEntry, 0) + for _, entry := range missingEntries { + insertIndex := appendIndex + if prev, ok := findPrevAnchor(entry.index); ok { + insertIndex = prev + } else { + unanchored = append(unanchored, entry) + continue + } + insertIndex = normalizeInsertIndex(insertIndex) + ops = append(ops, insertOp{ + index: insertIndex, + lines: entry.lines, + order: entry.index, + }) + } - newContent := strings.Join(newLines, lineEnding) - // Preserve trailing newline if template had one. - if strings.HasSuffix(normalizedTemplate, "\n") && !strings.HasSuffix(newContent, lineEnding) { - newContent += lineEnding + 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), + }) } + 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 } result.MissingKeys = missingKeys result.ExtraKeys = extraKeys + result.CaseConflictKeys = caseConflictKeys result.PreservedValues = preserved result.Warnings = warnings result.Changed = true 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 +444,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 +452,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 +462,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..e85d617 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) } @@ -351,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") { @@ -415,9 +412,41 @@ 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) + } + }) +} + +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) } }) } 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) }