cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: database setup & migration handlers build: logging & configuration

+1276 -361
+31 -7
cmd/cli/main.go
··· 3 import ( 4 "context" 5 "fmt" 6 - "log" 7 "os" 8 9 "github.com/charmbracelet/fang" 10 "github.com/spf13/cobra" 11 "stormlightlabs.org/noteleaf/internal/store" 12 ) 13 14 // App represents the main CLI application ··· 44 } 45 46 func main() { 47 app, err := NewApp() 48 if err != nil { 49 - log.Fatalf("Failed to initialize application: %v", err) 50 } 51 defer app.Close() 52 ··· 55 Short: "A TaskWarrior-inspired CLI with media queues and reading lists", 56 } 57 58 - // Task management commands 59 rootCmd.AddCommand(&cobra.Command{ 60 Use: "add [description]", 61 Short: "Add a new task", ··· 91 }, 92 }) 93 94 - // Movie queue commands 95 movieCmd := &cobra.Command{ 96 Use: "movie", 97 Short: "Manage movie watch queue", ··· 121 122 rootCmd.AddCommand(movieCmd) 123 124 - // TV show commands 125 tvCmd := &cobra.Command{ 126 Use: "tv", 127 Short: "Manage TV show watch queue", ··· 151 152 rootCmd.AddCommand(tvCmd) 153 154 - // Book commands 155 bookCmd := &cobra.Command{ 156 Use: "book", 157 Short: "Manage reading list", ··· 181 182 rootCmd.AddCommand(bookCmd) 183 184 - // Configuration commands 185 rootCmd.AddCommand(&cobra.Command{ 186 Use: "config [key] [value]", 187 Short: "Manage configuration",
··· 3 import ( 4 "context" 5 "fmt" 6 "os" 7 8 "github.com/charmbracelet/fang" 9 "github.com/spf13/cobra" 10 + "stormlightlabs.org/noteleaf/cmd/handlers" 11 "stormlightlabs.org/noteleaf/internal/store" 12 + "stormlightlabs.org/noteleaf/internal/utils" 13 ) 14 15 // App represents the main CLI application ··· 45 } 46 47 func main() { 48 + // Initialize logging early 49 + logger := utils.NewLogger("info", "text") 50 + utils.Logger = logger 51 + 52 app, err := NewApp() 53 if err != nil { 54 + logger.Fatal("Failed to initialize application", "error", err) 55 } 56 defer app.Close() 57 ··· 60 Short: "A TaskWarrior-inspired CLI with media queues and reading lists", 61 } 62 63 + rootCmd.AddCommand(&cobra.Command{ 64 + Use: "setup", 65 + Short: "Initialize the application database and configuration", 66 + RunE: func(cmd *cobra.Command, args []string) error { 67 + return handlers.Setup(cmd.Context(), args) 68 + }, 69 + }) 70 + 71 + rootCmd.AddCommand(&cobra.Command{ 72 + Use: "reset", 73 + Short: "Reset the application (removes all data)", 74 + RunE: func(cmd *cobra.Command, args []string) error { 75 + return handlers.Reset(cmd.Context(), args) 76 + }, 77 + }) 78 + 79 + rootCmd.AddCommand(&cobra.Command{ 80 + Use: "status", 81 + Short: "Show application status and configuration", 82 + RunE: func(cmd *cobra.Command, args []string) error { 83 + return handlers.Status(cmd.Context(), args) 84 + }, 85 + }) 86 + 87 rootCmd.AddCommand(&cobra.Command{ 88 Use: "add [description]", 89 Short: "Add a new task", ··· 119 }, 120 }) 121 122 movieCmd := &cobra.Command{ 123 Use: "movie", 124 Short: "Manage movie watch queue", ··· 148 149 rootCmd.AddCommand(movieCmd) 150 151 tvCmd := &cobra.Command{ 152 Use: "tv", 153 Short: "Manage TV show watch queue", ··· 177 178 rootCmd.AddCommand(tvCmd) 179 180 bookCmd := &cobra.Command{ 181 Use: "book", 182 Short: "Manage reading list", ··· 206 207 rootCmd.AddCommand(bookCmd) 208 209 rootCmd.AddCommand(&cobra.Command{ 210 Use: "config [key] [value]", 211 Short: "Manage configuration",
+151
cmd/handlers/handlers.go
··· 1 package handlers
··· 1 package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + 9 + "stormlightlabs.org/noteleaf/internal/store" 10 + "stormlightlabs.org/noteleaf/internal/utils" 11 + ) 12 + 13 + // Setup initializes the application database and configuration 14 + func Setup(ctx context.Context, args []string) error { 15 + logger := utils.GetLogger() 16 + logger.Info("Setting up noteleaf") 17 + 18 + configDir, err := store.GetConfigDir() 19 + if err != nil { 20 + logger.Error("Failed to get config directory", "error", err) 21 + return fmt.Errorf("failed to get config directory: %w", err) 22 + } 23 + 24 + logger.Info("Using config directory", "path", configDir) 25 + fmt.Printf("Config directory: %s\n", configDir) 26 + 27 + dbPath := filepath.Join(configDir, "noteleaf.db") 28 + if _, err := os.Stat(dbPath); err == nil { 29 + fmt.Println("Database already exists. Use --force to recreate.") 30 + return nil 31 + } 32 + 33 + db, err := store.NewDatabase() 34 + if err != nil { 35 + return fmt.Errorf("failed to initialize database: %w", err) 36 + } 37 + defer db.Close() 38 + 39 + fmt.Printf("Database created: %s\n", db.GetPath()) 40 + 41 + config, err := store.LoadConfig() 42 + if err != nil { 43 + return fmt.Errorf("failed to create configuration: %w", err) 44 + } 45 + 46 + configPath, err := store.GetConfigPath() 47 + if err != nil { 48 + return fmt.Errorf("failed to get config path: %w", err) 49 + } 50 + 51 + fmt.Printf("Configuration created: %s\n", configPath) 52 + fmt.Printf("Date format: %s\n", config.DateFormat) 53 + fmt.Printf("Color scheme: %s\n", config.ColorScheme) 54 + fmt.Printf("Default view: %s\n", config.DefaultView) 55 + 56 + runner := db.NewMigrationRunner() 57 + migrations, err := runner.GetAppliedMigrations() 58 + if err != nil { 59 + return fmt.Errorf("failed to get migration status: %w", err) 60 + } 61 + 62 + fmt.Printf("Applied migrations: %d\n", len(migrations)) 63 + for _, m := range migrations { 64 + fmt.Printf(" - %s (%s)\n", m.Version, m.AppliedAt) 65 + } 66 + 67 + fmt.Println("Setup completed successfully!") 68 + fmt.Println("\nYou can now use noteleaf commands:") 69 + fmt.Println(" noteleaf add \"Buy groceries\"") 70 + fmt.Println(" noteleaf list") 71 + fmt.Println(" noteleaf movie add \"The Matrix\"") 72 + 73 + return nil 74 + } 75 + 76 + // Reset recreates the database and configuration (destructive) 77 + func Reset(ctx context.Context, args []string) error { 78 + fmt.Println("Resetting noteleaf...") 79 + 80 + configDir, err := store.GetConfigDir() 81 + if err != nil { 82 + return fmt.Errorf("failed to get config directory: %w", err) 83 + } 84 + 85 + dbPath := filepath.Join(configDir, "noteleaf.db") 86 + if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) { 87 + return fmt.Errorf("failed to remove database: %w", err) 88 + } 89 + 90 + configPath, err := store.GetConfigPath() 91 + if err != nil { 92 + return fmt.Errorf("failed to get config path: %w", err) 93 + } 94 + 95 + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { 96 + return fmt.Errorf("failed to remove config: %w", err) 97 + } 98 + 99 + fmt.Println("Reset completed. Run 'noteleaf setup' to reinitialize.") 100 + return nil 101 + } 102 + 103 + // Status shows the current application status 104 + func Status(ctx context.Context, args []string) error { 105 + configDir, err := store.GetConfigDir() 106 + if err != nil { 107 + return fmt.Errorf("failed to get config directory: %w", err) 108 + } 109 + 110 + fmt.Printf("Config directory: %s\n", configDir) 111 + 112 + dbPath := filepath.Join(configDir, "noteleaf.db") 113 + if _, err := os.Stat(dbPath); err != nil { 114 + fmt.Println("Database: Not found") 115 + fmt.Println("Run 'noteleaf setup' to initialize.") 116 + return nil 117 + } 118 + 119 + fmt.Printf("Database: %s\n", dbPath) 120 + 121 + configPath, err := store.GetConfigPath() 122 + if err != nil { 123 + return fmt.Errorf("failed to get config path: %w", err) 124 + } 125 + 126 + if _, err := os.Stat(configPath); err != nil { 127 + fmt.Println("Configuration: Not found") 128 + } else { 129 + fmt.Printf("Configuration: %s\n", configPath) 130 + } 131 + 132 + db, err := store.NewDatabase() 133 + if err != nil { 134 + return fmt.Errorf("failed to connect to database: %w", err) 135 + } 136 + defer db.Close() 137 + 138 + runner := db.NewMigrationRunner() 139 + applied, err := runner.GetAppliedMigrations() 140 + if err != nil { 141 + return fmt.Errorf("failed to get applied migrations: %w", err) 142 + } 143 + 144 + available, err := runner.GetAvailableMigrations() 145 + if err != nil { 146 + return fmt.Errorf("failed to get available migrations: %w", err) 147 + } 148 + 149 + fmt.Printf("Migrations: %d/%d applied\n", len(applied), len(available)) 150 + 151 + return nil 152 + }
+282
cmd/handlers/handlers_test.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + "time" 9 + 10 + "stormlightlabs.org/noteleaf/internal/store" 11 + ) 12 + 13 + func createTestDir(t *testing.T) string { 14 + tempDir, err := os.MkdirTemp("", "noteleaf-test-*") 15 + if err != nil { 16 + t.Fatalf("Failed to create temp dir: %v", err) 17 + } 18 + 19 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 20 + os.Setenv("XDG_CONFIG_HOME", tempDir) 21 + 22 + t.Cleanup(func() { 23 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 24 + os.RemoveAll(tempDir) 25 + }) 26 + 27 + return tempDir 28 + } 29 + 30 + func TestSetup(t *testing.T) { 31 + t.Run("creates database and config files", func(t *testing.T) { 32 + _ = createTestDir(t) 33 + ctx := context.Background() 34 + 35 + err := Setup(ctx, []string{}) 36 + if err != nil { 37 + t.Errorf("Setup failed: %v", err) 38 + } 39 + 40 + configDir, err := store.GetConfigDir() 41 + if err != nil { 42 + t.Fatalf("Failed to get config dir: %v", err) 43 + } 44 + 45 + dbPath := filepath.Join(configDir, "noteleaf.db") 46 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { 47 + t.Error("Database file was not created") 48 + } 49 + 50 + configPath, err := store.GetConfigPath() 51 + if err != nil { 52 + t.Fatalf("Failed to get config path: %v", err) 53 + } 54 + 55 + if _, err := os.Stat(configPath); os.IsNotExist(err) { 56 + t.Error("Config file was not created") 57 + } 58 + 59 + }) 60 + 61 + t.Run("handles existing database gracefully", func(t *testing.T) { 62 + _ = createTestDir(t) 63 + ctx := context.Background() 64 + 65 + err1 := Setup(ctx, []string{}) 66 + if err1 != nil { 67 + t.Errorf("First setup failed: %v", err1) 68 + } 69 + 70 + err2 := Setup(ctx, []string{}) 71 + if err2 != nil { 72 + t.Errorf("Second setup should not fail: %v", err2) 73 + } 74 + 75 + }) 76 + 77 + t.Run("initializes migrations", func(t *testing.T) { 78 + _ = createTestDir(t) 79 + ctx := context.Background() 80 + 81 + err := Setup(ctx, []string{}) 82 + if err != nil { 83 + t.Errorf("Setup failed: %v", err) 84 + } 85 + 86 + db, err := store.NewDatabase() 87 + if err != nil { 88 + t.Fatalf("Failed to connect to database: %v", err) 89 + } 90 + defer db.Close() 91 + 92 + runner := db.NewMigrationRunner() 93 + migrations, err := runner.GetAppliedMigrations() 94 + if err != nil { 95 + t.Fatalf("Failed to get migrations: %v", err) 96 + } 97 + 98 + if len(migrations) == 0 { 99 + t.Error("No migrations were applied") 100 + } 101 + 102 + var count int 103 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&count) 104 + if err != nil { 105 + t.Errorf("Failed to query migrations table: %v", err) 106 + } 107 + 108 + if count == 0 { 109 + t.Error("Migrations table is empty") 110 + } 111 + 112 + }) 113 + } 114 + 115 + func TestReset(t *testing.T) { 116 + t.Run("removes database and config files", func(t *testing.T) { 117 + _ = createTestDir(t) 118 + ctx := context.Background() 119 + 120 + err := Setup(ctx, []string{}) 121 + if err != nil { 122 + t.Fatalf("Setup failed: %v", err) 123 + } 124 + 125 + configDir, err := store.GetConfigDir() 126 + if err != nil { 127 + t.Fatalf("Failed to get config dir: %v", err) 128 + } 129 + 130 + dbPath := filepath.Join(configDir, "noteleaf.db") 131 + configPath, err := store.GetConfigPath() 132 + if err != nil { 133 + t.Fatalf("Failed to get config path: %v", err) 134 + } 135 + 136 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { 137 + t.Fatal("Database should exist before reset") 138 + } 139 + 140 + if _, err := os.Stat(configPath); os.IsNotExist(err) { 141 + t.Fatal("Config should exist before reset") 142 + } 143 + 144 + err = Reset(ctx, []string{}) 145 + if err != nil { 146 + t.Errorf("Reset failed: %v", err) 147 + } 148 + 149 + if _, err := os.Stat(dbPath); !os.IsNotExist(err) { 150 + t.Error("Database file should be removed after reset") 151 + } 152 + 153 + if _, err := os.Stat(configPath); !os.IsNotExist(err) { 154 + t.Error("Config file should be removed after reset") 155 + } 156 + 157 + }) 158 + 159 + t.Run("handles non-existent files gracefully", func(t *testing.T) { 160 + _ = createTestDir(t) 161 + ctx := context.Background() 162 + 163 + err := Reset(ctx, []string{}) 164 + if err != nil { 165 + t.Errorf("Reset should handle non-existent files: %v", err) 166 + } 167 + 168 + }) 169 + } 170 + 171 + func TestStatus(t *testing.T) { 172 + t.Run("reports status when setup", func(t *testing.T) { 173 + _ = createTestDir(t) 174 + ctx := context.Background() 175 + 176 + err := Setup(ctx, []string{}) 177 + if err != nil { 178 + t.Fatalf("Setup failed: %v", err) 179 + } 180 + 181 + err = Status(ctx, []string{}) 182 + if err != nil { 183 + t.Errorf("Status failed: %v", err) 184 + } 185 + 186 + }) 187 + 188 + t.Run("reports status when not setup", func(t *testing.T) { 189 + _ = createTestDir(t) 190 + ctx := context.Background() 191 + 192 + err := Status(ctx, []string{}) 193 + if err != nil { 194 + t.Errorf("Status should not fail when not setup: %v", err) 195 + } 196 + 197 + }) 198 + 199 + t.Run("shows migration information", func(t *testing.T) { 200 + _ = createTestDir(t) 201 + ctx := context.Background() 202 + 203 + err := Setup(ctx, []string{}) 204 + if err != nil { 205 + t.Fatalf("Setup failed: %v", err) 206 + } 207 + 208 + err = Status(ctx, []string{}) 209 + if err != nil { 210 + t.Errorf("Status failed: %v", err) 211 + } 212 + 213 + }) 214 + } 215 + 216 + func TestIntegration(t *testing.T) { 217 + t.Run("full setup-reset-status cycle", func(t *testing.T) { 218 + _ = createTestDir(t) 219 + ctx := context.Background() 220 + 221 + err := Status(ctx, []string{}) 222 + if err != nil { 223 + t.Errorf("Initial status failed: %v", err) 224 + } 225 + 226 + err = Setup(ctx, []string{}) 227 + if err != nil { 228 + t.Errorf("Setup failed: %v", err) 229 + } 230 + 231 + err = Status(ctx, []string{}) 232 + if err != nil { 233 + t.Errorf("Status after setup failed: %v", err) 234 + } 235 + 236 + configDir, _ := store.GetConfigDir() 237 + dbPath := filepath.Join(configDir, "noteleaf.db") 238 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { 239 + t.Error("Database should exist after setup") 240 + } 241 + 242 + err = Reset(ctx, []string{}) 243 + if err != nil { 244 + t.Errorf("Reset failed: %v", err) 245 + } 246 + 247 + if _, err := os.Stat(dbPath); !os.IsNotExist(err) { 248 + t.Error("Database should not exist after reset") 249 + } 250 + 251 + err = Status(ctx, []string{}) 252 + if err != nil { 253 + t.Errorf("Status after reset failed: %v", err) 254 + } 255 + 256 + }) 257 + 258 + t.Run("concurrent operations", func(t *testing.T) { 259 + _ = createTestDir(t) 260 + ctx := context.Background() 261 + 262 + err := Setup(ctx, []string{}) 263 + if err != nil { 264 + t.Fatalf("Setup failed: %v", err) 265 + } 266 + 267 + done := make(chan error, 3) 268 + 269 + for range 3 { 270 + go func() { 271 + time.Sleep(time.Millisecond * 10) 272 + done <- Status(ctx, []string{}) 273 + }() 274 + } 275 + 276 + for i := range 3 { 277 + if err := <-done; err != nil { 278 + t.Errorf("Concurrent status operation %d failed: %v", i, err) 279 + } 280 + } 281 + }) 282 + }
+9 -25
go.mod
··· 3 go 1.24.5 4 5 require ( 6 - github.com/BurntSushi/toml v1.5.0 // indirect 7 - github.com/alecthomas/chroma/v2 v2.14.0 // indirect 8 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 9 - github.com/aymanbagabas/go-udiff v0.2.0 // indirect 10 - github.com/aymerick/douceur v0.2.0 // indirect 11 - github.com/charmbracelet/bubbles v0.21.0 // indirect 12 - github.com/charmbracelet/bubbletea v1.3.6 // indirect 13 github.com/charmbracelet/colorprofile v0.3.1 // indirect 14 - github.com/charmbracelet/fang v0.3.0 // indirect 15 - github.com/charmbracelet/glamour v0.10.0 // indirect 16 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect 17 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 18 - github.com/charmbracelet/log v0.4.2 // indirect 19 github.com/charmbracelet/x/ansi v0.9.3 // indirect 20 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 21 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 22 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 23 - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 24 github.com/charmbracelet/x/term v0.2.1 // indirect 25 - github.com/dlclark/regexp2 v1.11.0 // indirect 26 - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 27 github.com/go-logfmt/logfmt v0.6.0 // indirect 28 - github.com/gorilla/css v1.0.1 // indirect 29 github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 31 github.com/mattn/go-isatty v0.0.20 // indirect 32 - github.com/mattn/go-localereader v0.0.1 // indirect 33 github.com/mattn/go-runewidth v0.0.16 // indirect 34 - github.com/mattn/go-sqlite3 v1.14.32 // indirect 35 - github.com/microcosm-cc/bluemonday v1.0.27 // indirect 36 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 37 github.com/muesli/cancelreader v0.2.2 // indirect 38 github.com/muesli/mango v0.1.0 // indirect 39 github.com/muesli/mango-cobra v1.2.0 // indirect 40 github.com/muesli/mango-pflag v0.1.0 // indirect 41 - github.com/muesli/reflow v0.3.0 // indirect 42 github.com/muesli/roff v0.1.0 // indirect 43 github.com/muesli/termenv v0.16.0 // indirect 44 github.com/rivo/uniseg v0.4.7 // indirect 45 - github.com/spf13/cobra v1.9.1 // indirect 46 github.com/spf13/pflag v1.0.6 // indirect 47 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 48 - github.com/yuin/goldmark v1.7.8 // indirect 49 - github.com/yuin/goldmark-emoji v1.0.5 // indirect 50 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 51 - golang.org/x/net v0.33.0 // indirect 52 - golang.org/x/sync v0.15.0 // indirect 53 golang.org/x/sys v0.33.0 // indirect 54 - golang.org/x/term v0.31.0 // indirect 55 golang.org/x/text v0.24.0 // indirect 56 )
··· 3 go 1.24.5 4 5 require ( 6 + github.com/BurntSushi/toml v1.5.0 7 + github.com/charmbracelet/fang v0.3.0 8 + github.com/mattn/go-sqlite3 v1.14.32 9 + github.com/spf13/cobra v1.9.1 10 + ) 11 + 12 + require ( 13 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 github.com/charmbracelet/colorprofile v0.3.1 // indirect 15 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 16 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 17 + github.com/charmbracelet/log v0.4.2 18 github.com/charmbracelet/x/ansi v0.9.3 // indirect 19 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 20 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 21 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 22 github.com/charmbracelet/x/term v0.2.1 // indirect 23 github.com/go-logfmt/logfmt v0.6.0 // indirect 24 github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 github.com/mattn/go-isatty v0.0.20 // indirect 27 github.com/mattn/go-runewidth v0.0.16 // indirect 28 github.com/muesli/cancelreader v0.2.2 // indirect 29 github.com/muesli/mango v0.1.0 // indirect 30 github.com/muesli/mango-cobra v1.2.0 // indirect 31 github.com/muesli/mango-pflag v0.1.0 // indirect 32 github.com/muesli/roff v0.1.0 // indirect 33 github.com/muesli/termenv v0.16.0 // indirect 34 github.com/rivo/uniseg v0.4.7 // indirect 35 github.com/spf13/pflag v1.0.6 // indirect 36 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 37 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 38 golang.org/x/sys v0.33.0 // indirect 39 golang.org/x/text v0.24.0 // indirect 40 )
+7 -54
go.sum
··· 1 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 - github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 4 - github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 5 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 8 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 9 - github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 10 - github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 11 - github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 12 - github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 13 - github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 14 - github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 15 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 16 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 17 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 18 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 19 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 20 github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= 21 - github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 22 - github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 23 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 24 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 25 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 26 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 27 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 h1:vq2enzx1Hr3UenVefpPEf+E2xMmqtZoSHhx8IE+V8ug= 28 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= 29 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 30 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 31 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 32 github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 33 - github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 34 - github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 35 github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 36 github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 37 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 38 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 39 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 40 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 41 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= 42 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 43 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 44 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 45 - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 46 - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 47 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 48 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 49 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 50 - github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 51 - github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 52 - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 53 - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 54 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 55 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 56 - github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 57 - github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 58 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 59 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 60 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 61 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 62 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 63 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 64 - github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 65 - github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 66 - github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 67 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 68 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 69 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 70 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 71 - github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 72 - github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 73 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 74 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 75 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 76 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 77 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= ··· 80 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 81 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 82 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 83 - github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 84 - github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 85 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 86 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 87 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 88 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 89 - github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 90 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 91 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 92 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 95 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 96 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 97 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 98 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 99 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 100 - github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 101 - github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 102 - github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 103 - github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 104 - github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 105 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 106 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 107 - golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 108 - golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 109 - golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 110 - golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 111 - golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 - golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 114 - golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 115 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 116 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 117 - golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 118 - golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 119 - golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 120 - golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 121 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 122 golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 123 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 124 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
··· 1 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 8 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 9 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 10 github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= 11 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 12 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 13 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 14 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 15 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 16 github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 17 github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 18 github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 19 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 20 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= 22 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 23 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 24 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 25 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 26 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 27 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 28 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 31 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 32 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 33 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 34 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 35 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 36 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 37 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 38 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 39 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 40 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 41 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 42 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 43 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 44 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= ··· 47 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 48 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 49 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 50 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 51 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 52 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 53 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 54 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 55 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 56 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 57 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 58 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 61 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 62 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 63 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 64 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 65 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 66 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 67 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 68 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 69 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 70 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 72 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 73 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 74 golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 75 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+228
internal/models/models.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + // Model defines the common interface that all domain models must implement 9 + type Model interface { 10 + // GetID returns the primary key identifier 11 + GetID() int64 12 + 13 + // SetID sets the primary key identifier 14 + SetID(id int64) 15 + 16 + // GetTableName returns the database table name for this model 17 + GetTableName() string 18 + 19 + // GetCreatedAt returns when the model was created 20 + GetCreatedAt() time.Time 21 + 22 + // SetCreatedAt sets when the model was created 23 + SetCreatedAt(t time.Time) 24 + 25 + // GetUpdatedAt returns when the model was last updated 26 + GetUpdatedAt() time.Time 27 + 28 + // SetUpdatedAt sets when the model was last updated 29 + SetUpdatedAt(t time.Time) 30 + } 31 + 32 + // Task represents a task item with TaskWarrior-inspired fields 33 + type Task struct { 34 + ID int64 `json:"id"` 35 + UUID string `json:"uuid"` 36 + Description string `json:"description"` 37 + // pending, completed, deleted 38 + Status string `json:"status"` 39 + // A-Z or empty 40 + Priority string `json:"priority,omitempty"` 41 + Project string `json:"project,omitempty"` 42 + Tags []string `json:"tags,omitempty"` 43 + Due *time.Time `json:"due,omitempty"` 44 + Entry time.Time `json:"entry"` 45 + Modified time.Time `json:"modified"` 46 + // completion time 47 + End *time.Time `json:"end,omitempty"` 48 + // when task was started 49 + Start *time.Time `json:"start,omitempty"` 50 + Annotations []string `json:"annotations,omitempty"` 51 + } 52 + 53 + // Movie represents a movie in the watch queue 54 + type Movie struct { 55 + ID int64 `json:"id"` 56 + Title string `json:"title"` 57 + Year int `json:"year,omitempty"` 58 + // queued, watched, removed 59 + Status string `json:"status"` 60 + Rating float64 `json:"rating,omitempty"` 61 + Notes string `json:"notes,omitempty"` 62 + Added time.Time `json:"added"` 63 + Watched *time.Time `json:"watched,omitempty"` 64 + } 65 + 66 + // TVShow represents a TV show in the watch queue 67 + type TVShow struct { 68 + ID int64 `json:"id"` 69 + Title string `json:"title"` 70 + Season int `json:"season,omitempty"` 71 + Episode int `json:"episode,omitempty"` 72 + // queued, watching, watched, removed 73 + Status string `json:"status"` 74 + Rating float64 `json:"rating,omitempty"` 75 + Notes string `json:"notes,omitempty"` 76 + Added time.Time `json:"added"` 77 + LastWatched *time.Time `json:"last_watched,omitempty"` 78 + } 79 + 80 + // Book represents a book in the reading list 81 + type Book struct { 82 + ID int64 `json:"id"` 83 + Title string `json:"title"` 84 + Author string `json:"author,omitempty"` 85 + // queued, reading, finished, removed 86 + Status string `json:"status"` 87 + // percentage 0-100 88 + Progress int `json:"progress"` 89 + Pages int `json:"pages,omitempty"` 90 + Rating float64 `json:"rating,omitempty"` 91 + Notes string `json:"notes,omitempty"` 92 + Added time.Time `json:"added"` 93 + Started *time.Time `json:"started,omitempty"` 94 + Finished *time.Time `json:"finished,omitempty"` 95 + } 96 + 97 + // MarshalTags converts tags slice to JSON string for database storage 98 + func (t *Task) MarshalTags() (string, error) { 99 + if len(t.Tags) == 0 { 100 + return "", nil 101 + } 102 + data, err := json.Marshal(t.Tags) 103 + return string(data), err 104 + } 105 + 106 + // UnmarshalTags converts JSON string from database to tags slice 107 + func (t *Task) UnmarshalTags(data string) error { 108 + if data == "" { 109 + t.Tags = nil 110 + return nil 111 + } 112 + return json.Unmarshal([]byte(data), &t.Tags) 113 + } 114 + 115 + // MarshalAnnotations converts annotations slice to JSON string for database storage 116 + func (t *Task) MarshalAnnotations() (string, error) { 117 + if len(t.Annotations) == 0 { 118 + return "", nil 119 + } 120 + data, err := json.Marshal(t.Annotations) 121 + return string(data), err 122 + } 123 + 124 + // UnmarshalAnnotations converts JSON string from database to annotations slice 125 + func (t *Task) UnmarshalAnnotations(data string) error { 126 + if data == "" { 127 + t.Annotations = nil 128 + return nil 129 + } 130 + return json.Unmarshal([]byte(data), &t.Annotations) 131 + } 132 + 133 + // IsCompleted returns true if the task is marked as completed 134 + func (t *Task) IsCompleted() bool { 135 + return t.Status == "completed" 136 + } 137 + 138 + // IsPending returns true if the task is pending 139 + func (t *Task) IsPending() bool { 140 + return t.Status == "pending" 141 + } 142 + 143 + // IsDeleted returns true if the task is deleted 144 + func (t *Task) IsDeleted() bool { 145 + return t.Status == "deleted" 146 + } 147 + 148 + // HasPriority returns true if the task has a priority set 149 + func (t *Task) HasPriority() bool { 150 + return t.Priority != "" 151 + } 152 + 153 + // IsWatched returns true if the movie has been watched 154 + func (m *Movie) IsWatched() bool { 155 + return m.Status == "watched" 156 + } 157 + 158 + // IsQueued returns true if the movie is in the queue 159 + func (m *Movie) IsQueued() bool { 160 + return m.Status == "queued" 161 + } 162 + 163 + // IsWatching returns true if the TV show is currently being watched 164 + func (tv *TVShow) IsWatching() bool { 165 + return tv.Status == "watching" 166 + } 167 + 168 + // IsWatched returns true if the TV show has been watched 169 + func (tv *TVShow) IsWatched() bool { 170 + return tv.Status == "watched" 171 + } 172 + 173 + // IsQueued returns true if the TV show is in the queue 174 + func (tv *TVShow) IsQueued() bool { 175 + return tv.Status == "queued" 176 + } 177 + 178 + // IsReading returns true if the book is currently being read 179 + func (b *Book) IsReading() bool { 180 + return b.Status == "reading" 181 + } 182 + 183 + // IsFinished returns true if the book has been finished 184 + func (b *Book) IsFinished() bool { 185 + return b.Status == "finished" 186 + } 187 + 188 + // IsQueued returns true if the book is in the queue 189 + func (b *Book) IsQueued() bool { 190 + return b.Status == "queued" 191 + } 192 + 193 + // ProgressPercent returns the reading progress as a percentage 194 + func (b *Book) ProgressPercent() int { 195 + return b.Progress 196 + } 197 + 198 + func (t *Task) GetID() int64 { return t.ID } 199 + func (t *Task) SetID(id int64) { t.ID = id } 200 + func (t *Task) GetTableName() string { return "tasks" } 201 + func (t *Task) GetCreatedAt() time.Time { return t.Entry } 202 + func (t *Task) SetCreatedAt(time time.Time) { t.Entry = time } 203 + func (t *Task) GetUpdatedAt() time.Time { return t.Modified } 204 + func (t *Task) SetUpdatedAt(time time.Time) { t.Modified = time } 205 + 206 + func (m *Movie) GetID() int64 { return m.ID } 207 + func (m *Movie) SetID(id int64) { m.ID = id } 208 + func (m *Movie) GetTableName() string { return "movies" } 209 + func (m *Movie) GetCreatedAt() time.Time { return m.Added } 210 + func (m *Movie) SetCreatedAt(time time.Time) { m.Added = time } 211 + func (m *Movie) GetUpdatedAt() time.Time { return m.Added } 212 + func (m *Movie) SetUpdatedAt(time time.Time) { m.Added = time } 213 + 214 + func (tv *TVShow) GetID() int64 { return tv.ID } 215 + func (tv *TVShow) SetID(id int64) { tv.ID = id } 216 + func (tv *TVShow) GetTableName() string { return "tv_shows" } 217 + func (tv *TVShow) GetCreatedAt() time.Time { return tv.Added } 218 + func (tv *TVShow) SetCreatedAt(time time.Time) { tv.Added = time } 219 + func (tv *TVShow) GetUpdatedAt() time.Time { return tv.Added } 220 + func (tv *TVShow) SetUpdatedAt(time time.Time) { tv.Added = time } 221 + 222 + func (b *Book) GetID() int64 { return b.ID } 223 + func (b *Book) SetID(id int64) { b.ID = id } 224 + func (b *Book) GetTableName() string { return "books" } 225 + func (b *Book) GetCreatedAt() time.Time { return b.Added } 226 + func (b *Book) SetCreatedAt(time time.Time) { b.Added = time } 227 + func (b *Book) GetUpdatedAt() time.Time { return b.Added } 228 + func (b *Book) SetUpdatedAt(time time.Time) { b.Added = time }
-164
internal/models/task.go
··· 1 - package models 2 - 3 - import ( 4 - "encoding/json" 5 - "time" 6 - ) 7 - 8 - // Task represents a task item with TaskWarrior-inspired fields 9 - type Task struct { 10 - ID int64 `json:"id"` 11 - UUID string `json:"uuid"` 12 - Description string `json:"description"` 13 - Status string `json:"status"` // pending, completed, deleted 14 - Priority string `json:"priority,omitempty"` // A-Z or empty 15 - Project string `json:"project,omitempty"` 16 - Tags []string `json:"tags,omitempty"` 17 - Due *time.Time `json:"due,omitempty"` 18 - Entry time.Time `json:"entry"` 19 - Modified time.Time `json:"modified"` 20 - End *time.Time `json:"end,omitempty"` // completion time 21 - Start *time.Time `json:"start,omitempty"` // when task was started 22 - Annotations []string `json:"annotations,omitempty"` 23 - } 24 - 25 - // Movie represents a movie in the watch queue 26 - type Movie struct { 27 - ID int64 `json:"id"` 28 - Title string `json:"title"` 29 - Year int `json:"year,omitempty"` 30 - Status string `json:"status"` // queued, watched, removed 31 - Rating float64 `json:"rating,omitempty"` 32 - Notes string `json:"notes,omitempty"` 33 - Added time.Time `json:"added"` 34 - Watched *time.Time `json:"watched,omitempty"` 35 - } 36 - 37 - // TVShow represents a TV show in the watch queue 38 - type TVShow struct { 39 - ID int64 `json:"id"` 40 - Title string `json:"title"` 41 - Season int `json:"season,omitempty"` 42 - Episode int `json:"episode,omitempty"` 43 - Status string `json:"status"` // queued, watching, watched, removed 44 - Rating float64 `json:"rating,omitempty"` 45 - Notes string `json:"notes,omitempty"` 46 - Added time.Time `json:"added"` 47 - LastWatched *time.Time `json:"last_watched,omitempty"` 48 - } 49 - 50 - // Book represents a book in the reading list 51 - type Book struct { 52 - ID int64 `json:"id"` 53 - Title string `json:"title"` 54 - Author string `json:"author,omitempty"` 55 - Status string `json:"status"` // queued, reading, finished, removed 56 - Progress int `json:"progress"` // percentage 0-100 57 - Pages int `json:"pages,omitempty"` 58 - Rating float64 `json:"rating,omitempty"` 59 - Notes string `json:"notes,omitempty"` 60 - Added time.Time `json:"added"` 61 - Started *time.Time `json:"started,omitempty"` 62 - Finished *time.Time `json:"finished,omitempty"` 63 - } 64 - 65 - // MarshalTags converts tags slice to JSON string for database storage 66 - func (t *Task) MarshalTags() (string, error) { 67 - if len(t.Tags) == 0 { 68 - return "", nil 69 - } 70 - data, err := json.Marshal(t.Tags) 71 - return string(data), err 72 - } 73 - 74 - // UnmarshalTags converts JSON string from database to tags slice 75 - func (t *Task) UnmarshalTags(data string) error { 76 - if data == "" { 77 - t.Tags = nil 78 - return nil 79 - } 80 - return json.Unmarshal([]byte(data), &t.Tags) 81 - } 82 - 83 - // MarshalAnnotations converts annotations slice to JSON string for database storage 84 - func (t *Task) MarshalAnnotations() (string, error) { 85 - if len(t.Annotations) == 0 { 86 - return "", nil 87 - } 88 - data, err := json.Marshal(t.Annotations) 89 - return string(data), err 90 - } 91 - 92 - // UnmarshalAnnotations converts JSON string from database to annotations slice 93 - func (t *Task) UnmarshalAnnotations(data string) error { 94 - if data == "" { 95 - t.Annotations = nil 96 - return nil 97 - } 98 - return json.Unmarshal([]byte(data), &t.Annotations) 99 - } 100 - 101 - // IsCompleted returns true if the task is marked as completed 102 - func (t *Task) IsCompleted() bool { 103 - return t.Status == "completed" 104 - } 105 - 106 - // IsPending returns true if the task is pending 107 - func (t *Task) IsPending() bool { 108 - return t.Status == "pending" 109 - } 110 - 111 - // IsDeleted returns true if the task is deleted 112 - func (t *Task) IsDeleted() bool { 113 - return t.Status == "deleted" 114 - } 115 - 116 - // HasPriority returns true if the task has a priority set 117 - func (t *Task) HasPriority() bool { 118 - return t.Priority != "" 119 - } 120 - 121 - // IsWatched returns true if the movie has been watched 122 - func (m *Movie) IsWatched() bool { 123 - return m.Status == "watched" 124 - } 125 - 126 - // IsQueued returns true if the movie is in the queue 127 - func (m *Movie) IsQueued() bool { 128 - return m.Status == "queued" 129 - } 130 - 131 - // IsWatching returns true if the TV show is currently being watched 132 - func (tv *TVShow) IsWatching() bool { 133 - return tv.Status == "watching" 134 - } 135 - 136 - // IsWatched returns true if the TV show has been watched 137 - func (tv *TVShow) IsWatched() bool { 138 - return tv.Status == "watched" 139 - } 140 - 141 - // IsQueued returns true if the TV show is in the queue 142 - func (tv *TVShow) IsQueued() bool { 143 - return tv.Status == "queued" 144 - } 145 - 146 - // IsReading returns true if the book is currently being read 147 - func (b *Book) IsReading() bool { 148 - return b.Status == "reading" 149 - } 150 - 151 - // IsFinished returns true if the book has been finished 152 - func (b *Book) IsFinished() bool { 153 - return b.Status == "finished" 154 - } 155 - 156 - // IsQueued returns true if the book is in the queue 157 - func (b *Book) IsQueued() bool { 158 - return b.Status == "queued" 159 - } 160 - 161 - // ProgressPercent returns the reading progress as a percentage 162 - func (b *Book) ProgressPercent() int { 163 - return b.Progress 164 - }
···
+53
internal/repo/repo.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + 6 + "stormlightlabs.org/noteleaf/internal/models" 7 + ) 8 + 9 + // Repository defines a general, behavior-focused interface for data access 10 + type Repository interface { 11 + // Create stores a new model and returns its assigned ID 12 + Create(ctx context.Context, model models.Model) (int64, error) 13 + 14 + // Get retrieves a model by ID 15 + Get(ctx context.Context, table string, id int64, dest models.Model) error 16 + 17 + // Update modifies an existing model 18 + Update(ctx context.Context, model models.Model) error 19 + 20 + // Delete removes a model by ID 21 + Delete(ctx context.Context, table string, id int64) error 22 + 23 + // List retrieves models with optional filtering and sorting 24 + List(ctx context.Context, table string, opts ListOptions, dest any) error 25 + 26 + // Find retrieves models matching specific conditions 27 + Find(ctx context.Context, table string, conditions map[string]any, dest any) error 28 + 29 + // Count returns the number of models matching conditions 30 + Count(ctx context.Context, table string, conditions map[string]any) (int64, error) 31 + 32 + // Execute runs a custom query with parameters 33 + Execute(ctx context.Context, query string, args ...any) error 34 + 35 + // Query runs a custom query and returns results 36 + Query(ctx context.Context, query string, dest any, args ...any) error 37 + } 38 + 39 + // ListOptions defines generic options for listing items 40 + type ListOptions struct { 41 + // field: value pairs for WHERE conditions 42 + Where map[string]any 43 + Limit int 44 + Offset int 45 + // field name to sort by 46 + SortBy string 47 + // "asc" or "desc" 48 + SortOrder string 49 + // general search term 50 + Search string 51 + // fields to search in 52 + SearchFields []string 53 + }
+20 -32
internal/store/config.go
··· 10 11 // Config holds application configuration 12 type Config struct { 13 - // Database settings 14 - DatabasePath string `toml:"database_path,omitempty"` 15 - 16 - // Display settings 17 - DateFormat string `toml:"date_format"` 18 - ColorScheme string `toml:"color_scheme"` 19 - DefaultView string `toml:"default_view"` 20 - 21 - // Task settings 22 DefaultPriority string `toml:"default_priority,omitempty"` 23 AutoArchive bool `toml:"auto_archive"` 24 - 25 - // Sync settings 26 - SyncEnabled bool `toml:"sync_enabled"` 27 - SyncEndpoint string `toml:"sync_endpoint,omitempty"` 28 - SyncToken string `toml:"sync_token,omitempty"` 29 - 30 - // Export settings 31 - ExportFormat string `toml:"export_format"` 32 - 33 - // Media settings 34 - MovieAPIKey string `toml:"movie_api_key,omitempty"` 35 - BookAPIKey string `toml:"book_api_key,omitempty"` 36 } 37 38 // DefaultConfig returns a configuration with sensible defaults ··· 53 if err != nil { 54 return nil, fmt.Errorf("failed to get config directory: %w", err) 55 } 56 - 57 configPath := filepath.Join(configDir, ".noteleaf.conf.toml") 58 - 59 - // If config file doesn't exist, create it with defaults 60 if _, err := os.Stat(configPath); os.IsNotExist(err) { 61 config := DefaultConfig() 62 if err := SaveConfig(config); err != nil { ··· 64 } 65 return config, nil 66 } 67 - 68 data, err := os.ReadFile(configPath) 69 if err != nil { 70 return nil, fmt.Errorf("failed to read config file: %w", err) 71 } 72 - 73 config := DefaultConfig() 74 if err := toml.Unmarshal(data, config); err != nil { 75 return nil, fmt.Errorf("failed to parse config file: %w", err) 76 } 77 - 78 return config, nil 79 } 80 ··· 84 if err != nil { 85 return fmt.Errorf("failed to get config directory: %w", err) 86 } 87 - 88 configPath := filepath.Join(configDir, ".noteleaf.conf.toml") 89 - 90 data, err := toml.Marshal(config) 91 if err != nil { 92 return fmt.Errorf("failed to marshal config: %w", err) 93 } 94 - 95 if err := os.WriteFile(configPath, data, 0644); err != nil { 96 return fmt.Errorf("failed to write config file: %w", err) 97 } 98 - 99 return nil 100 } 101 ··· 106 return "", err 107 } 108 return filepath.Join(configDir, ".noteleaf.conf.toml"), nil 109 - }
··· 10 11 // Config holds application configuration 12 type Config struct { 13 + DatabasePath string `toml:"database_path,omitempty"` 14 + DateFormat string `toml:"date_format"` 15 + ColorScheme string `toml:"color_scheme"` 16 + DefaultView string `toml:"default_view"` 17 DefaultPriority string `toml:"default_priority,omitempty"` 18 AutoArchive bool `toml:"auto_archive"` 19 + SyncEnabled bool `toml:"sync_enabled"` 20 + SyncEndpoint string `toml:"sync_endpoint,omitempty"` 21 + SyncToken string `toml:"sync_token,omitempty"` 22 + ExportFormat string `toml:"export_format"` 23 + MovieAPIKey string `toml:"movie_api_key,omitempty"` 24 + BookAPIKey string `toml:"book_api_key,omitempty"` 25 } 26 27 // DefaultConfig returns a configuration with sensible defaults ··· 42 if err != nil { 43 return nil, fmt.Errorf("failed to get config directory: %w", err) 44 } 45 + 46 configPath := filepath.Join(configDir, ".noteleaf.conf.toml") 47 + 48 if _, err := os.Stat(configPath); os.IsNotExist(err) { 49 config := DefaultConfig() 50 if err := SaveConfig(config); err != nil { ··· 52 } 53 return config, nil 54 } 55 + 56 data, err := os.ReadFile(configPath) 57 if err != nil { 58 return nil, fmt.Errorf("failed to read config file: %w", err) 59 } 60 + 61 config := DefaultConfig() 62 if err := toml.Unmarshal(data, config); err != nil { 63 return nil, fmt.Errorf("failed to parse config file: %w", err) 64 } 65 + 66 return config, nil 67 } 68 ··· 72 if err != nil { 73 return fmt.Errorf("failed to get config directory: %w", err) 74 } 75 + 76 configPath := filepath.Join(configDir, ".noteleaf.conf.toml") 77 + 78 data, err := toml.Marshal(config) 79 if err != nil { 80 return fmt.Errorf("failed to marshal config: %w", err) 81 } 82 + 83 if err := os.WriteFile(configPath, data, 0644); err != nil { 84 return fmt.Errorf("failed to write config file: %w", err) 85 } 86 + 87 return nil 88 } 89 ··· 94 return "", err 95 } 96 return filepath.Join(configDir, ".noteleaf.conf.toml"), nil 97 + }
+6 -79
internal/store/database.go
··· 7 "os" 8 "path/filepath" 9 "runtime" 10 - "sort" 11 - "strings" 12 13 _ "github.com/mattn/go-sqlite3" 14 ) ··· 33 return "", fmt.Errorf("APPDATA environment variable not set") 34 } 35 configDir = filepath.Join(appData, "noteleaf") 36 - default: // Unix-like systems (Linux, macOS, BSD, etc.) 37 xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 38 if xdgConfigHome == "" { 39 homeDir, err := os.UserHomeDir() ··· 67 return nil, fmt.Errorf("failed to open database: %w", err) 68 } 69 70 - // Enable foreign keys and WAL mode for better performance 71 if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 72 db.Close() 73 return nil, fmt.Errorf("failed to enable foreign keys: %w", err) ··· 83 path: dbPath, 84 } 85 86 - // Run migrations 87 - if err := database.runMigrations(); err != nil { 88 db.Close() 89 return nil, fmt.Errorf("failed to run migrations: %w", err) 90 } ··· 92 return database, nil 93 } 94 95 - // runMigrations applies all pending migrations 96 - func (db *Database) runMigrations() error { 97 - // Get all migration files 98 - entries, err := migrationFiles.ReadDir("../sql/migrations") 99 - if err != nil { 100 - return fmt.Errorf("failed to read migrations directory: %w", err) 101 - } 102 - 103 - // Filter and sort up migrations 104 - var upMigrations []string 105 - for _, entry := range entries { 106 - if strings.HasSuffix(entry.Name(), "_up.sql") { 107 - upMigrations = append(upMigrations, entry.Name()) 108 - } 109 - } 110 - sort.Strings(upMigrations) 111 - 112 - // Apply migrations in order 113 - for _, migrationFile := range upMigrations { 114 - version := extractVersionFromFilename(migrationFile) 115 - 116 - // Check if migration already applied 117 - var count int 118 - err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count) 119 - if err != nil { 120 - return fmt.Errorf("failed to check migrations table: %w", err) 121 - } 122 - 123 - // If migrations table doesn't exist and this isn't migration 0000, skip 124 - if count == 0 && version != "0000" { 125 - continue 126 - } 127 - 128 - // Check if this specific migration was applied (only if migrations table exists) 129 - if count > 0 { 130 - var applied int 131 - err = db.QueryRow("SELECT COUNT(*) FROM migrations WHERE version = ?", version).Scan(&applied) 132 - if err != nil { 133 - return fmt.Errorf("failed to check migration %s: %w", version, err) 134 - } 135 - if applied > 0 { 136 - continue // Skip already applied migration 137 - } 138 - } 139 - 140 - // Read and execute migration 141 - content, err := migrationFiles.ReadFile("../sql/migrations/" + migrationFile) 142 - if err != nil { 143 - return fmt.Errorf("failed to read migration %s: %w", migrationFile, err) 144 - } 145 - 146 - if _, err := db.Exec(string(content)); err != nil { 147 - return fmt.Errorf("failed to execute migration %s: %w", migrationFile, err) 148 - } 149 - 150 - // Record migration as applied (only if migrations table exists) 151 - if count > 0 || version == "0000" { 152 - if _, err := db.Exec("INSERT INTO migrations (version) VALUES (?)", version); err != nil { 153 - return fmt.Errorf("failed to record migration %s: %w", version, err) 154 - } 155 - } 156 - } 157 - 158 - return nil 159 - } 160 - 161 - // extractVersionFromFilename extracts the 4-digit version from a migration filename 162 - func extractVersionFromFilename(filename string) string { 163 - parts := strings.Split(filename, "_") 164 - if len(parts) > 0 { 165 - return parts[0] 166 - } 167 - return "" 168 } 169 170 // GetPath returns the database file path
··· 7 "os" 8 "path/filepath" 9 "runtime" 10 11 _ "github.com/mattn/go-sqlite3" 12 ) ··· 31 return "", fmt.Errorf("APPDATA environment variable not set") 32 } 33 configDir = filepath.Join(appData, "noteleaf") 34 + default: 35 xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 36 if xdgConfigHome == "" { 37 homeDir, err := os.UserHomeDir() ··· 65 return nil, fmt.Errorf("failed to open database: %w", err) 66 } 67 68 if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 69 db.Close() 70 return nil, fmt.Errorf("failed to enable foreign keys: %w", err) ··· 80 path: dbPath, 81 } 82 83 + runner := NewMigrationRunner(db, migrationFiles) 84 + if err := runner.RunMigrations(); err != nil { 85 db.Close() 86 return nil, fmt.Errorf("failed to run migrations: %w", err) 87 } ··· 89 return database, nil 90 } 91 92 + // NewMigrationRunnerFromDB creates a new migration runner from a Database instance 93 + func (db *Database) NewMigrationRunner() *MigrationRunner { 94 + return NewMigrationRunner(db.DB, migrationFiles) 95 } 96 97 // GetPath returns the database file path
+228
internal/store/migration.go
···
··· 1 + package store 2 + 3 + import ( 4 + "database/sql" 5 + "embed" 6 + "fmt" 7 + "sort" 8 + "strings" 9 + ) 10 + 11 + // Migration represents a single database migration 12 + type Migration struct { 13 + Version string 14 + Name string 15 + UpSQL string 16 + DownSQL string 17 + Applied bool 18 + AppliedAt string 19 + } 20 + 21 + // MigrationRunner handles database migrations 22 + type MigrationRunner struct { 23 + db *sql.DB 24 + migrationFiles embed.FS 25 + } 26 + 27 + // NewMigrationRunner creates a new migration runner 28 + func NewMigrationRunner(db *sql.DB, files embed.FS) *MigrationRunner { 29 + return &MigrationRunner{ 30 + db: db, 31 + migrationFiles: files, 32 + } 33 + } 34 + 35 + // RunMigrations applies all pending migrations 36 + func (mr *MigrationRunner) RunMigrations() error { 37 + entries, err := mr.migrationFiles.ReadDir("sql/migrations") 38 + if err != nil { 39 + return fmt.Errorf("failed to read migrations directory: %w", err) 40 + } 41 + 42 + var upMigrations []string 43 + for _, entry := range entries { 44 + if strings.HasSuffix(entry.Name(), "_up.sql") { 45 + upMigrations = append(upMigrations, entry.Name()) 46 + } 47 + } 48 + sort.Strings(upMigrations) 49 + 50 + for _, migrationFile := range upMigrations { 51 + version := extractVersionFromFilename(migrationFile) 52 + 53 + var count int 54 + err := mr.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count) 55 + if err != nil { 56 + return fmt.Errorf("failed to check migrations table: %w", err) 57 + } 58 + 59 + if count == 0 && version != "0000" { 60 + continue 61 + } 62 + 63 + if count > 0 { 64 + var applied int 65 + err = mr.db.QueryRow("SELECT COUNT(*) FROM migrations WHERE version = ?", version).Scan(&applied) 66 + if err != nil { 67 + return fmt.Errorf("failed to check migration %s: %w", version, err) 68 + } 69 + if applied > 0 { 70 + continue 71 + } 72 + } 73 + 74 + content, err := mr.migrationFiles.ReadFile("sql/migrations/" + migrationFile) 75 + if err != nil { 76 + return fmt.Errorf("failed to read migration %s: %w", migrationFile, err) 77 + } 78 + 79 + if _, err := mr.db.Exec(string(content)); err != nil { 80 + return fmt.Errorf("failed to execute migration %s: %w", migrationFile, err) 81 + } 82 + 83 + if count > 0 || version == "0000" { 84 + if _, err := mr.db.Exec("INSERT INTO migrations (version) VALUES (?)", version); err != nil { 85 + return fmt.Errorf("failed to record migration %s: %w", version, err) 86 + } 87 + } 88 + } 89 + 90 + return nil 91 + } 92 + 93 + // GetAppliedMigrations returns a list of all applied migrations 94 + func (mr *MigrationRunner) GetAppliedMigrations() ([]Migration, error) { 95 + var count int 96 + err := mr.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to check migrations table: %w", err) 99 + } 100 + 101 + if count == 0 { 102 + return []Migration{}, nil 103 + } 104 + 105 + rows, err := mr.db.Query("SELECT version, applied_at FROM migrations ORDER BY version") 106 + if err != nil { 107 + return nil, fmt.Errorf("failed to query migrations: %w", err) 108 + } 109 + defer rows.Close() 110 + 111 + var migrations []Migration 112 + for rows.Next() { 113 + var m Migration 114 + if err := rows.Scan(&m.Version, &m.AppliedAt); err != nil { 115 + return nil, fmt.Errorf("failed to scan migration: %w", err) 116 + } 117 + m.Applied = true 118 + migrations = append(migrations, m) 119 + } 120 + 121 + return migrations, nil 122 + } 123 + 124 + // GetAvailableMigrations returns all available migrations from embedded files 125 + func (mr *MigrationRunner) GetAvailableMigrations() ([]Migration, error) { 126 + entries, err := mr.migrationFiles.ReadDir("sql/migrations") 127 + if err != nil { 128 + return nil, fmt.Errorf("failed to read migrations directory: %w", err) 129 + } 130 + 131 + migrationMap := make(map[string]*Migration) 132 + 133 + for _, entry := range entries { 134 + version := extractVersionFromFilename(entry.Name()) 135 + if version == "" { 136 + continue 137 + } 138 + 139 + if migrationMap[version] == nil { 140 + migrationMap[version] = &Migration{ 141 + Version: version, 142 + Name: extractNameFromFilename(entry.Name()), 143 + } 144 + } 145 + 146 + content, err := mr.migrationFiles.ReadFile("sql/migrations/" + entry.Name()) 147 + if err != nil { 148 + return nil, fmt.Errorf("failed to read migration file %s: %w", entry.Name(), err) 149 + } 150 + 151 + if strings.HasSuffix(entry.Name(), "_up.sql") { 152 + migrationMap[version].UpSQL = string(content) 153 + } else if strings.HasSuffix(entry.Name(), "_down.sql") { 154 + migrationMap[version].DownSQL = string(content) 155 + } 156 + } 157 + 158 + var migrations []Migration 159 + for _, m := range migrationMap { 160 + migrations = append(migrations, *m) 161 + } 162 + sort.Slice(migrations, func(i, j int) bool { 163 + return migrations[i].Version < migrations[j].Version 164 + }) 165 + 166 + return migrations, nil 167 + } 168 + 169 + // Rollback rolls back the last applied migration 170 + func (mr *MigrationRunner) Rollback() error { 171 + var version string 172 + err := mr.db.QueryRow("SELECT version FROM migrations ORDER BY version DESC LIMIT 1").Scan(&version) 173 + if err != nil { 174 + if err == sql.ErrNoRows { 175 + return fmt.Errorf("no migrations to rollback") 176 + } 177 + return fmt.Errorf("failed to get last migration: %w", err) 178 + } 179 + 180 + entries, err := mr.migrationFiles.ReadDir("sql/migrations") 181 + if err != nil { 182 + return fmt.Errorf("failed to read migrations directory: %w", err) 183 + } 184 + 185 + var downContent []byte 186 + for _, entry := range entries { 187 + if strings.HasPrefix(entry.Name(), version) && strings.HasSuffix(entry.Name(), "_down.sql") { 188 + downContent, err = mr.migrationFiles.ReadFile("sql/migrations/" + entry.Name()) 189 + if err != nil { 190 + return fmt.Errorf("failed to read down migration: %w", err) 191 + } 192 + break 193 + } 194 + } 195 + 196 + if downContent == nil { 197 + return fmt.Errorf("down migration not found for version %s", version) 198 + } 199 + 200 + if _, err := mr.db.Exec(string(downContent)); err != nil { 201 + return fmt.Errorf("failed to execute down migration: %w", err) 202 + } 203 + 204 + if _, err := mr.db.Exec("DELETE FROM migrations WHERE version = ?", version); err != nil { 205 + return fmt.Errorf("failed to remove migration record: %w", err) 206 + } 207 + 208 + return nil 209 + } 210 + 211 + // extractVersionFromFilename extracts the 4-digit version from a migration filename 212 + func extractVersionFromFilename(filename string) string { 213 + parts := strings.Split(filename, "_") 214 + if len(parts) > 0 { 215 + return parts[0] 216 + } 217 + return "" 218 + } 219 + 220 + func extractNameFromFilename(filename string) string { 221 + parts := strings.Split(filename, "_") 222 + if len(parts) < 3 { 223 + return "" 224 + } 225 + 226 + name := strings.Join(parts[1:len(parts)-1], "_") 227 + return strings.TrimSuffix(name, "_up") 228 + }
+47
internal/utils/utils.go
···
··· 1 + package utils 2 + 3 + import ( 4 + "os" 5 + "strings" 6 + 7 + "github.com/charmbracelet/log" 8 + ) 9 + 10 + // Logger is the global application logger 11 + var Logger *log.Logger 12 + 13 + // NewLogger creates a new logger with the specified level and format 14 + func NewLogger(level string, format string) *log.Logger { 15 + logger := log.New(os.Stderr) 16 + 17 + switch strings.ToLower(level) { 18 + case "debug": 19 + logger.SetLevel(log.DebugLevel) 20 + case "info": 21 + logger.SetLevel(log.InfoLevel) 22 + case "warn", "warning": 23 + logger.SetLevel(log.WarnLevel) 24 + case "error": 25 + logger.SetLevel(log.ErrorLevel) 26 + default: 27 + logger.SetLevel(log.InfoLevel) 28 + } 29 + 30 + // Set format 31 + if format == "json" { 32 + logger.SetFormatter(log.JSONFormatter) 33 + } else { 34 + logger.SetFormatter(log.TextFormatter) 35 + logger.SetReportTimestamp(true) 36 + } 37 + 38 + return logger 39 + } 40 + 41 + // GetLogger returns the global logger, creating a default one if it doesn't exist 42 + func GetLogger() *log.Logger { 43 + if Logger == nil { 44 + Logger = NewLogger("info", "text") 45 + } 46 + return Logger 47 + }
+214
internal/utils/utils_test.go
···
··· 1 + package utils 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/charmbracelet/log" 10 + ) 11 + 12 + func TestNewLogger(t *testing.T) { 13 + t.Run("creates logger with info level", func(t *testing.T) { 14 + logger := NewLogger("info", "text") 15 + if logger == nil { 16 + t.Fatal("Logger should not be nil") 17 + } 18 + 19 + if logger.GetLevel() != log.InfoLevel { 20 + t.Errorf("Expected InfoLevel, got %v", logger.GetLevel()) 21 + } 22 + }) 23 + 24 + t.Run("creates logger with debug level", func(t *testing.T) { 25 + logger := NewLogger("debug", "text") 26 + if logger.GetLevel() != log.DebugLevel { 27 + t.Errorf("Expected DebugLevel, got %v", logger.GetLevel()) 28 + } 29 + }) 30 + 31 + t.Run("creates logger with warn level", func(t *testing.T) { 32 + logger := NewLogger("warn", "text") 33 + if logger.GetLevel() != log.WarnLevel { 34 + t.Errorf("Expected WarnLevel, got %v", logger.GetLevel()) 35 + } 36 + }) 37 + 38 + t.Run("creates logger with warning level alias", func(t *testing.T) { 39 + logger := NewLogger("warning", "text") 40 + if logger.GetLevel() != log.WarnLevel { 41 + t.Errorf("Expected WarnLevel, got %v", logger.GetLevel()) 42 + } 43 + }) 44 + 45 + t.Run("creates logger with error level", func(t *testing.T) { 46 + logger := NewLogger("error", "text") 47 + if logger.GetLevel() != log.ErrorLevel { 48 + t.Errorf("Expected ErrorLevel, got %v", logger.GetLevel()) 49 + } 50 + }) 51 + 52 + t.Run("defaults to info level for invalid level", func(t *testing.T) { 53 + logger := NewLogger("invalid", "text") 54 + if logger.GetLevel() != log.InfoLevel { 55 + t.Errorf("Expected InfoLevel for invalid input, got %v", logger.GetLevel()) 56 + } 57 + }) 58 + 59 + t.Run("handles case insensitive levels", func(t *testing.T) { 60 + logger := NewLogger("DEBUG", "text") 61 + if logger.GetLevel() != log.DebugLevel { 62 + t.Errorf("Expected DebugLevel for uppercase input, got %v", logger.GetLevel()) 63 + } 64 + }) 65 + 66 + t.Run("creates logger with json format", func(t *testing.T) { 67 + var buf bytes.Buffer 68 + logger := NewLogger("info", "json") 69 + logger.SetOutput(&buf) 70 + 71 + logger.Info("test message") 72 + output := buf.String() 73 + 74 + if !strings.Contains(output, "{") || !strings.Contains(output, "}") { 75 + t.Error("Expected JSON formatted output") 76 + } 77 + }) 78 + 79 + t.Run("creates logger with text format", func(t *testing.T) { 80 + var buf bytes.Buffer 81 + logger := NewLogger("info", "text") 82 + logger.SetOutput(&buf) 83 + 84 + logger.Info("test message") 85 + output := buf.String() 86 + 87 + if strings.Contains(output, "{") && strings.Contains(output, "}") { 88 + t.Error("Expected text formatted output, not JSON") 89 + } 90 + }) 91 + 92 + t.Run("text format includes timestamp", func(t *testing.T) { 93 + var buf bytes.Buffer 94 + logger := NewLogger("info", "text") 95 + logger.SetOutput(&buf) 96 + 97 + logger.Info("test message") 98 + output := buf.String() 99 + 100 + if !strings.Contains(output, ":") { 101 + t.Error("Expected timestamp in text format output") 102 + } 103 + }) 104 + } 105 + 106 + func TestGetLogger(t *testing.T) { 107 + t.Run("returns global logger when set", func(t *testing.T) { 108 + originalLogger := Logger 109 + defer func() { Logger = originalLogger }() 110 + 111 + testLogger := NewLogger("debug", "json") 112 + Logger = testLogger 113 + 114 + retrieved := GetLogger() 115 + if retrieved != testLogger { 116 + t.Error("GetLogger should return the global logger") 117 + } 118 + }) 119 + 120 + t.Run("creates default logger when global is nil", func(t *testing.T) { 121 + originalLogger := Logger 122 + defer func() { Logger = originalLogger }() 123 + 124 + Logger = nil 125 + 126 + retrieved := GetLogger() 127 + if retrieved == nil { 128 + t.Fatal("GetLogger should create a default logger") 129 + } 130 + 131 + if retrieved.GetLevel() != log.InfoLevel { 132 + t.Error("Default logger should have InfoLevel") 133 + } 134 + 135 + if Logger != retrieved { 136 + t.Error("Global logger should be set after GetLogger call") 137 + } 138 + }) 139 + 140 + t.Run("subsequent calls return same logger", func(t *testing.T) { 141 + originalLogger := Logger 142 + defer func() { Logger = originalLogger }() 143 + 144 + Logger = nil 145 + 146 + logger1 := GetLogger() 147 + logger2 := GetLogger() 148 + 149 + if logger1 != logger2 { 150 + t.Error("Subsequent GetLogger calls should return the same instance") 151 + } 152 + }) 153 + } 154 + 155 + func TestLoggerIntegration(t *testing.T) { 156 + t.Run("logger writes to stderr by default", func(t *testing.T) { 157 + oldStderr := os.Stderr 158 + r, w, _ := os.Pipe() 159 + os.Stderr = w 160 + 161 + logger := NewLogger("info", "text") 162 + logger.Info("test message") 163 + 164 + w.Close() 165 + os.Stderr = oldStderr 166 + 167 + var buf bytes.Buffer 168 + buf.ReadFrom(r) 169 + output := buf.String() 170 + 171 + if !strings.Contains(output, "test message") { 172 + t.Error("Logger should write to stderr by default") 173 + } 174 + }) 175 + 176 + t.Run("logger respects level filtering", func(t *testing.T) { 177 + var buf bytes.Buffer 178 + logger := NewLogger("error", "text") 179 + logger.SetOutput(&buf) 180 + 181 + logger.Debug("debug message") 182 + logger.Info("info message") 183 + logger.Warn("warn message") 184 + logger.Error("error message") 185 + 186 + output := buf.String() 187 + 188 + if strings.Contains(output, "debug message") { 189 + t.Error("Debug message should be filtered out at error level") 190 + } 191 + if strings.Contains(output, "info message") { 192 + t.Error("Info message should be filtered out at error level") 193 + } 194 + if strings.Contains(output, "warn message") { 195 + t.Error("Warn message should be filtered out at error level") 196 + } 197 + if !strings.Contains(output, "error message") { 198 + t.Error("Error message should be included at error level") 199 + } 200 + }) 201 + 202 + t.Run("global logger persists between function calls", func(t *testing.T) { 203 + originalLogger := Logger 204 + defer func() { Logger = originalLogger }() 205 + 206 + Logger = NewLogger("debug", "json") 207 + 208 + retrieved := GetLogger() 209 + 210 + if retrieved.GetLevel() != log.DebugLevel { 211 + t.Error("Global logger settings should persist") 212 + } 213 + }) 214 + }