package cmd import ( "context" "encoding/json" "fmt" "math" "os" "strings" "text/tabwriter" "time" "dbbackup/internal/catalog" "github.com/spf13/cobra" ) var forecastCmd = &cobra.Command{ Use: "forecast [database]", Short: "Predict future disk space requirements", Long: `Analyze backup growth patterns and predict future disk space needs. This command helps with: - Capacity planning (when will we run out of space?) - Budget forecasting (how much storage to provision?) - Growth trend analysis (is growth accelerating?) - Alert thresholds (when to add capacity?) Uses historical backup data to calculate: - Average daily growth rate - Growth acceleration/deceleration - Time until space limit reached - Projected size at future dates Examples: # Forecast for specific database dbbackup forecast mydb # Forecast all databases dbbackup forecast --all # Show projection for 90 days dbbackup forecast mydb --days 90 # Set capacity limit (alert when approaching) dbbackup forecast mydb --limit 100GB # JSON output for automation dbbackup forecast mydb --format json`, Args: cobra.MaximumNArgs(1), RunE: runForecast, } var ( forecastFormat string forecastAll bool forecastDays int forecastLimitSize string ) type ForecastResult struct { Database string `json:"database"` CurrentSize int64 `json:"current_size_bytes"` TotalBackups int `json:"total_backups"` OldestBackup time.Time `json:"oldest_backup"` NewestBackup time.Time `json:"newest_backup"` ObservationPeriod time.Duration `json:"observation_period_seconds"` DailyGrowthRate float64 `json:"daily_growth_bytes"` DailyGrowthPct float64 `json:"daily_growth_percent"` Projections []ForecastProjection `json:"projections"` TimeToLimit *time.Duration `json:"time_to_limit_seconds,omitempty"` SizeAtLimit *time.Time `json:"date_reaching_limit,omitempty"` Confidence string `json:"confidence"` // "high", "medium", "low" } type ForecastProjection struct { Days int `json:"days_from_now"` Date time.Time `json:"date"` PredictedSize int64 `json:"predicted_size_bytes"` Confidence float64 `json:"confidence_percent"` } func init() { rootCmd.AddCommand(forecastCmd) forecastCmd.Flags().StringVar(&forecastFormat, "format", "table", "Output format (table, json)") forecastCmd.Flags().BoolVar(&forecastAll, "all", false, "Show forecast for all databases") forecastCmd.Flags().IntVar(&forecastDays, "days", 90, "Days to project into future") forecastCmd.Flags().StringVar(&forecastLimitSize, "limit", "", "Capacity limit (e.g., '100GB', '1TB')") } func runForecast(cmd *cobra.Command, args []string) error { cat, err := openCatalog() if err != nil { return err } defer cat.Close() ctx := context.Background() var forecasts []*ForecastResult if forecastAll || len(args) == 0 { // Get all databases databases, err := cat.ListDatabases(ctx) if err != nil { return err } for _, db := range databases { forecast, err := calculateForecast(ctx, cat, db) if err != nil { return err } if forecast != nil { forecasts = append(forecasts, forecast) } } } else { database := args[0] forecast, err := calculateForecast(ctx, cat, database) if err != nil { return err } if forecast != nil { forecasts = append(forecasts, forecast) } } if len(forecasts) == 0 { fmt.Println("No forecast data available.") fmt.Println("\nRun 'dbbackup catalog sync ' to import backups.") return nil } // Parse limit if provided var limitBytes int64 if forecastLimitSize != "" { limitBytes, err = parseSize(forecastLimitSize) if err != nil { return fmt.Errorf("invalid limit size: %w", err) } } // Output results if forecastFormat == "json" { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(forecasts) } // Table output for i, forecast := range forecasts { if i > 0 { fmt.Println() } printForecast(forecast, limitBytes) } return nil } func calculateForecast(ctx context.Context, cat *catalog.SQLiteCatalog, database string) (*ForecastResult, error) { // Get all backups for this database query := &catalog.SearchQuery{ Database: database, Limit: 1000, OrderBy: "created_at", OrderDesc: false, } entries, err := cat.Search(ctx, query) if err != nil { return nil, err } if len(entries) < 2 { return nil, nil // Need at least 2 backups for growth rate } // Calculate metrics var totalSize int64 oldest := entries[0].CreatedAt newest := entries[len(entries)-1].CreatedAt for _, entry := range entries { totalSize += entry.SizeBytes } // Calculate observation period observationPeriod := newest.Sub(oldest) if observationPeriod == 0 { return nil, nil } // Calculate daily growth rate firstSize := entries[0].SizeBytes lastSize := entries[len(entries)-1].SizeBytes sizeDelta := float64(lastSize - firstSize) daysObserved := observationPeriod.Hours() / 24 dailyGrowthRate := sizeDelta / daysObserved // Calculate daily growth percentage var dailyGrowthPct float64 if firstSize > 0 { dailyGrowthPct = (dailyGrowthRate / float64(firstSize)) * 100 } // Determine confidence based on sample size and consistency confidence := determineConfidence(entries, dailyGrowthRate) // Generate projections projections := make([]ForecastProjection, 0) projectionDates := []int{7, 30, 60, 90, 180, 365} if forecastDays > 0 { // Use user-specified days projectionDates = []int{forecastDays} if forecastDays > 30 { projectionDates = []int{7, 30, forecastDays} } } for _, days := range projectionDates { if days > 365 && forecastDays == 90 { continue // Skip longer projections unless explicitly requested } predictedSize := lastSize + int64(dailyGrowthRate*float64(days)) if predictedSize < 0 { predictedSize = 0 } // Confidence decreases with time confidencePct := calculateConfidence(days, confidence) projections = append(projections, ForecastProjection{ Days: days, Date: newest.Add(time.Duration(days) * 24 * time.Hour), PredictedSize: predictedSize, Confidence: confidencePct, }) } result := &ForecastResult{ Database: database, CurrentSize: lastSize, TotalBackups: len(entries), OldestBackup: oldest, NewestBackup: newest, ObservationPeriod: observationPeriod, DailyGrowthRate: dailyGrowthRate, DailyGrowthPct: dailyGrowthPct, Projections: projections, Confidence: confidence, } return result, nil } func determineConfidence(entries []*catalog.Entry, avgGrowth float64) string { if len(entries) < 5 { return "low" } if len(entries) < 15 { return "medium" } // Calculate variance in growth rates var variance float64 for i := 1; i < len(entries); i++ { timeDiff := entries[i].CreatedAt.Sub(entries[i-1].CreatedAt).Hours() / 24 if timeDiff == 0 { continue } sizeDiff := float64(entries[i].SizeBytes - entries[i-1].SizeBytes) growthRate := sizeDiff / timeDiff variance += math.Pow(growthRate-avgGrowth, 2) } variance /= float64(len(entries) - 1) stdDev := math.Sqrt(variance) // If standard deviation is more than 50% of average growth, confidence is low if stdDev > math.Abs(avgGrowth)*0.5 { return "medium" } return "high" } func calculateConfidence(daysAhead int, baseConfidence string) float64 { var base float64 switch baseConfidence { case "high": base = 95.0 case "medium": base = 75.0 case "low": base = 50.0 } // Decay confidence over time (10% per 30 days) decay := float64(daysAhead) / 30.0 * 10.0 confidence := base - decay if confidence < 30 { confidence = 30 } return confidence } func printForecast(f *ForecastResult, limitBytes int64) { fmt.Printf("[FORECAST] %s\n", f.Database) fmt.Println(strings.Repeat("=", 60)) fmt.Printf("\n[CURRENT STATE]\n") fmt.Printf(" Size: %s\n", catalog.FormatSize(f.CurrentSize)) fmt.Printf(" Backups: %d backups\n", f.TotalBackups) fmt.Printf(" Observed: %s (%.0f days)\n", formatForecastDuration(f.ObservationPeriod), f.ObservationPeriod.Hours()/24) fmt.Printf("\n[GROWTH RATE]\n") if f.DailyGrowthRate > 0 { fmt.Printf(" Daily: +%s/day (%.2f%%/day)\n", catalog.FormatSize(int64(f.DailyGrowthRate)), f.DailyGrowthPct) fmt.Printf(" Weekly: +%s/week\n", catalog.FormatSize(int64(f.DailyGrowthRate*7))) fmt.Printf(" Monthly: +%s/month\n", catalog.FormatSize(int64(f.DailyGrowthRate*30))) fmt.Printf(" Annual: +%s/year\n", catalog.FormatSize(int64(f.DailyGrowthRate*365))) } else if f.DailyGrowthRate < 0 { fmt.Printf(" Daily: %s/day (shrinking)\n", catalog.FormatSize(int64(f.DailyGrowthRate))) } else { fmt.Printf(" Daily: No growth detected\n") } fmt.Printf(" Confidence: %s (%d samples)\n", f.Confidence, f.TotalBackups) if len(f.Projections) > 0 { fmt.Printf("\n[PROJECTIONS]\n") w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, " Days\tDate\tPredicted Size\tConfidence\n") fmt.Fprintf(w, " ----\t----\t--------------\t----------\n") for _, proj := range f.Projections { fmt.Fprintf(w, " %d\t%s\t%s\t%.0f%%\n", proj.Days, proj.Date.Format("2006-01-02"), catalog.FormatSize(proj.PredictedSize), proj.Confidence) } w.Flush() } // Check against limit if limitBytes > 0 { fmt.Printf("\n[CAPACITY LIMIT]\n") fmt.Printf(" Limit: %s\n", catalog.FormatSize(limitBytes)) currentPct := float64(f.CurrentSize) / float64(limitBytes) * 100 fmt.Printf(" Current: %.1f%% used\n", currentPct) if f.CurrentSize >= limitBytes { fmt.Printf(" Status: [WARN] LIMIT EXCEEDED\n") } else if currentPct >= 80 { fmt.Printf(" Status: [WARN] Approaching limit\n") } else { fmt.Printf(" Status: [OK] Within limit\n") } // Calculate when we'll hit the limit if f.DailyGrowthRate > 0 { remaining := limitBytes - f.CurrentSize daysToLimit := float64(remaining) / f.DailyGrowthRate if daysToLimit > 0 && daysToLimit < 1000 { dateAtLimit := f.NewestBackup.Add(time.Duration(daysToLimit*24) * time.Hour) fmt.Printf(" Estimated: Limit reached in %.0f days (%s)\n", daysToLimit, dateAtLimit.Format("2006-01-02")) if daysToLimit < 30 { fmt.Printf(" Alert: [CRITICAL] Less than 30 days remaining!\n") } else if daysToLimit < 90 { fmt.Printf(" Alert: [WARN] Less than 90 days remaining\n") } } } } fmt.Println() } func formatForecastDuration(d time.Duration) string { hours := d.Hours() if hours < 24 { return fmt.Sprintf("%.1f hours", hours) } days := hours / 24 if days < 7 { return fmt.Sprintf("%.1f days", days) } weeks := days / 7 if weeks < 4 { return fmt.Sprintf("%.1f weeks", weeks) } months := days / 30 if months < 12 { return fmt.Sprintf("%.1f months", months) } years := days / 365 return fmt.Sprintf("%.1f years", years) } func parseSize(s string) (int64, error) { // Simple size parser (supports KB, MB, GB, TB) s = strings.ToUpper(strings.TrimSpace(s)) var multiplier int64 = 1 var numStr string if strings.HasSuffix(s, "TB") { multiplier = 1024 * 1024 * 1024 * 1024 numStr = strings.TrimSuffix(s, "TB") } else if strings.HasSuffix(s, "GB") { multiplier = 1024 * 1024 * 1024 numStr = strings.TrimSuffix(s, "GB") } else if strings.HasSuffix(s, "MB") { multiplier = 1024 * 1024 numStr = strings.TrimSuffix(s, "MB") } else if strings.HasSuffix(s, "KB") { multiplier = 1024 numStr = strings.TrimSuffix(s, "KB") } else { numStr = s } var num float64 _, err := fmt.Sscanf(numStr, "%f", &num) if err != nil { return 0, fmt.Errorf("invalid size format: %s", s) } return int64(num * float64(multiplier)), nil }