package config import ( "fmt" "os" "path/filepath" "sort" "strings" "time" ) const backupDirName = ".backups" // BackupInfo holds metadata about a single backup. type BackupInfo struct { Version string `json:"version"` Timestamp string `json:"timestamp"` Path string `json:"path"` } // CreateBackup copies the current file into a timestamped backup before modification. // Returns the backup version string. If the source file doesn't exist, returns "" with no error. func CreateBackup(baseDir, relPath string, maxBackups int) (string, error) { srcPath, err := FullPath(baseDir, relPath) if err != nil { return "", err } if _, err := os.Stat(srcPath); os.IsNotExist(err) { return "", nil } data, err := os.ReadFile(srcPath) if err != nil { return "", fmt.Errorf("read source for backup: %w", err) } backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath))) if err := os.MkdirAll(backupDir, 0o755); err != nil { return "", fmt.Errorf("create backup dir: %w", err) } version := time.Now().UTC().Format("20060102T150405Z") base := filepath.Base(relPath) ext := filepath.Ext(base) name := strings.TrimSuffix(base, ext) backupName := fmt.Sprintf("%s.%s%s", name, version, ext) backupPath := filepath.Join(backupDir, backupName) if err := AtomicWrite(backupPath, data, 0o644); err != nil { return "", fmt.Errorf("write backup: %w", err) } if err := pruneBackups(backupDir, name, ext, maxBackups); err != nil { return "", fmt.Errorf("prune backups: %w", err) } return version, nil } // ListBackups returns all backup versions for a given config file, newest first. func ListBackups(baseDir, relPath string) ([]BackupInfo, error) { if err := ValidatePath(relPath); err != nil { return nil, err } backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath))) base := filepath.Base(relPath) ext := filepath.Ext(base) name := strings.TrimSuffix(base, ext) prefix := name + "." entries, err := os.ReadDir(backupDir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("read backup dir: %w", err) } var backups []BackupInfo for _, e := range entries { fname := e.Name() if !strings.HasPrefix(fname, prefix) || !strings.HasSuffix(fname, ext) { continue } version := strings.TrimSuffix(strings.TrimPrefix(fname, prefix), ext) backups = append(backups, BackupInfo{ Version: version, Timestamp: version, Path: filepath.Join(backupDir, fname), }) } sort.Slice(backups, func(i, j int) bool { return backups[i].Version > backups[j].Version }) return backups, nil } // Rollback restores a config file to the given backup version. func Rollback(baseDir, relPath, version string) error { if err := ValidatePath(relPath); err != nil { return err } backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath))) base := filepath.Base(relPath) ext := filepath.Ext(base) name := strings.TrimSuffix(base, ext) backupName := fmt.Sprintf("%s.%s%s", name, version, ext) backupPath := filepath.Join(backupDir, backupName) data, err := os.ReadFile(backupPath) if err != nil { return fmt.Errorf("read backup %q: %w", version, err) } targetPath, err := FullPath(baseDir, relPath) if err != nil { return err } return AtomicWrite(targetPath, data, 0o644) } // pruneBackups removes oldest backups exceeding maxBackups. func pruneBackups(backupDir, name, ext string, maxBackups int) error { if maxBackups <= 0 { return nil } prefix := name + "." entries, err := os.ReadDir(backupDir) if err != nil { return err } var matching []string for _, e := range entries { if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ext) { matching = append(matching, e.Name()) } } sort.Strings(matching) if len(matching) > maxBackups { for _, old := range matching[:len(matching)-maxBackups] { os.Remove(filepath.Join(backupDir, old)) } } return nil }