diff --git a/README.md b/README.md index 8caafd3..a391016 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - **🌐 Global Projects** - Track time for any project from any directory without configuration files - **🎯 Milestone Tracking** - Organize time entries into sprints, releases, or project phases - **💾 Local & Private Storage** - All data stored locally in SQLite - your time tracking stays private +- **🔒 Built-in Backups** - Create, restore, and manage database backups with a single command - **📊 Rich Reporting** - View stats, export to CSV/JSON, and track hourly rates - **⚡ Zero Configuration Needed** - Works out of the box, configure only when you need to diff --git a/cmd/backups/backup.go b/cmd/backups/backup.go new file mode 100644 index 0000000..80de8fb --- /dev/null +++ b/cmd/backups/backup.go @@ -0,0 +1,18 @@ +package backups + +import "github.com/spf13/cobra" + +func BackupCmds() *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "Manage backups", + Long: `Manage backups to allow for easy restoration when things go wrong.`, + } + + cmd.AddCommand(CreateCmd()) + cmd.AddCommand(RestoreCmd()) + cmd.AddCommand(ListCmd()) + cmd.AddCommand(DeleteCmd()) + + return cmd +} diff --git a/cmd/backups/create.go b/cmd/backups/create.go new file mode 100644 index 0000000..d25b5d6 --- /dev/null +++ b/cmd/backups/create.go @@ -0,0 +1,57 @@ +package backups + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func CreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new backup", + Long: `Create a new backup of your entire database to save all your data to be restored from later.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + ui.NewlineBelow() + os.Exit(1) + } + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("checking for active timer: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf(`timer is running for %s — stop it before creating a backup`, ui.Bold(running.ProjectName))) + ui.NewlineBelow() + os.Exit(1) + } + + backup, err := db.CreateBackup() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("creating backup: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiBackup, "Backup created successfully") + fmt.Println() + ui.PrintInfo(2, "File", backup.Filename) + ui.PrintInfo(2, "Path", backup.Path) + ui.PrintInfo(2, "Size", ui.FormatFileSize(backup.Size)) + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/backups/delete.go b/cmd/backups/delete.go new file mode 100644 index 0000000..dbe8d07 --- /dev/null +++ b/cmd/backups/delete.go @@ -0,0 +1,123 @@ +package backups + +import ( + "fmt" + "os" + "strconv" + + "github.com/DylanDevelops/tmpo/internal/settings" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + deleteIDFlag string +) + +func DeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a backup", + Long: `Permanently delete an existing backup. This action cannot be undone.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + backups, err := storage.ListBackups() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + if len(backups) == 0 { + ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") + ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") + ui.NewlineBelow() + return + } + + var selected *storage.BackupInfo + + if deleteIDFlag != "" { + if id, err := strconv.Atoi(deleteIDFlag); err == nil { + for i := range backups { + if backups[i].ID == id { + selected = &backups[i] + break + } + } + if selected == nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with ID %d", id)) + ui.NewlineBelow() + os.Exit(1) + } + } else { + for i := range backups { + if backups[i].Filename == deleteIDFlag { + selected = &backups[i] + break + } + } + if selected == nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with filename %q", deleteIDFlag)) + ui.NewlineBelow() + os.Exit(1) + } + } + } else { + items := make([]string, len(backups)) + for i, b := range backups { + versionTag := fmt.Sprintf("v%d, current", b.SchemaVersion) + if !b.IsUpToDate { + versionTag = fmt.Sprintf("v%d, outdated", b.SchemaVersion) + } + items[i] = fmt.Sprintf("[%d] %s %s (%s)", + b.ID, + settings.FormatDateTime(b.CreatedAt), + ui.FormatFileSize(b.Size), + versionTag, + ) + } + + prompt := promptui.Select{ + Label: "Select backup to delete", + Items: items, + } + + idx, _, err := prompt.Run() + if err != nil { + ui.NewlineBelow() + return + } + + selected = &backups[idx] + } + + confirmPrompt := promptui.Prompt{ + Label: fmt.Sprintf("Permanently delete %s? This cannot be undone [y/N]", selected.Filename), + IsConfirm: true, + } + + if _, err := confirmPrompt.Run(); err != nil { + ui.PrintInfo(0, ui.EmojiInfo+" Deletion cancelled", "") + ui.NewlineBelow() + return + } + + if err := os.Remove(selected.Path); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("deleting backup: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Deleted %s", selected.Filename)) + ui.NewlineBelow() + }, + } + + cmd.Flags().StringVarP(&deleteIDFlag, "id", "i", "", "backup ID or filename to delete (skips interactive selection)") + + return cmd +} diff --git a/cmd/backups/list.go b/cmd/backups/list.go new file mode 100644 index 0000000..7672f60 --- /dev/null +++ b/cmd/backups/list.go @@ -0,0 +1,60 @@ +package backups + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/settings" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func ListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all existing backups", + Long: `Lists all existing backups which can be used to restore.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + backups, err := storage.ListBackups() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + if len(backups) == 0 { + ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") + ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") + ui.NewlineBelow() + return + } + + fmt.Printf(" %s%-4s %-28s %-8s %s%s\n", + ui.FormatBold, "ID", "Created", "Size", "Schema", ui.ColorReset) + fmt.Println() + + for _, b := range backups { + var schemaTag string + if b.IsUpToDate { + schemaTag = fmt.Sprintf("%s%s v%d (current)%s", ui.ColorGreen, ui.EmojiSuccess, b.SchemaVersion, ui.ColorReset) + } else { + schemaTag = fmt.Sprintf("%s%s v%d (outdated)%s", ui.ColorYellow, ui.EmojiWarning, b.SchemaVersion, ui.ColorReset) + } + + fmt.Printf(" %-4d %-28s %-8s %s\n", + b.ID, + settings.FormatDateTime(b.CreatedAt), + ui.FormatFileSize(b.Size), + schemaTag, + ) + } + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/backups/restore.go b/cmd/backups/restore.go new file mode 100644 index 0000000..b4fa77c --- /dev/null +++ b/cmd/backups/restore.go @@ -0,0 +1,130 @@ +package backups + +import ( + "fmt" + "os" + "strconv" + + "github.com/DylanDevelops/tmpo/internal/settings" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + restoreIDFlag string +) + +func RestoreCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "restore", + Short: "Restore from a backup", + Long: `Restore your database from an existing backup.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + backups, err := storage.ListBackups() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + if len(backups) == 0 { + ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "") + ui.PrintMuted(2, "Run 'tmpo backup create' to create one.") + ui.NewlineBelow() + return + } + + var selected *storage.BackupInfo + + if restoreIDFlag != "" { + if id, err := strconv.Atoi(restoreIDFlag); err == nil { + for i := range backups { + if backups[i].ID == id { + selected = &backups[i] + break + } + } + if selected == nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with ID %d", id)) + ui.NewlineBelow() + os.Exit(1) + } + } else { + for i := range backups { + if backups[i].Filename == restoreIDFlag { + selected = &backups[i] + break + } + } + if selected == nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with filename %q", restoreIDFlag)) + ui.NewlineBelow() + os.Exit(1) + } + } + } else { + items := make([]string, len(backups)) + for i, b := range backups { + versionTag := fmt.Sprintf("v%d, current", b.SchemaVersion) + if !b.IsUpToDate { + versionTag = fmt.Sprintf("v%d, outdated", b.SchemaVersion) + } + items[i] = fmt.Sprintf("[%d] %s %s (%s)", + b.ID, + settings.FormatDateTime(b.CreatedAt), + ui.FormatFileSize(b.Size), + versionTag, + ) + } + + prompt := promptui.Select{ + Label: "Select backup to restore", + Items: items, + } + + idx, _, err := prompt.Run() + if err != nil { + ui.NewlineBelow() + return + } + + selected = &backups[idx] + } + + if !selected.IsUpToDate { + fmt.Printf("\n %s%s This backup uses schema v%d; the current binary expects v%d.%s\n", + ui.ColorYellow, ui.EmojiWarning, selected.SchemaVersion, storage.CurrentSchemaVersion, ui.ColorReset) + fmt.Printf(" %sMigrations will run automatically after restore.%s\n\n", + ui.ColorGray, ui.ColorReset) + } + + confirmPrompt := promptui.Prompt{ + Label: fmt.Sprintf("Restore from %s? This will overwrite your current database [y/N]", selected.Filename), + IsConfirm: true, + } + + if _, err := confirmPrompt.Run(); err != nil { + ui.PrintInfo(0, ui.EmojiInfo+" Restore cancelled", "") + ui.NewlineBelow() + return + } + + if err := storage.RestoreBackup(selected.Path); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("restoring backup: %v", err)) + ui.NewlineBelow() + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiBackup, fmt.Sprintf("Restored from %s", selected.Filename)) + ui.NewlineBelow() + }, + } + + cmd.Flags().StringVarP(&restoreIDFlag, "id", "i", "", "backup ID or filename to restore (skips interactive selection)") + + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index e6c758f..e1ae07d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "os" + "github.com/DylanDevelops/tmpo/cmd/backups" "github.com/DylanDevelops/tmpo/cmd/config" "github.com/DylanDevelops/tmpo/cmd/entries" "github.com/DylanDevelops/tmpo/cmd/history" @@ -66,6 +67,9 @@ Track time effortlessly with automatic project detection and simple commands.`, // Milestones cmd.AddCommand(milestones.MilestoneCmds()) + // Backups + cmd.AddCommand(backups.BackupCmds()) + return cmd } diff --git a/docs/usage.md b/docs/usage.md index 44a98ca..153898c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -471,6 +471,92 @@ my-project,2024-01-15 14:30:00,2024-01-15 16:45:00,2.25,Implementing feature,Spr ] ``` +## Backup Management + +tmpo can back up and restore your entire database. Backups are plain SQLite files stored in `~/.tmpo/backups/` and can be created, listed, restored, or deleted at any time. + +### `tmpo backup create` + +Create a snapshot of your database. The backup file is named `tmpo-YYYYMMDD-HHMMSS.db`. + +**Examples:** + +```bash +tmpo backup create +# 💾 Backup created successfully +# +# File: tmpo-20260429-202433.db +# Path: /Users/you/.tmpo/backups/tmpo-20260429-202433.db +# Size: 44.0 KB +``` + +> [!NOTE] +> A timer must not be running when you create a backup. Stop any active session with `tmpo stop` first. + +### `tmpo backup list` + +List all existing backups with their ID, creation date, size, and schema version status. + +**Examples:** + +```bash +tmpo backup list +# +# ID Created Size Schema +# +# 1 04/29/2026 8:24 PM 44.0 KB ✅ v1 (current) +# 2 04/28/2026 9:00 AM 43.5 KB ✅ v1 (current) +``` + +**Notes:** + +- IDs are assigned newest-first and can be used with `--id` in `restore` and `delete` +- Schema shows whether the backup's database schema matches the current binary +- Backups created before a tmpo upgrade may show ⚠️ `v0 (outdated)` — they can still be restored, and migrations will run automatically after + +### `tmpo backup restore` + +Restore your database from a backup. **This overwrites your current database.** + +**Options:** + +- `--id VALUE` / `-i VALUE` - Backup ID or filename to restore, skips interactive selection + +**Examples:** + +```bash +tmpo backup restore # Interactive selection prompt +tmpo backup restore --id 2 # Restore by ID +tmpo backup restore -i tmpo-20260428-090000.db # Restore by filename +``` + +**Interactive Flow:** + +1. Select a backup from the list using arrow keys +2. If the backup schema is outdated, a warning is shown (migrations will auto-run after restore) +3. Confirm restoration before overwriting your current database + +### `tmpo backup delete` + +Permanently delete a backup. **This cannot be undone.** + +**Options:** + +- `--id VALUE` / `-i VALUE` - Backup ID or filename to delete, skips interactive selection + +**Examples:** + +```bash +tmpo backup delete # Interactive selection prompt +tmpo backup delete --id 2 # Delete by ID +tmpo backup delete -i tmpo-20260428-090000.db # Delete by filename +``` + +**Interactive Flow:** + +1. Select a backup from the list using arrow keys +2. Confirm permanent deletion before proceeding + ## Tips and Workflows ### Taking Breaks with Pause/Resume diff --git a/internal/storage/backup.go b/internal/storage/backup.go new file mode 100644 index 0000000..731f3a9 --- /dev/null +++ b/internal/storage/backup.go @@ -0,0 +1,175 @@ +package storage + +import ( + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type BackupInfo struct { + ID int + Filename string + Path string + CreatedAt time.Time + Size int64 + SchemaVersion int + IsUpToDate bool +} + +func GetDBPath() (string, error) { + tmpoDir, err := getTmpoDir() + if err != nil { + return "", err + } + return filepath.Join(tmpoDir, "tmpo.db"), nil +} + +func GetBackupDir() (string, error) { + tmpoDir, err := getTmpoDir() + if err != nil { + return "", err + } + return filepath.Join(tmpoDir, "backups"), nil +} + +// CreateBackup uses SQLite's VACUUM INTO to produce a clean, consistent snapshot of the live database. +func (d *Database) CreateBackup() (*BackupInfo, error) { + backupDir, err := GetBackupDir() + if err != nil { + return nil, err + } + + if err := os.MkdirAll(backupDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create backups directory: %w", err) + } + + now := time.Now() + filename := fmt.Sprintf("tmpo-%s.db", now.Format("20060102-150405")) + destPath := filepath.Join(backupDir, filename) + + escapedPath := strings.ReplaceAll(destPath, "'", "''") + if _, err = d.db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escapedPath)); err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + + info, err := os.Stat(destPath) + if err != nil { + return nil, fmt.Errorf("failed to stat backup file: %w", err) + } + + return &BackupInfo{ + ID: 1, + Filename: filename, + Path: destPath, + CreatedAt: now, + Size: info.Size(), + SchemaVersion: CurrentSchemaVersion, + IsUpToDate: true, + }, nil +} + +// ListBackups returns all backups sorted newest-first with display IDs assigned (1 = newest). +func ListBackups() ([]BackupInfo, error) { + backupDir, err := GetBackupDir() + if err != nil { + return nil, err + } + + dirEntries, err := os.ReadDir(backupDir) + if os.IsNotExist(err) { + return []BackupInfo{}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read backups directory: %w", err) + } + + var backups []BackupInfo + for _, entry := range dirEntries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".db") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + path := filepath.Join(backupDir, entry.Name()) + version, err := getBackupSchemaVersion(path) + if err != nil { + version = 0 + } + + backups = append(backups, BackupInfo{ + Filename: entry.Name(), + Path: path, + CreatedAt: info.ModTime(), + Size: info.Size(), + SchemaVersion: version, + IsUpToDate: version == CurrentSchemaVersion, + }) + } + + sort.Slice(backups, func(i, j int) bool { + return backups[i].CreatedAt.After(backups[j].CreatedAt) + }) + + for i := range backups { + backups[i].ID = i + 1 + } + + return backups, nil +} + +// RestoreBackup copies a backup file over the live database. The caller must ensure no DB connection is open. +func RestoreBackup(backupPath string) error { + dbPath, err := GetDBPath() + if err != nil { + return err + } + + src, err := os.Open(backupPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer src.Close() + + dst, err := os.Create(dbPath) + if err != nil { + return fmt.Errorf("failed to open database for writing: %w", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("failed to restore backup: %w", err) + } + + return nil +} + +// getBackupSchemaVersion opens a backup SQLite file and counts how many known migrations are marked complete. +func getBackupSchemaVersion(dbPath string) (int, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return 0, err + } + defer db.Close() + + count := 0 + for _, key := range allMigrationKeys { + var value string + err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err == nil && value == "completed" { + count++ + } + } + + return count, nil +} diff --git a/internal/storage/backup_test.go b/internal/storage/backup_test.go new file mode 100644 index 0000000..a139001 --- /dev/null +++ b/internal/storage/backup_test.go @@ -0,0 +1,259 @@ +package storage + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + _ "modernc.org/sqlite" +) + +// makeSettingsDB creates a SQLite file with a settings table at the given path. +func makeSettingsDB(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", path) + assert.NoError(t, err) + _, err = db.Exec(` + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL + ) + `) + assert.NoError(t, err) + return db +} + +func TestCurrentSchemaVersion(t *testing.T) { + t.Run("matches length of allMigrationKeys", func(t *testing.T) { + assert.Equal(t, len(allMigrationKeys), CurrentSchemaVersion) + }) + + t.Run("is greater than zero", func(t *testing.T) { + assert.Greater(t, CurrentSchemaVersion, 0) + }) +} + +func TestGetDBPath(t *testing.T) { + t.Run("default path ends with tmpo.db under .tmpo", func(t *testing.T) { + t.Setenv("TMPO_DEV", "") + path, err := GetDBPath() + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(path, "tmpo.db")) + assert.Contains(t, path, ".tmpo") + }) + + t.Run("dev mode uses .tmpo-dev directory", func(t *testing.T) { + t.Setenv("TMPO_DEV", "1") + path, err := GetDBPath() + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(path, "tmpo.db")) + assert.Contains(t, path, ".tmpo-dev") + }) +} + +func TestGetBackupDir(t *testing.T) { + t.Run("default backup dir ends with backups under .tmpo", func(t *testing.T) { + t.Setenv("TMPO_DEV", "") + dir, err := GetBackupDir() + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(dir, "backups")) + assert.Contains(t, dir, ".tmpo") + }) + + t.Run("dev mode backup dir is under .tmpo-dev", func(t *testing.T) { + t.Setenv("TMPO_DEV", "1") + dir, err := GetBackupDir() + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(dir, "backups")) + assert.Contains(t, dir, ".tmpo-dev") + }) +} + +func TestGetBackupSchemaVersion(t *testing.T) { + t.Run("returns zero when settings table is absent", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "no_settings.db") + + db, err := sql.Open("sqlite", path) + assert.NoError(t, err) + _, err = db.Exec(`CREATE TABLE time_entries (id INTEGER PRIMARY KEY)`) + assert.NoError(t, err) + db.Close() + + version, err := getBackupSchemaVersion(path) + assert.NoError(t, err) + assert.Equal(t, 0, version) + }) + + t.Run("returns zero when settings table is empty", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty_settings.db") + + db := makeSettingsDB(t, path) + db.Close() + + version, err := getBackupSchemaVersion(path) + assert.NoError(t, err) + assert.Equal(t, 0, version) + }) + + t.Run("counts completed migrations correctly", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "migrated.db") + + db := makeSettingsDB(t, path) + _, err := db.Exec( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + Migration001_UTCTimestamps, "completed", time.Now().UTC(), + ) + assert.NoError(t, err) + db.Close() + + version, err := getBackupSchemaVersion(path) + assert.NoError(t, err) + assert.Equal(t, 1, version) + }) + + t.Run("does not count non-completed migration entries", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "pending.db") + + db := makeSettingsDB(t, path) + _, err := db.Exec( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + Migration001_UTCTimestamps, "pending", time.Now().UTC(), + ) + assert.NoError(t, err) + db.Close() + + version, err := getBackupSchemaVersion(path) + assert.NoError(t, err) + assert.Equal(t, 0, version) + }) +} + +func TestListBackups_EmptyWhenDirAbsent(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + backups, err := ListBackups() + assert.NoError(t, err) + assert.Empty(t, backups) +} + +func TestListBackups_IgnoresNonDBFiles(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + backupDir := filepath.Join(tmpHome, ".tmpo", "backups") + assert.NoError(t, os.MkdirAll(backupDir, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(backupDir, "notes.txt"), []byte("ignore"), 0644)) + + backups, err := ListBackups() + assert.NoError(t, err) + assert.Empty(t, backups) +} + +func TestListBackups_SortedNewestFirst(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + backupDir := filepath.Join(tmpHome, ".tmpo", "backups") + assert.NoError(t, os.MkdirAll(backupDir, 0755)) + + olderPath := filepath.Join(backupDir, "tmpo-20260101-100000.db") + newerPath := filepath.Join(backupDir, "tmpo-20260102-100000.db") + + for _, path := range []string{olderPath, newerPath} { + db := makeSettingsDB(t, path) + db.Close() + } + + now := time.Now() + assert.NoError(t, os.Chtimes(olderPath, now.Add(-time.Hour), now.Add(-time.Hour))) + assert.NoError(t, os.Chtimes(newerPath, now, now)) + + backups, err := ListBackups() + assert.NoError(t, err) + assert.Len(t, backups, 2) + + assert.Equal(t, 1, backups[0].ID) + assert.Equal(t, 2, backups[1].ID) + assert.Equal(t, "tmpo-20260102-100000.db", backups[0].Filename) + assert.Equal(t, "tmpo-20260101-100000.db", backups[1].Filename) + assert.True(t, backups[0].CreatedAt.After(backups[1].CreatedAt)) +} + +func TestCreateBackup(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + db, err := Initialize() + assert.NoError(t, err) + defer db.Close() + + backup, err := db.CreateBackup() + assert.NoError(t, err) + assert.NotNil(t, backup) + + assert.True(t, strings.HasPrefix(backup.Filename, "tmpo-")) + assert.True(t, strings.HasSuffix(backup.Filename, ".db")) + assert.Greater(t, backup.Size, int64(0)) + assert.Equal(t, CurrentSchemaVersion, backup.SchemaVersion) + assert.True(t, backup.IsUpToDate) + + _, err = os.Stat(backup.Path) + assert.NoError(t, err) +} + +func TestRestoreBackup(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + // Create and populate the live DB, then snapshot it + db, err := Initialize() + assert.NoError(t, err) + _, err = db.CreateEntry("test-project", "before backup", nil, nil) + assert.NoError(t, err) + + backup, err := db.CreateBackup() + assert.NoError(t, err) + db.Close() + + // Add an entry after the backup was taken + db2, err := Initialize() + assert.NoError(t, err) + _, err = db2.CreateEntry("test-project", "after backup", nil, nil) + assert.NoError(t, err) + entries, err := db2.GetEntries(0) + assert.NoError(t, err) + assert.Len(t, entries, 2) + db2.Close() + + // Restore and verify only the pre-backup state remains + assert.NoError(t, RestoreBackup(backup.Path)) + + db3, err := Initialize() + assert.NoError(t, err) + defer db3.Close() + + entries, err = db3.GetEntries(0) + assert.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "before backup", entries[0].Description) +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 2b20e28..b31eb31 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -15,17 +15,23 @@ type Database struct { db *sql.DB } -func Initialize() (*Database, error) { +func getTmpoDir() (string, error) { homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) + return "", fmt.Errorf("failed to get home directory: %w", err) } - tmpoDir := filepath.Join(homeDir, ".tmpo") if devMode := os.Getenv("TMPO_DEV"); devMode == "1" || devMode == "true" { tmpoDir = filepath.Join(homeDir, ".tmpo-dev") } + return tmpoDir, nil +} + +func Initialize() (*Database, error) { + tmpoDir, err := getTmpoDir() + if err != nil { + return nil, err + } if err := os.MkdirAll(tmpoDir, 0755); err != nil { return nil, fmt.Errorf("failed to create .tmpo directory: %w", err) diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 43c7f1c..21ef1e2 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -12,6 +12,14 @@ const ( Migration001_UTCTimestamps = "001_utc_timestamps" ) +// allMigrationKeys lists every migration in order. Adding a new migration here automatically bumps CurrentSchemaVersion. +var allMigrationKeys = []string{ + Migration001_UTCTimestamps, +} + +// CurrentSchemaVersion is the number of migrations the current binary knows about. +var CurrentSchemaVersion = len(allMigrationKeys) + // runMigrations executes all pending migrations func (d *Database) runMigrations() error { // Migration 1: Convert all timestamps to UTC diff --git a/internal/ui/ui.go b/internal/ui/ui.go index ccbbd3e..5e9cd14 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -43,6 +43,7 @@ const ( EmojiInit = "⚙️" EmojiExport = "📤" EmojiMilestone = "🎯" + EmojiBackup = "💾" EmojiSuccess = "✅" EmojiError = "❌" EmojiWarning = "⚠️" @@ -148,6 +149,15 @@ func NewlineBelow() { } } +func FormatFileSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } else if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) +} + func FormatDuration(d time.Duration) string { hours := int(d.Hours()) minutes := int(d.Minutes()) % 60 diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 9a6b8c8..d902fec 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -108,6 +108,62 @@ func TestCombinedFormattingFunctions(t *testing.T) { }) } +func TestFormatFileSize(t *testing.T) { + tests := []struct { + name string + bytes int64 + expected string + }{ + { + name: "zero bytes", + bytes: 0, + expected: "0 B", + }, + { + name: "bytes under 1 KB", + bytes: 512, + expected: "512 B", + }, + { + name: "exactly 1023 bytes stays in bytes", + bytes: 1023, + expected: "1023 B", + }, + { + name: "exactly 1 KB", + bytes: 1024, + expected: "1.0 KB", + }, + { + name: "fractional kilobytes", + bytes: 1536, + expected: "1.5 KB", + }, + { + name: "whole kilobytes", + bytes: 24576, + expected: "24.0 KB", + }, + { + name: "exactly 1 MB", + bytes: 1024 * 1024, + expected: "1.0 MB", + }, + { + name: "multiple megabytes", + bytes: 2*1024*1024 + 512*1024, + expected: "2.5 MB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatFileSize(tt.bytes) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestFormatDuration(t *testing.T) { tests := []struct { name string @@ -194,6 +250,7 @@ func TestConstants(t *testing.T) { assert.NotEmpty(t, EmojiManual) assert.NotEmpty(t, EmojiInit) assert.NotEmpty(t, EmojiExport) + assert.NotEmpty(t, EmojiBackup) assert.NotEmpty(t, EmojiSuccess) assert.NotEmpty(t, EmojiError) assert.NotEmpty(t, EmojiWarning)