Files
dbbackup/cmd/catalog.go
Alexander Renz f3a339d517
Some checks failed
CI/CD / Test (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
feat: catalog prune command - remove old/missing/failed entries
- Add 'dbbackup catalog prune' subcommand
- Support --missing flag to remove entries for deleted backup files
- Support --older-than duration (90d, 6m, 1y) for retention cleanup
- Support --status flag to remove failed/corrupted entries
- Add --dry-run flag to preview changes without deleting
- Add --database filter for targeted pruning
- Display detailed results with space freed estimates
- Implement PruneAdvanced() in catalog package
- Add parseDuration() helper for flexible time parsing

Quick Win #2 from TODO list
2026-01-31 05:45:23 +01:00

911 lines
24 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"dbbackup/internal/catalog"
"github.com/spf13/cobra"
)
var (
catalogDBPath string
catalogFormat string
catalogLimit int
catalogDatabase string
catalogStartDate string
catalogEndDate string
catalogInterval string
catalogVerbose bool
)
// catalogCmd represents the catalog command group
var catalogCmd = &cobra.Command{
Use: "catalog",
Short: "Backup catalog management",
Long: `Manage the backup catalog - a SQLite database tracking all backups.
The catalog provides:
- Searchable history of all backups
- Gap detection for backup schedules
- Statistics and reporting
- Integration with DR drill testing
Examples:
# Sync backups from a directory
dbbackup catalog sync /backups
# List all backups
dbbackup catalog list
# Show catalog statistics
dbbackup catalog stats
# Detect gaps in backup schedule
dbbackup catalog gaps mydb --interval 24h
# Search backups
dbbackup catalog search --database mydb --after 2024-01-01`,
}
// catalogSyncCmd syncs backups from directory
var catalogSyncCmd = &cobra.Command{
Use: "sync [directory]",
Short: "Sync backups from directory into catalog",
Long: `Scan a directory for backup files and import them into the catalog.
This command:
- Finds all .meta.json files
- Imports backup metadata into SQLite catalog
- Detects removed backups
- Updates changed entries
Examples:
# Sync from backup directory
dbbackup catalog sync /backups
# Sync with verbose output
dbbackup catalog sync /backups --verbose`,
Args: cobra.MinimumNArgs(1),
RunE: runCatalogSync,
}
// catalogListCmd lists backups
var catalogListCmd = &cobra.Command{
Use: "list",
Short: "List backups in catalog",
Long: `List all backups in the catalog with optional filtering.
Examples:
# List all backups
dbbackup catalog list
# List backups for specific database
dbbackup catalog list --database mydb
# List last 10 backups
dbbackup catalog list --limit 10
# Output as JSON
dbbackup catalog list --format json`,
RunE: runCatalogList,
}
// catalogStatsCmd shows statistics
var catalogStatsCmd = &cobra.Command{
Use: "stats",
Short: "Show catalog statistics",
Long: `Display comprehensive backup statistics.
Shows:
- Total backup count and size
- Backups by database
- Backups by type and status
- Verification and drill test coverage
Examples:
# Show overall stats
dbbackup catalog stats
# Stats for specific database
dbbackup catalog stats --database mydb
# Output as JSON
dbbackup catalog stats --format json`,
RunE: runCatalogStats,
}
// catalogGapsCmd detects schedule gaps
var catalogGapsCmd = &cobra.Command{
Use: "gaps [database]",
Short: "Detect gaps in backup schedule",
Long: `Analyze backup history and detect schedule gaps.
This helps identify:
- Missed backups
- Schedule irregularities
- RPO violations
Examples:
# Check all databases for gaps (24h expected interval)
dbbackup catalog gaps
# Check specific database with custom interval
dbbackup catalog gaps mydb --interval 6h
# Check gaps in date range
dbbackup catalog gaps --after 2024-01-01 --before 2024-02-01`,
RunE: runCatalogGaps,
}
// catalogSearchCmd searches backups
var catalogSearchCmd = &cobra.Command{
Use: "search",
Short: "Search backups in catalog",
Long: `Search for backups matching specific criteria.
Examples:
# Search by database name (supports wildcards)
dbbackup catalog search --database "prod*"
# Search by date range
dbbackup catalog search --after 2024-01-01 --before 2024-02-01
# Search verified backups only
dbbackup catalog search --verified
# Search encrypted backups
dbbackup catalog search --encrypted`,
RunE: runCatalogSearch,
}
// catalogInfoCmd shows entry details
var catalogInfoCmd = &cobra.Command{
Use: "info [backup-path]",
Short: "Show detailed info for a backup",
Long: `Display detailed information about a specific backup.
Examples:
# Show info by path
dbbackup catalog info /backups/mydb_20240115.dump.gz`,
Args: cobra.ExactArgs(1),
RunE: runCatalogInfo,
}
var catalogPruneCmd = &cobra.Command{
Use: "prune",
Short: "Remove old or invalid entries from catalog",
Long: `Clean up the catalog by removing entries that meet specified criteria.
This command can remove:
- Entries for backups that no longer exist on disk
- Entries older than a specified retention period
- Failed or corrupted backups
- Entries marked as deleted
Examples:
# Remove entries for missing backup files
dbbackup catalog prune --missing
# Remove entries older than 90 days
dbbackup catalog prune --older-than 90d
# Remove failed backups
dbbackup catalog prune --status failed
# Dry run (preview without deleting)
dbbackup catalog prune --missing --dry-run
# Combined: remove missing and old entries
dbbackup catalog prune --missing --older-than 30d`,
RunE: runCatalogPrune,
}
func init() {
rootCmd.AddCommand(catalogCmd)
// Default catalog path
defaultCatalogPath := filepath.Join(getDefaultConfigDir(), "catalog.db")
// Global catalog flags
catalogCmd.PersistentFlags().StringVar(&catalogDBPath, "catalog-db", defaultCatalogPath,
"Path to catalog SQLite database")
catalogCmd.PersistentFlags().StringVar(&catalogFormat, "format", "table",
"Output format: table, json, csv")
// Add subcommands
catalogCmd.AddCommand(catalogSyncCmd)
catalogCmd.AddCommand(catalogListCmd)
catalogCmd.AddCommand(catalogStatsCmd)
catalogCmd.AddCommand(catalogGapsCmd)
catalogCmd.AddCommand(catalogSearchCmd)
catalogCmd.AddCommand(catalogInfoCmd)
catalogCmd.AddCommand(catalogPruneCmd)
// Sync flags
catalogSyncCmd.Flags().BoolVarP(&catalogVerbose, "verbose", "v", false, "Show detailed output")
// List flags
catalogListCmd.Flags().IntVar(&catalogLimit, "limit", 50, "Maximum entries to show")
catalogListCmd.Flags().StringVar(&catalogDatabase, "database", "", "Filter by database name")
// Stats flags
catalogStatsCmd.Flags().StringVar(&catalogDatabase, "database", "", "Show stats for specific database")
// Gaps flags
catalogGapsCmd.Flags().StringVar(&catalogInterval, "interval", "24h", "Expected backup interval")
catalogGapsCmd.Flags().StringVar(&catalogStartDate, "after", "", "Start date (YYYY-MM-DD)")
catalogGapsCmd.Flags().StringVar(&catalogEndDate, "before", "", "End date (YYYY-MM-DD)")
// Search flags
catalogSearchCmd.Flags().StringVar(&catalogDatabase, "database", "", "Filter by database name (supports wildcards)")
catalogSearchCmd.Flags().StringVar(&catalogStartDate, "after", "", "Backups after date (YYYY-MM-DD)")
catalogSearchCmd.Flags().StringVar(&catalogEndDate, "before", "", "Backups before date (YYYY-MM-DD)")
catalogSearchCmd.Flags().IntVar(&catalogLimit, "limit", 100, "Maximum results")
catalogSearchCmd.Flags().Bool("verified", false, "Only verified backups")
catalogSearchCmd.Flags().Bool("encrypted", false, "Only encrypted backups")
catalogSearchCmd.Flags().Bool("drill-tested", false, "Only drill-tested backups")
// Prune flags
catalogPruneCmd.Flags().Bool("missing", false, "Remove entries for missing backup files")
catalogPruneCmd.Flags().String("older-than", "", "Remove entries older than duration (e.g., 90d, 6m, 1y)")
catalogPruneCmd.Flags().String("status", "", "Remove entries with specific status (failed, corrupted, deleted)")
catalogPruneCmd.Flags().Bool("dry-run", false, "Preview changes without actually deleting")
catalogPruneCmd.Flags().StringVar(&catalogDatabase, "database", "", "Only prune entries for specific database")
}
func getDefaultConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".dbbackup")
}
func openCatalog() (*catalog.SQLiteCatalog, error) {
return catalog.NewSQLiteCatalog(catalogDBPath)
}
func runCatalogSync(cmd *cobra.Command, args []string) error {
dir := args[0]
// Validate directory
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("directory not found: %s", dir)
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", dir)
}
absDir, _ := filepath.Abs(dir)
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
fmt.Printf("[DIR] Syncing backups from: %s\n", absDir)
fmt.Printf("[STATS] Catalog database: %s\n\n", catalogDBPath)
ctx := context.Background()
result, err := cat.SyncFromDirectory(ctx, absDir)
if err != nil {
return err
}
// Update last sync time
cat.SetLastSync(ctx)
// Show results
fmt.Printf("=====================================================\n")
fmt.Printf(" Sync Results\n")
fmt.Printf("=====================================================\n")
fmt.Printf(" [OK] Added: %d\n", result.Added)
fmt.Printf(" [SYNC] Updated: %d\n", result.Updated)
fmt.Printf(" [DEL] Removed: %d\n", result.Removed)
if result.Skipped > 0 {
fmt.Printf(" [SKIP] Skipped: %d (legacy files without metadata)\n", result.Skipped)
}
if result.Errors > 0 {
fmt.Printf(" [FAIL] Errors: %d\n", result.Errors)
}
fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
fmt.Printf("=====================================================\n")
// Show legacy backup warning
if result.LegacyWarning != "" {
fmt.Printf("\n[WARN] %s\n", result.LegacyWarning)
}
// Show details if verbose
if catalogVerbose && len(result.Details) > 0 {
fmt.Printf("\nDetails:\n")
for _, detail := range result.Details {
fmt.Printf(" %s\n", detail)
}
}
return nil
}
func runCatalogList(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
query := &catalog.SearchQuery{
Database: catalogDatabase,
Limit: catalogLimit,
OrderBy: "created_at",
OrderDesc: true,
}
entries, err := cat.Search(ctx, query)
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Println("No backups in catalog. Run 'dbbackup catalog sync <directory>' to import backups.")
return nil
}
if catalogFormat == "json" {
data, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(data))
return nil
}
// Table format
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
"DATABASE", "TYPE", "SIZE", "CREATED", "STATUS", "PATH")
fmt.Println(strings.Repeat("-", 120))
for _, entry := range entries {
dbName := truncateString(entry.Database, 28)
backupPath := truncateString(filepath.Base(entry.BackupPath), 40)
status := string(entry.Status)
if entry.VerifyValid != nil && *entry.VerifyValid {
status = "[OK] verified"
}
if entry.DrillSuccess != nil && *entry.DrillSuccess {
status = "[OK] tested"
}
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
dbName,
entry.DatabaseType,
catalog.FormatSize(entry.SizeBytes),
entry.CreatedAt.Format("2006-01-02 15:04"),
status,
backupPath,
)
}
fmt.Printf("\nShowing %d of %d total backups\n", len(entries), len(entries))
return nil
}
func runCatalogStats(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
var stats *catalog.Stats
if catalogDatabase != "" {
stats, err = cat.StatsByDatabase(ctx, catalogDatabase)
} else {
stats, err = cat.Stats(ctx)
}
if err != nil {
return err
}
if catalogFormat == "json" {
data, _ := json.MarshalIndent(stats, "", " ")
fmt.Println(string(data))
return nil
}
// Table format
fmt.Printf("=====================================================\n")
if catalogDatabase != "" {
fmt.Printf(" Catalog Statistics: %s\n", catalogDatabase)
} else {
fmt.Printf(" Catalog Statistics\n")
}
fmt.Printf("=====================================================\n\n")
fmt.Printf("[STATS] Total Backups: %d\n", stats.TotalBackups)
fmt.Printf("[SAVE] Total Size: %s\n", stats.TotalSizeHuman)
fmt.Printf("[SIZE] Average Size: %s\n", catalog.FormatSize(stats.AvgSize))
fmt.Printf("[TIME] Average Duration: %.1fs\n", stats.AvgDuration)
fmt.Printf("[OK] Verified: %d\n", stats.VerifiedCount)
fmt.Printf("[TEST] Drill Tested: %d\n", stats.DrillTestedCount)
if stats.OldestBackup != nil {
fmt.Printf("📅 Oldest Backup: %s\n", stats.OldestBackup.Format("2006-01-02 15:04"))
}
if stats.NewestBackup != nil {
fmt.Printf("📅 Newest Backup: %s\n", stats.NewestBackup.Format("2006-01-02 15:04"))
}
if len(stats.ByDatabase) > 0 && catalogDatabase == "" {
fmt.Printf("\n[DIR] By Database:\n")
for db, count := range stats.ByDatabase {
fmt.Printf(" %-30s %d\n", db, count)
}
}
if len(stats.ByType) > 0 {
fmt.Printf("\n[PKG] By Type:\n")
for t, count := range stats.ByType {
fmt.Printf(" %-15s %d\n", t, count)
}
}
if len(stats.ByStatus) > 0 {
fmt.Printf("\n[LOG] By Status:\n")
for s, count := range stats.ByStatus {
fmt.Printf(" %-15s %d\n", s, count)
}
}
fmt.Printf("\n=====================================================\n")
return nil
}
func runCatalogGaps(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
// Parse interval
interval, err := time.ParseDuration(catalogInterval)
if err != nil {
return fmt.Errorf("invalid interval: %w", err)
}
config := &catalog.GapDetectionConfig{
ExpectedInterval: interval,
Tolerance: interval / 4, // 25% tolerance
RPOThreshold: interval * 2, // 2x interval = critical
}
// Parse date range
if catalogStartDate != "" {
t, err := time.Parse("2006-01-02", catalogStartDate)
if err != nil {
return fmt.Errorf("invalid start date: %w", err)
}
config.StartDate = &t
}
if catalogEndDate != "" {
t, err := time.Parse("2006-01-02", catalogEndDate)
if err != nil {
return fmt.Errorf("invalid end date: %w", err)
}
config.EndDate = &t
}
var allGaps map[string][]*catalog.Gap
if len(args) > 0 {
// Specific database
database := args[0]
gaps, err := cat.DetectGaps(ctx, database, config)
if err != nil {
return err
}
if len(gaps) > 0 {
allGaps = map[string][]*catalog.Gap{database: gaps}
}
} else {
// All databases
allGaps, err = cat.DetectAllGaps(ctx, config)
if err != nil {
return err
}
}
if catalogFormat == "json" {
data, _ := json.MarshalIndent(allGaps, "", " ")
fmt.Println(string(data))
return nil
}
if len(allGaps) == 0 {
fmt.Printf("[OK] No backup gaps detected (expected interval: %s)\n", interval)
return nil
}
fmt.Printf("=====================================================\n")
fmt.Printf(" Backup Gaps Detected (expected interval: %s)\n", interval)
fmt.Printf("=====================================================\n\n")
totalGaps := 0
criticalGaps := 0
for database, gaps := range allGaps {
fmt.Printf("[DIR] %s (%d gaps)\n", database, len(gaps))
for _, gap := range gaps {
totalGaps++
icon := "[INFO]"
switch gap.Severity {
case catalog.SeverityWarning:
icon = "[WARN]"
case catalog.SeverityCritical:
icon = "🚨"
criticalGaps++
}
fmt.Printf(" %s %s\n", icon, gap.Description)
fmt.Printf(" Gap: %s → %s (%s)\n",
gap.GapStart.Format("2006-01-02 15:04"),
gap.GapEnd.Format("2006-01-02 15:04"),
catalog.FormatDuration(gap.Duration))
fmt.Printf(" Expected at: %s\n", gap.ExpectedAt.Format("2006-01-02 15:04"))
}
fmt.Println()
}
fmt.Printf("=====================================================\n")
fmt.Printf("Total: %d gaps detected", totalGaps)
if criticalGaps > 0 {
fmt.Printf(" (%d critical)", criticalGaps)
}
fmt.Println()
return nil
}
func runCatalogSearch(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
query := &catalog.SearchQuery{
Database: catalogDatabase,
Limit: catalogLimit,
OrderBy: "created_at",
OrderDesc: true,
}
// Parse date range
if catalogStartDate != "" {
t, err := time.Parse("2006-01-02", catalogStartDate)
if err != nil {
return fmt.Errorf("invalid start date: %w", err)
}
query.StartDate = &t
}
if catalogEndDate != "" {
t, err := time.Parse("2006-01-02", catalogEndDate)
if err != nil {
return fmt.Errorf("invalid end date: %w", err)
}
query.EndDate = &t
}
// Boolean filters
if verified, _ := cmd.Flags().GetBool("verified"); verified {
t := true
query.Verified = &t
}
if encrypted, _ := cmd.Flags().GetBool("encrypted"); encrypted {
t := true
query.Encrypted = &t
}
if drillTested, _ := cmd.Flags().GetBool("drill-tested"); drillTested {
t := true
query.DrillTested = &t
}
entries, err := cat.Search(ctx, query)
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Println("No matching backups found.")
return nil
}
if catalogFormat == "json" {
data, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(data))
return nil
}
fmt.Printf("Found %d matching backups:\n\n", len(entries))
for _, entry := range entries {
fmt.Printf("[DIR] %s\n", entry.Database)
fmt.Printf(" Path: %s\n", entry.BackupPath)
fmt.Printf(" Type: %s | Size: %s | Created: %s\n",
entry.DatabaseType,
catalog.FormatSize(entry.SizeBytes),
entry.CreatedAt.Format("2006-01-02 15:04:05"))
if entry.Encrypted {
fmt.Printf(" [LOCK] Encrypted\n")
}
if entry.VerifyValid != nil && *entry.VerifyValid {
fmt.Printf(" [OK] Verified: %s\n", entry.VerifiedAt.Format("2006-01-02 15:04"))
}
if entry.DrillSuccess != nil && *entry.DrillSuccess {
fmt.Printf(" [TEST] Drill Tested: %s\n", entry.DrillTestedAt.Format("2006-01-02 15:04"))
}
fmt.Println()
}
return nil
}
func runCatalogInfo(cmd *cobra.Command, args []string) error {
backupPath := args[0]
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
// Try absolute path
absPath, _ := filepath.Abs(backupPath)
entry, err := cat.GetByPath(ctx, absPath)
if err != nil {
return err
}
if entry == nil {
// Try as provided
entry, err = cat.GetByPath(ctx, backupPath)
if err != nil {
return err
}
}
if entry == nil {
return fmt.Errorf("backup not found in catalog: %s", backupPath)
}
if catalogFormat == "json" {
data, _ := json.MarshalIndent(entry, "", " ")
fmt.Println(string(data))
return nil
}
fmt.Printf("=====================================================\n")
fmt.Printf(" Backup Details\n")
fmt.Printf("=====================================================\n\n")
fmt.Printf("[DIR] Database: %s\n", entry.Database)
fmt.Printf("🔧 Type: %s\n", entry.DatabaseType)
fmt.Printf("[HOST] Host: %s:%d\n", entry.Host, entry.Port)
fmt.Printf("📂 Path: %s\n", entry.BackupPath)
fmt.Printf("[PKG] Backup Type: %s\n", entry.BackupType)
fmt.Printf("[SAVE] Size: %s (%d bytes)\n", catalog.FormatSize(entry.SizeBytes), entry.SizeBytes)
fmt.Printf("[HASH] SHA256: %s\n", entry.SHA256)
fmt.Printf("📅 Created: %s\n", entry.CreatedAt.Format("2006-01-02 15:04:05 MST"))
fmt.Printf("[TIME] Duration: %.2fs\n", entry.Duration)
fmt.Printf("[LOG] Status: %s\n", entry.Status)
if entry.Compression != "" {
fmt.Printf("[PKG] Compression: %s\n", entry.Compression)
}
if entry.Encrypted {
fmt.Printf("[LOCK] Encrypted: yes\n")
}
if entry.CloudLocation != "" {
fmt.Printf("[CLOUD] Cloud: %s\n", entry.CloudLocation)
}
if entry.RetentionPolicy != "" {
fmt.Printf("📆 Retention: %s\n", entry.RetentionPolicy)
}
fmt.Printf("\n[STATS] Verification:\n")
if entry.VerifiedAt != nil {
status := "[FAIL] Failed"
if entry.VerifyValid != nil && *entry.VerifyValid {
status = "[OK] Valid"
}
fmt.Printf(" Status: %s (checked %s)\n", status, entry.VerifiedAt.Format("2006-01-02 15:04"))
} else {
fmt.Printf(" Status: [WAIT] Not verified\n")
}
fmt.Printf("\n[TEST] DR Drill Test:\n")
if entry.DrillTestedAt != nil {
status := "[FAIL] Failed"
if entry.DrillSuccess != nil && *entry.DrillSuccess {
status = "[OK] Passed"
}
fmt.Printf(" Status: %s (tested %s)\n", status, entry.DrillTestedAt.Format("2006-01-02 15:04"))
} else {
fmt.Printf(" Status: [WAIT] Not tested\n")
}
if len(entry.Metadata) > 0 {
fmt.Printf("\n[NOTE] Additional Metadata:\n")
for k, v := range entry.Metadata {
fmt.Printf(" %s: %s\n", k, v)
}
}
fmt.Printf("\n=====================================================\n")
return nil
}
func runCatalogPrune(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
// Parse flags
missing, _ := cmd.Flags().GetBool("missing")
olderThan, _ := cmd.Flags().GetString("older-than")
status, _ := cmd.Flags().GetString("status")
dryRun, _ := cmd.Flags().GetBool("dry-run")
// Validate that at least one criterion is specified
if !missing && olderThan == "" && status == "" {
return fmt.Errorf("at least one prune criterion must be specified (--missing, --older-than, or --status)")
}
// Parse olderThan duration
var cutoffTime *time.Time
if olderThan != "" {
duration, err := parseDuration(olderThan)
if err != nil {
return fmt.Errorf("invalid duration: %w", err)
}
t := time.Now().Add(-duration)
cutoffTime = &t
}
// Validate status
if status != "" && status != "failed" && status != "corrupted" && status != "deleted" {
return fmt.Errorf("invalid status: %s (must be: failed, corrupted, or deleted)", status)
}
pruneConfig := &catalog.PruneConfig{
CheckMissing: missing,
OlderThan: cutoffTime,
Status: status,
Database: catalogDatabase,
DryRun: dryRun,
}
fmt.Printf("=====================================================\n")
if dryRun {
fmt.Printf(" Catalog Prune (DRY RUN)\n")
} else {
fmt.Printf(" Catalog Prune\n")
}
fmt.Printf("=====================================================\n\n")
if catalogDatabase != "" {
fmt.Printf("[DIR] Database filter: %s\n", catalogDatabase)
}
if missing {
fmt.Printf("[CHK] Checking for missing backup files...\n")
}
if cutoffTime != nil {
fmt.Printf("[TIME] Removing entries older than: %s (%s)\n", cutoffTime.Format("2006-01-02"), olderThan)
}
if status != "" {
fmt.Printf("[LOG] Removing entries with status: %s\n", status)
}
fmt.Println()
result, err := cat.PruneAdvanced(ctx, pruneConfig)
if err != nil {
return err
}
if result.TotalChecked == 0 {
fmt.Printf("[INFO] No entries found matching criteria\n")
return nil
}
// Show results
fmt.Printf("=====================================================\n")
fmt.Printf(" Prune Results\n")
fmt.Printf("=====================================================\n")
fmt.Printf(" [CHK] Checked: %d entries\n", result.TotalChecked)
if dryRun {
fmt.Printf(" [WAIT] Would remove: %d entries\n", result.Removed)
} else {
fmt.Printf(" [DEL] Removed: %d entries\n", result.Removed)
}
fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
fmt.Printf("=====================================================\n")
if len(result.Details) > 0 {
fmt.Printf("\nRemoved entries:\n")
for _, detail := range result.Details {
fmt.Printf(" • %s\n", detail)
}
}
if result.SpaceFreed > 0 {
fmt.Printf("\n[SAVE] Estimated space freed: %s\n", catalog.FormatSize(result.SpaceFreed))
}
if dryRun {
fmt.Printf("\n[INFO] This was a dry run. Run without --dry-run to actually delete entries.\n")
}
return nil
}
// parseDuration extends time.ParseDuration to support days, months, years
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'd': // days
multiplier = 24 * time.Hour
case 'w': // weeks
multiplier = 7 * 24 * time.Hour
case 'm': // months (approximate)
multiplier = 30 * 24 * time.Hour
case 'y': // years (approximate)
multiplier = 365 * 24 * time.Hour
default:
// Try standard time.ParseDuration
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", value)
}
return time.Duration(num) * multiplier, nil
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}