diff --git a/cmd/commands.go b/cmd/commands.go index 42e6e4a..81576a5 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -1,12 +1,4 @@ /* -TODO: Implement movie addition -TODO: Implement movie listing -TODO: Implement movie watched status -TODO: Implement movie removal -TODO: Implement TV show addition -TODO: Implement TV show listing -TODO: Implement TV show watched status -TODO: Implement TV show removal TODO: Implement config management */ package main @@ -16,85 +8,67 @@ import ( "strconv" "strings" - "github.com/charmbracelet/log" - "github.com/spf13/cobra" "github.com/stormlightlabs/noteleaf/internal/handlers" - "github.com/stormlightlabs/noteleaf/internal/ui" ) -func rootCmd() *cobra.Command { - root := &cobra.Command{ - Use: "noteleaf", - Long: ui.Georgia.ColoredInViewport(), - Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", - RunE: func(c *cobra.Command, args []string) error { - if len(args) == 0 { - return c.Help() - } - - output := strings.Join(args, " ") - fmt.Println(output) - return nil - }, - } - - root.SetHelpCommand(&cobra.Command{Hidden: true}) - cobra.EnableCommandSorting = false - - root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) - root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) - return root +// CommandGroup represents a group of related CLI commands +type CommandGroup interface { + Create() *cobra.Command } -func todoCmd() *cobra.Command { - root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} - - handler, err := handlers.NewTaskHandler() - if err != nil { - log.Fatalf("failed to create task handler: %v", err) - } - - for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ - addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, - deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, - taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, - } { - cmd := init(handler) - root.AddCommand(cmd) - } - - return root +// MovieCommand implements CommandGroup for movie-related commands +type MovieCommand struct { + handler *handlers.MovieHandler } -func mediaCmd() *cobra.Command { - cmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} - for _, init := range []func() *cobra.Command{bookMediaCmd, movieMediaCmd, tvMediaCmd} { - cmd.AddCommand(init()) - } - return cmd +// NewMovieCommand creates a new MovieCommands with the given handler +func NewMovieCommand(handler *handlers.MovieHandler) *MovieCommand { + return &MovieCommand{handler: handler} } -func movieMediaCmd() *cobra.Command { +func (c *MovieCommand) Create() *cobra.Command { root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} - root.AddCommand(&cobra.Command{ - Use: "add [title]", - Short: "Add movie to watch queue", - Args: cobra.MinimumNArgs(1), + addCmd := &cobra.Command{ + Use: "add [search query...]", + Short: "Search and add movie to watch queue", + Long: `Search for movies and add them to your watch queue. + +By default, shows search results in a simple list format where you can select by number. +Use the -i flag for an interactive interface with navigation keys.`, RunE: func(cmd *cobra.Command, args []string) error { - title := args[0] - fmt.Printf("Adding movie: %s\n", title) - return nil + if len(args) == 0 { + return fmt.Errorf("search query cannot be empty") + } + interactive, _ := cmd.Flags().GetBool("interactive") + query := strings.Join(args, " ") + + return c.handler.SearchAndAddMovie(cmd.Context(), query, interactive) }, - }) + } + addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") + root.AddCommand(addCmd) root.AddCommand(&cobra.Command{ - Use: "list", - Short: "List movies in queue", + Use: "list [--all|--watched|--queued]", + Short: "List movies in queue with status filtering", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Listing movies...") - return nil + var status string + if len(args) > 0 { + switch args[0] { + case "--all": + status = "" + case "--watched": + status = "watched" + case "--queued": + status = "queued" + default: + return fmt.Errorf("invalid status filter: %s (use: --all, --watched, --queued)", args[0]) + } + } + + return c.handler.ListMovies(cmd.Context(), status) }, }) @@ -104,8 +78,7 @@ func movieMediaCmd() *cobra.Command { Aliases: []string{"seen"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Printf("Marking movie %s as watched\n", args[0]) - return nil + return c.handler.MarkMovieWatched(cmd.Context(), args[0]) }, }) @@ -115,34 +88,76 @@ func movieMediaCmd() *cobra.Command { Aliases: []string{"rm"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Printf("Removing movie %s from queue\n", args[0]) - return nil + return c.handler.RemoveMovie(cmd.Context(), args[0]) }, }) return root } -func tvMediaCmd() *cobra.Command { +// TVCommand implements [CommandGroup] for TV show-related commands +type TVCommand struct { + handler *handlers.TVHandler +} + +// NewTVCommand creates a new [TVCommand] with the given handler +func NewTVCommand(handler *handlers.TVHandler) *TVCommand { + return &TVCommand{handler: handler} +} + +func (c *TVCommand) Create() *cobra.Command { root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} + addCmd := &cobra.Command{ + Use: "add [search query...]", + Short: "Search and add TV show to watch queue", + Long: `Search for TV shows and add them to your watch queue. + +By default, shows search results in a simple list format where you can select by number. +Use the -i flag for an interactive interface with navigation keys.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("search query cannot be empty") + } + interactive, _ := cmd.Flags().GetBool("interactive") + query := strings.Join(args, " ") + + return c.handler.SearchAndAddTV(cmd.Context(), query, interactive) + }, + } + addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for TV show selection") + root.AddCommand(addCmd) + root.AddCommand(&cobra.Command{ - Use: "add [title]", - Short: "Add TV show to watch queue", - Args: cobra.MinimumNArgs(1), + Use: "list [--all|--queued|--watching|--watched]", + Short: "List TV shows in queue with status filtering", RunE: func(cmd *cobra.Command, args []string) error { - title := args[0] - fmt.Printf("Adding TV show: %s\n", title) - return nil + var status string + if len(args) > 0 { + switch args[0] { + case "--all": + status = "" + case "--queued": + status = "queued" + case "--watching": + status = "watching" + case "--watched": + status = "watched" + default: + return fmt.Errorf("invalid status filter: %s (use: --all, --queued, --watching, --watched)", args[0]) + } + } + + return c.handler.ListTVShows(cmd.Context(), status) }, }) root.AddCommand(&cobra.Command{ - Use: "list", - Short: "List TV shows in queue", + Use: "watching [id]", + Short: "Mark TV show as currently watching", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Listing TV shows...") - return nil + return c.handler.MarkTVShowWatching(cmd.Context(), args[0]) }, }) @@ -152,8 +167,7 @@ func tvMediaCmd() *cobra.Command { Aliases: []string{"seen"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Printf("Marking TV show %s as watched\n", args[0]) - return nil + return c.handler.MarkTVShowWatched(cmd.Context(), args[0]) }, }) @@ -163,15 +177,24 @@ func tvMediaCmd() *cobra.Command { Aliases: []string{"rm"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Printf("Removing TV show %s from queue\n", args[0]) - return nil + return c.handler.RemoveTVShow(cmd.Context(), args[0]) }, }) return root } -func bookMediaCmd() *cobra.Command { +// BookCommand implements CommandGroup for book-related commands +type BookCommand struct { + handler *handlers.BookHandler +} + +// NewBookCommand creates a new BookCommand with the given handler +func NewBookCommand(handler *handlers.BookHandler) *BookCommand { + return &BookCommand{handler: handler} +} + +func (c *BookCommand) Create() *cobra.Command { root := &cobra.Command{Use: "book", Short: "Manage reading list"} addCmd := &cobra.Command{ @@ -183,7 +206,7 @@ By default, shows search results in a simple list format where you can select by Use the -i flag for an interactive interface with navigation keys.`, RunE: func(cmd *cobra.Command, args []string) error { interactive, _ := cmd.Flags().GetBool("interactive") - return handlers.SearchAndAddWithOptions(cmd.Context(), args, interactive) + return c.handler.SearchAndAddBook(cmd.Context(), args, interactive) }, } addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") @@ -193,7 +216,22 @@ Use the -i flag for an interactive interface with navigation keys.`, Use: "list [--all|--reading|--finished|--queued]", Short: "Show reading queue with progress", RunE: func(cmd *cobra.Command, args []string) error { - return handlers.ListBooks(cmd.Context(), args) + var status string + if len(args) > 0 { + switch args[0] { + case "--all": + status = "" + case "--reading": + status = "reading" + case "--finished": + status = "finished" + case "--queued": + status = "queued" + default: + return fmt.Errorf("invalid status filter: %s (use: --all, --reading, --finished, --queued)", args[0]) + } + } + return c.handler.ListBooks(cmd.Context(), status) }, }) @@ -202,7 +240,7 @@ Use the -i flag for an interactive interface with navigation keys.`, Short: "Mark book as currently reading", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "reading"}) + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "reading") }, }) @@ -212,7 +250,7 @@ Use the -i flag for an interactive interface with navigation keys.`, Aliases: []string{"read"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "finished"}) + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "finished") }, }) @@ -222,7 +260,7 @@ Use the -i flag for an interactive interface with navigation keys.`, Aliases: []string{"rm"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "removed"}) + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "removed") }, }) @@ -231,7 +269,11 @@ Use the -i flag for an interactive interface with navigation keys.`, Short: "Update reading progress percentage (0-100)", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return handlers.UpdateBookProgress(cmd.Context(), args) + progress, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid progress percentage: %s", args[1]) + } + return c.handler.UpdateBookProgressByID(cmd.Context(), args[0], progress) }, }) @@ -240,20 +282,25 @@ Use the -i flag for an interactive interface with navigation keys.`, Short: "Update book status (queued|reading|finished|removed)", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return handlers.UpdateBookStatus(cmd.Context(), args) + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], args[1]) }, }) return root } -func noteCmd() *cobra.Command { - root := &cobra.Command{Use: "note", Short: "Manage notes"} +// NoteCommand implements [CommandGroup] for note-related commands +type NoteCommand struct { + handler *handlers.NoteHandler +} - handler, err := handlers.NewNoteHandler() - if err != nil { - log.Fatalf("failed to instantiate note handler: %v", err) - } +// NewNoteCommand creates a new NoteCommand with the given handler +func NewNoteCommand(handler *handlers.NoteHandler) *NoteCommand { + return &NoteCommand{handler: handler} +} + +func (c *NoteCommand) Create() *cobra.Command { + root := &cobra.Command{Use: "note", Short: "Manage notes"} createCmd := &cobra.Command{ Use: "create [title] [content...]", @@ -271,11 +318,8 @@ func noteCmd() *cobra.Command { content = strings.Join(args[1:], " ") } - if err != nil { - return err - } - defer handler.Close() - return handler.Create(cmd.Context(), title, content, filePath, interactive) + defer c.handler.Close() + return c.handler.Create(cmd.Context(), title, content, filePath, interactive) }, } createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") @@ -298,12 +342,8 @@ func noteCmd() *cobra.Command { } } - handler, err := handlers.NewNoteHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.List(cmd.Context(), false, archived, tags) + defer c.handler.Close() + return c.handler.List(cmd.Context(), false, archived, tags) }, } listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") @@ -320,12 +360,8 @@ func noteCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid note ID: %s", args[0]) } - handler, err := handlers.NewNoteHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.View(cmd.Context(), noteID) + defer c.handler.Close() + return c.handler.View(cmd.Context(), noteID) }, }) @@ -338,12 +374,8 @@ func noteCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid note ID: %s", args[0]) } - handler, err := handlers.NewNoteHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Edit(cmd.Context(), noteID) + defer c.handler.Close() + return c.handler.Edit(cmd.Context(), noteID) }, }) @@ -357,76 +389,10 @@ func noteCmd() *cobra.Command { if err != nil { return fmt.Errorf("invalid note ID: %s", args[0]) } - handler, err := handlers.NewNoteHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Delete(cmd.Context(), noteID) + defer c.handler.Close() + return c.handler.Delete(cmd.Context(), noteID) }, }) return root } - -func statusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show application status and configuration", - RunE: func(cmd *cobra.Command, args []string) error { - return handlers.Status(cmd.Context(), args) - }, - } -} - -func resetCmd() *cobra.Command { - return &cobra.Command{ - Use: "reset", - Short: "Reset the application (removes all data)", - RunE: func(cmd *cobra.Command, args []string) error { - return handlers.Reset(cmd.Context(), args) - }, - } -} - -func setupCmd() *cobra.Command { - handler, err := handlers.NewSeedHandler() - if err != nil { - log.Fatalf("failed to instantiate seed handler: %v", err) - } - - root := &cobra.Command{ - Use: "setup", - Short: "Initialize and manage application setup", - RunE: func(c *cobra.Command, args []string) error { - return handlers.Setup(c.Context(), args) - }, - } - - seedCmd := &cobra.Command{ - Use: "seed", - Short: "Populate database with test data", - Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", - RunE: func(c *cobra.Command, args []string) error { - force, _ := c.Flags().GetBool("force") - return handler.Seed(c.Context(), force) - }, - } - seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") - - root.AddCommand(seedCmd) - return root -} - -func confCmd() *cobra.Command { - return &cobra.Command{ - Use: "config [key] [value]", - Short: "Manage configuration", - Args: cobra.ExactArgs(2), - RunE: func(c *cobra.Command, args []string) error { - key, value := args[0], args[1] - fmt.Printf("Setting config %s = %s\n", key, value) - return nil - }, - } -} diff --git a/cmd/commands_test.go b/cmd/commands_test.go new file mode 100644 index 0000000..432894a --- /dev/null +++ b/cmd/commands_test.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "os" + "slices" + "testing" + + "github.com/stormlightlabs/noteleaf/internal/handlers" +) + +func setupCommandTest(t *testing.T) func() { + tempDir, err := os.MkdirTemp("", "noteleaf-cmd-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + + cleanup := func() { + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) + os.RemoveAll(tempDir) + } + + ctx := context.Background() + err = handlers.Setup(ctx, []string{}) + if err != nil { + cleanup() + t.Fatalf("Failed to setup database: %v", err) + } + + return cleanup +} + +func createTestTaskHandler(t *testing.T) (*handlers.TaskHandler, func()) { + cleanup := setupCommandTest(t) + handler, err := handlers.NewTaskHandler() + if err != nil { + cleanup() + t.Fatalf("Failed to create test task handler: %v", err) + } + return handler, func() { + handler.Close() + cleanup() + } +} + +func createTestMovieHandler(t *testing.T) (*handlers.MovieHandler, func()) { + cleanup := setupCommandTest(t) + handler, err := handlers.NewMovieHandler() + if err != nil { + cleanup() + t.Fatalf("Failed to create test movie handler: %v", err) + } + return handler, func() { + handler.Close() + cleanup() + } +} + +func createTestTVHandler(t *testing.T) (*handlers.TVHandler, func()) { + cleanup := setupCommandTest(t) + handler, err := handlers.NewTVHandler() + if err != nil { + cleanup() + t.Fatalf("Failed to create test TV handler: %v", err) + } + return handler, func() { + handler.Close() + cleanup() + } +} + +func createTestNoteHandler(t *testing.T) (*handlers.NoteHandler, func()) { + cleanup := setupCommandTest(t) + handler, err := handlers.NewNoteHandler() + if err != nil { + cleanup() + t.Fatalf("Failed to create test note handler: %v", err) + } + return handler, func() { + handler.Close() + cleanup() + } +} + +func createTestBookHandler(t *testing.T) (*handlers.BookHandler, func()) { + cleanup := setupCommandTest(t) + handler, err := handlers.NewBookHandler() + if err != nil { + cleanup() + t.Fatalf("Failed to create test book handler: %v", err) + } + return handler, func() { + handler.Close() + cleanup() + } +} + +func findSubcommand(commands []string, target string) bool { + return slices.Contains(commands, target) +} + +func TestCommandGroup(t *testing.T) { + t.Run("Interface Implementations", func(t *testing.T) { + taskHandler, taskCleanup := createTestTaskHandler(t) + defer taskCleanup() + + movieHandler, movieCleanup := createTestMovieHandler(t) + defer movieCleanup() + + tvHandler, tvCleanup := createTestTVHandler(t) + defer tvCleanup() + + noteHandler, noteCleanup := createTestNoteHandler(t) + defer noteCleanup() + + bookHandler, bookCleanup := createTestBookHandler(t) + defer bookCleanup() + + var _ CommandGroup = NewTaskCommand(taskHandler) + var _ CommandGroup = NewMovieCommand(movieHandler) + var _ CommandGroup = NewTVCommand(tvHandler) + var _ CommandGroup = NewNoteCommand(noteHandler) + var _ CommandGroup = NewBookCommand(bookHandler) + }) + + t.Run("Create", func(t *testing.T) { + t.Run("TaskCommand", func(t *testing.T) { + handler, cleanup := createTestTaskHandler(t) + defer cleanup() + + commands := NewTaskCommand(handler) + cmd := commands.Create() + + if cmd == nil { + t.Fatal("Create returned nil") + } + if cmd.Use != "todo" { + t.Errorf("Expected Use to be 'todo', got '%s'", cmd.Use) + } + if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "task" { + t.Errorf("Expected aliases to be ['task'], got %v", cmd.Aliases) + } + if cmd.Short != "task management" { + t.Errorf("Expected Short to be 'task management', got '%s'", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("Expected command to have subcommands") + } + }) + + t.Run("MovieCommand", func(t *testing.T) { + handler, cleanup := createTestMovieHandler(t) + defer cleanup() + + commands := NewMovieCommand(handler) + cmd := commands.Create() + + if cmd == nil { + t.Fatal("Create returned nil") + } + if cmd.Use != "movie" { + t.Errorf("Expected Use to be 'movie', got '%s'", cmd.Use) + } + if cmd.Short != "Manage movie watch queue" { + t.Errorf("Expected Short to be 'Manage movie watch queue', got '%s'", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("Expected command to have subcommands") + } + + subcommands := cmd.Commands() + subcommandNames := make([]string, len(subcommands)) + for i, subcmd := range subcommands { + subcommandNames[i] = subcmd.Use + } + + expectedSubcommands := []string{ + "add [search query...]", + "list [--all|--watched|--queued]", + "watched [id]", + "remove [id]", + } + + for _, expected := range expectedSubcommands { + if !findSubcommand(subcommandNames, expected) { + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) + } + } + }) + + t.Run("TVCommand", func(t *testing.T) { + handler, cleanup := createTestTVHandler(t) + defer cleanup() + + commands := NewTVCommand(handler) + cmd := commands.Create() + + if cmd == nil { + t.Fatal("Create returned nil") + } + if cmd.Use != "tv" { + t.Errorf("Expected Use to be 'tv', got '%s'", cmd.Use) + } + if cmd.Short != "Manage TV show watch queue" { + t.Errorf("Expected Short to be 'Manage TV show watch queue', got '%s'", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("Expected command to have subcommands") + } + + subcommands := cmd.Commands() + subcommandNames := make([]string, len(subcommands)) + for i, subcmd := range subcommands { + subcommandNames[i] = subcmd.Use + } + + expectedSubcommands := []string{ + "add [search query...]", + "list [--all|--queued|--watching|--watched]", + "watching [id]", + "watched [id]", + "remove [id]", + } + + for _, expected := range expectedSubcommands { + if !findSubcommand(subcommandNames, expected) { + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) + } + } + }) + + t.Run("NoteCommand", func(t *testing.T) { + handler, cleanup := createTestNoteHandler(t) + defer cleanup() + + commands := NewNoteCommand(handler) + cmd := commands.Create() + + if cmd == nil { + t.Fatal("Create returned nil") + } + if cmd.Use != "note" { + t.Errorf("Expected Use to be 'note', got '%s'", cmd.Use) + } + if cmd.Short != "Manage notes" { + t.Errorf("Expected Short to be 'Manage notes', got '%s'", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("Expected command to have subcommands") + } + + subcommands := cmd.Commands() + subcommandNames := make([]string, len(subcommands)) + for i, subcmd := range subcommands { + subcommandNames[i] = subcmd.Use + } + + expectedSubcommands := []string{ + "create [title] [content...]", + "list [--archived] [--tags=tag1,tag2]", + "read [note-id]", + "edit [note-id]", + "remove [note-id]", + } + + for _, expected := range expectedSubcommands { + if !findSubcommand(subcommandNames, expected) { + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) + } + } + }) + + t.Run("BookCommand", func(t *testing.T) { + handler, cleanup := createTestBookHandler(t) + defer cleanup() + + commands := NewBookCommand(handler) + cmd := commands.Create() + + if cmd == nil { + t.Fatal("Create returned nil") + } + if cmd.Use != "book" { + t.Errorf("Expected Use to be 'book', got '%s'", cmd.Use) + } + if cmd.Short != "Manage reading list" { + t.Errorf("Expected Short to be 'Manage reading list', got '%s'", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("Expected command to have subcommands") + } + + subcommands := cmd.Commands() + subcommandNames := make([]string, len(subcommands)) + for i, subcmd := range subcommands { + subcommandNames[i] = subcmd.Use + } + + expectedSubcommands := []string{ + "add [search query...]", + "list [--all|--reading|--finished|--queued]", + "reading ", + "finished ", + "remove ", + "progress ", + "update ", + } + + for _, expected := range expectedSubcommands { + if !findSubcommand(subcommandNames, expected) { + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) + } + } + }) + + t.Run("all command groups implement Create", func(t *testing.T) { + taskHandler, taskCleanup := createTestTaskHandler(t) + defer taskCleanup() + + movieHandler, movieCleanup := createTestMovieHandler(t) + defer movieCleanup() + + tvHandler, tvCleanup := createTestTVHandler(t) + defer tvCleanup() + + noteHandler, noteCleanup := createTestNoteHandler(t) + defer noteCleanup() + + bookHandler, bookCleanup := createTestBookHandler(t) + defer bookCleanup() + + groups := []CommandGroup{ + NewTaskCommand(taskHandler), + NewMovieCommand(movieHandler), + NewTVCommand(tvHandler), + NewNoteCommand(noteHandler), + NewBookCommand(bookHandler), + } + + for i, group := range groups { + cmd := group.Create() + if cmd == nil { + t.Errorf("CommandGroup %d returned nil from Create()", i) + continue + } + if cmd.Use == "" { + t.Errorf("CommandGroup %d returned command with empty Use", i) + } + } + }) + }) + +} diff --git a/cmd/main.go b/cmd/main.go index e32a6a1..d8e4401 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,9 +4,12 @@ import ( "context" "fmt" "os" + "strings" "github.com/charmbracelet/fang" + "github.com/charmbracelet/log" "github.com/spf13/cobra" + "github.com/stormlightlabs/noteleaf/internal/handlers" "github.com/stormlightlabs/noteleaf/internal/store" "github.com/stormlightlabs/noteleaf/internal/ui" "github.com/stormlightlabs/noteleaf/internal/utils" @@ -28,7 +31,7 @@ func NewApp() (*App, error) { if config, err := store.LoadConfig(); err != nil { return nil, fmt.Errorf("failed to load configuration: %w", err) } else { - return &App{db: db, config: config}, nil + return &App{db, config}, nil } } @@ -40,6 +43,92 @@ func (app *App) Close() error { return nil } +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show application status and configuration", + RunE: func(cmd *cobra.Command, args []string) error { + return handlers.Status(cmd.Context(), args) + }, + } +} + +func resetCmd() *cobra.Command { + return &cobra.Command{ + Use: "reset", + Short: "Reset the application (removes all data)", + RunE: func(cmd *cobra.Command, args []string) error { + return handlers.Reset(cmd.Context(), args) + }, + } +} + +func rootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "noteleaf", + Long: ui.Georgia.ColoredInViewport(), + Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return c.Help() + } + + output := strings.Join(args, " ") + fmt.Println(output) + return nil + }, + } + + root.SetHelpCommand(&cobra.Command{Hidden: true}) + cobra.EnableCommandSorting = false + + root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) + root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) + return root +} + +func setupCmd() *cobra.Command { + handler, err := handlers.NewSeedHandler() + if err != nil { + log.Fatalf("failed to instantiate seed handler: %v", err) + } + + root := &cobra.Command{ + Use: "setup", + Short: "Initialize and manage application setup", + RunE: func(c *cobra.Command, args []string) error { + return handlers.Setup(c.Context(), args) + }, + } + + seedCmd := &cobra.Command{ + Use: "seed", + Short: "Populate database with test data", + Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", + RunE: func(c *cobra.Command, args []string) error { + force, _ := c.Flags().GetBool("force") + return handler.Seed(c.Context(), force) + }, + } + seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") + + root.AddCommand(seedCmd) + return root +} + +func confCmd() *cobra.Command { + return &cobra.Command{ + Use: "config [key] [value]", + Short: "Manage configuration", + Args: cobra.ExactArgs(2), + RunE: func(c *cobra.Command, args []string) error { + key, value := args[0], args[1] + fmt.Printf("Setting config %s = %s\n", key, value) + return nil + }, + } +} + func main() { logger := utils.NewLogger("info", "text") utils.Logger = logger @@ -50,16 +139,52 @@ func main() { } defer app.Close() + taskHandler, err := handlers.NewTaskHandler() + if err != nil { + log.Fatalf("failed to create task handler: %v", err) + } + + movieHandler, err := handlers.NewMovieHandler() + if err != nil { + log.Fatalf("failed to create movie handler: %v", err) + } + + tvHandler, err := handlers.NewTVHandler() + if err != nil { + log.Fatalf("failed to create TV handler: %v", err) + } + + noteHandler, err := handlers.NewNoteHandler() + if err != nil { + log.Fatalf("failed to create note handler: %v", err) + } + + bookHandler, err := handlers.NewBookHandler() + if err != nil { + log.Fatalf("failed to create book handler: %v", err) + } + root := rootCmd() - core := []func() *cobra.Command{todoCmd, mediaCmd, noteCmd} - mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} - for _, cmdFunc := range core { - cmd := cmdFunc() + coreGroups := []CommandGroup{ + NewTaskCommand(taskHandler), + NewNoteCommand(noteHandler), + } + + for _, group := range coreGroups { + cmd := group.Create() cmd.GroupID = "core" root.AddCommand(cmd) } + mediaCmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} + mediaCmd.GroupID = "core" + mediaCmd.AddCommand(NewMovieCommand(movieHandler).Create()) + mediaCmd.AddCommand(NewTVCommand(tvHandler).Create()) + mediaCmd.AddCommand(NewBookCommand(bookHandler).Create()) + root.AddCommand(mediaCmd) + + mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} for _, cmdFunc := range mgmt { cmd := cmdFunc() cmd.GroupID = "management" diff --git a/cmd/task_commands.go b/cmd/task_commands.go index c6e7d45..170536a 100644 --- a/cmd/task_commands.go +++ b/cmd/task_commands.go @@ -5,6 +5,31 @@ import ( "github.com/stormlightlabs/noteleaf/internal/handlers" ) +// TaskCommand implements CommandGroup for task-related commands +type TaskCommand struct { + handler *handlers.TaskHandler +} + +// NewTaskCommand creates a new TaskCommands with the given handler +func NewTaskCommand(handler *handlers.TaskHandler) *TaskCommand { + return &TaskCommand{handler: handler} +} + +func (c *TaskCommand) Create() *cobra.Command { + root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} + + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, + deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, + taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, + } { + cmd := init(c.handler) + root.AddCommand(cmd) + } + + return root +} + func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { cmd := &cobra.Command{ Use: "add [description]", diff --git a/codecov.yml b/codecov.yml index b323fd1..7371ab8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,6 +20,6 @@ ignore: - "**/*_test.go" - "**/testdata/**" - "**/vendor/**" - - "cmd/*.go" + - "cmd/main.go" - "internal/repo/test_utilities.go" - "internal/handlers/test_utilities.go" diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..57f4a7c --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,33 @@ +# CLI Docs + +## CommandGroup Interface Pattern + +This section outlines the CommandGroup interface pattern for implementing CLI commands in the noteleaf application. + +### Core Concepts + +Each major command group implements the CommandGroup interface with a `Create() *cobra.Command` method. Command groups receive handlers as constructor dependencies, enabling dependency injection for testing. Handler initialization occurs centrally in main.go with `log.Fatalf` error handling to fail fast during application startup. + +### CommandGroup Interface + +interface `CommandGroup` provides a consistent contract for all command groups. Each implementation encapsulates related commands and the shared handler dependency. The Create method returns a fully configured cobra command tree. + +#### Implementations + +TaskCommands handles todo and task-related operations using TaskHandler. MovieCommand manages movie queue operations via MovieHandler. +TVCommand handles TV show queue operations through TVHandler. NoteCommand manages note operations using NoteHandler. + +### Handler Lifecycle + +Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures. +Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances. + +### Testing Benefits + +`CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing. +Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing. + +### Registry Pattern + +`main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality. +Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..f049643 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,106 @@ +# Testing Documentation + +This document outlines the testing patterns and practices used in the noteleaf application. + +## Testing Principles + +The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library `testing` package and avoid mock frameworks or assertion libraries. This keeps dependencies minimal and tests readable using standard Go patterns. + +## Test File Organization + +Test files follow the standard Go convention of `*_test.go` naming. Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. + +## Testing Patterns + +### Handler Creation Pattern + +Tests create real handler instances using temporary databases to ensure test isolation. Factory functions handle both database setup and handler initialization, returning both the handler and a cleanup function. + +### Database Isolation + +Tests use temporary directories and environment variable manipulation to create isolated database instances. Each test gets its own temporary SQLite database that is automatically cleaned up after the test completes. + +The `setupCommandTest` function creates a temporary directory, sets `XDG_CONFIG_HOME` to point to it, and initializes the database schema. This ensures tests don't interfere with each other or with development data. + +### Resource Management + +Tests properly manage resources using cleanup functions returned by factory methods. The cleanup function handles both handler closure and temporary directory removal. This pattern ensures complete resource cleanup even if tests fail. + +### Error Handling + +Tests use `t.Fatal` for setup errors that prevent test execution and `t.Error` for test assertion failures. Fatal errors stop test execution while errors allow tests to continue checking other conditions. + +### Command Structure Testing + +Command group tests verify cobra command structure including use strings, aliases, short descriptions, and subcommand presence. Tests check that commands are properly configured without executing their logic. + +### Interface Compliance Testing + +Tests verify interface compliance using compile-time checks with blank identifier assignments. This ensures structs implement expected interfaces without runtime overhead. + +```go +var _ CommandGroup = NewTaskCommands(handler) +``` + +## Test Organization Patterns + +### Single Root Test Pattern + +The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. + +```go +func TestCommandGroup(t *testing.T) { + t.Run("Interface Implementations", func(t *testing.T) { + // Test interface compliance + }) + + t.Run("Create", func(t *testing.T) { + t.Run("TaskCommand", func(t *testing.T) { + // Test task command creation + }) + t.Run("MovieCommand", func(t *testing.T) { + // Test movie command creation + }) + }) +} +``` + +This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections with `go test -run TestCommandGroup/Create/TaskCommand`, consistent test structure across the codebase, and shared setup that can be inherited by subtests. + +### Integration vs Unit Testing + +The codebase emphasizes integration testing over heavy mocking. Tests use real handlers and services to verify actual behavior rather than mocked interactions. This approach catches integration issues while maintaining test reliability. + +### Static Output Testing + +UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. + +## Test Utilities + +### Helper Functions + +Test files include helper functions for creating test data and finding elements in collections. These utilities reduce code duplication and improve test readability. + +### Mock Data Creation + +Tests create realistic mock data using factory functions that return properly initialized structs with sensible defaults. This approach provides consistent test data across different test cases. + +## Testing CLI Commands + +Command group tests focus on structure verification rather than execution testing. Tests check command configuration, subcommand presence, and interface compliance. This approach ensures command trees are properly constructed without requiring complex execution mocking. + +### CommandGroup Interface Testing + +The CommandGroup interface enables testable CLI architecture. Tests verify that command groups implement the interface correctly and return properly configured cobra commands. This pattern separates command structure from command execution. + +Interface compliance is tested using compile-time checks within the "Interface Implementations" subtest, ensuring all command structs properly implement the CommandGroup interface without runtime overhead. + +## Performance Considerations + +Tests avoid expensive operations in setup functions. Handler creation uses real instances but tests focus on structure verification rather than full execution paths. This keeps test suites fast while maintaining coverage of critical functionality. + +The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases. + +## Best Practices Summary + +Use factory functions for test handler creation with proper cleanup patterns. Organize tests using single root test functions with nested subtests for clear hierarchy. Manage resources with cleanup functions returned by factory methods. Prefer integration testing over mocking for real-world behavior validation. Verify interface compliance at compile time within dedicated subtests. Focus command tests on structure verification rather than execution testing. Leverage the single root test pattern for logical grouping and selective test execution. Use realistic test data with factory functions for consistent test scenarios. diff --git a/go.mod b/go.mod index 579ad28..90db5ce 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.5 require ( github.com/BurntSushi/toml v1.5.0 - github.com/charmbracelet/fang v0.3.0 + github.com/charmbracelet/fang v0.4.2 github.com/mattn/go-sqlite3 v1.14.32 github.com/spf13/cobra v1.9.1 ) @@ -48,26 +48,26 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/term v0.31.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/term v0.35.0 // indirect ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 github.com/charmbracelet/log v0.4.2 - github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/gocolly/colly/v2 v2.2.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -80,6 +80,7 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.28.0 + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 + golang.org/x/tools v0.37.0 ) diff --git a/go.sum b/go.sum index 5e71ed7..d23d56f 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,10 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= -github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas= +github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= @@ -43,8 +43,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= @@ -89,8 +89,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -171,8 +171,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -180,8 +180,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -196,8 +196,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -208,8 +208,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -220,8 +220,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -230,6 +230,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= diff --git a/internal/handlers/books.go b/internal/handlers/books.go index 129926b..908ca1a 100644 --- a/internal/handlers/books.go +++ b/internal/handlers/books.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "slices" + "strconv" "time" "github.com/stormlightlabs/noteleaf/internal/models" @@ -53,55 +54,40 @@ func (h *BookHandler) Close() error { return h.db.Close() } -// SearchAndAdd searches for books and allows user to select and add to queue -func SearchAndAdd(ctx context.Context, args []string) error { - handler, err := NewBookHandler() - if err != nil { - return fmt.Errorf("failed to initialize book handler: %w", err) - } - defer handler.Close() - - return handler.searchAndAdd(ctx, args) -} +func (h *BookHandler) printBook(book *models.Book) { + fmt.Printf("[%d] %s", book.ID, book.Title) -// SearchAndAddWithOptions searches for books with interactive option -func SearchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error { - handler, err := NewBookHandler() - if err != nil { - return fmt.Errorf("failed to initialize book handler: %w", err) + if book.Author != "" { + fmt.Printf(" by %s", book.Author) } - defer handler.Close() - - return handler.searchAndAddWithOptions(ctx, args, interactive) -} -func (h *BookHandler) searchAndAdd(ctx context.Context, args []string) error { - if len(args) == 0 { - return fmt.Errorf("usage: book add [-i for interactive mode]") + if book.Status != "queued" { + fmt.Printf(" (%s)", book.Status) } - interactive := false - searchArgs := args - if len(args) > 0 && args[len(args)-1] == "-i" { - interactive = true - searchArgs = args[:len(args)-1] + if book.Progress > 0 { + fmt.Printf(" [%d%%]", book.Progress) } - if len(searchArgs) == 0 { - return fmt.Errorf("search query cannot be empty") + if book.Rating > 0 { + fmt.Printf(" ★%.1f", book.Rating) } - query := searchArgs[0] - if len(searchArgs) > 1 { - for _, arg := range searchArgs[1:] { - query += " " + arg + fmt.Println() + + if book.Notes != "" { + notes := book.Notes + if len(notes) > 80 { + notes = notes[:77] + "..." } + fmt.Printf(" %s\n", notes) } - return h.searchAndAddWithOptions(ctx, searchArgs, interactive) + fmt.Println() } -func (h *BookHandler) searchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error { +// SearchAndAddBook searches for books and allows user to select and add to queue +func (h *BookHandler) SearchAndAddBook(ctx context.Context, args []string, interactive bool) error { if len(args) == 0 { return fmt.Errorf("usage: book add ") } @@ -187,32 +173,8 @@ func (h *BookHandler) searchAndAddWithOptions(ctx context.Context, args []string return nil } -// ListBooks lists all books in the queue -func ListBooks(ctx context.Context, args []string) error { - handler, err := NewBookHandler() - if err != nil { - return fmt.Errorf("failed to initialize book handler: %w", err) - } - defer handler.Close() - - return handler.listBooks(ctx, args) -} - -func (h *BookHandler) listBooks(ctx context.Context, args []string) error { - status := "queued" - if len(args) > 0 { - switch args[0] { - case "all", "--all", "-a": - status = "" - case "reading", "--reading", "-r": - status = "reading" - case "finished", "--finished", "-f": - status = "finished" - case "queued", "--queued", "-q": - status = "queued" - } - } - +// ListBooks lists all books with status filtering +func (h *BookHandler) ListBooks(ctx context.Context, status string) error { var books []*models.Book var err error @@ -252,30 +214,15 @@ func (h *BookHandler) listBooks(ctx context.Context, args []string) error { return nil } -func UpdateBookStatus(ctx context.Context, args []string) error { - handler, err := NewBookHandler() +// UpdateBookStatusByID changes the status of a book +func (h *BookHandler) UpdateBookStatusByID(ctx context.Context, id, status string) error { + bookID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return fmt.Errorf("failed to initialize book handler: %w", err) - } - defer handler.Close() - - return handler.updateBookStatus(ctx, args) -} - -func (h *BookHandler) updateBookStatus(ctx context.Context, args []string) error { - if len(args) < 2 { - return fmt.Errorf("usage: book update ") - } - - var bookID int64 - if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil { - return fmt.Errorf("invalid book ID: %s", args[0]) + return fmt.Errorf("invalid book ID: %s", id) } - status := args[1] validStatuses := []string{"queued", "reading", "finished", "removed"} - valid := slices.Contains(validStatuses, status) - if !valid { + if !slices.Contains(validStatuses, status) { return fmt.Errorf("invalid status: %s (valid: %v)", status, validStatuses) } @@ -303,30 +250,11 @@ func (h *BookHandler) updateBookStatus(ctx context.Context, args []string) error return nil } -// UpdateBookProgress updates a book's reading progress percentage -func UpdateBookProgress(ctx context.Context, args []string) error { - handler, err := NewBookHandler() +// UpdateBookProgressByID updates a book's reading progress percentage +func (h *BookHandler) UpdateBookProgressByID(ctx context.Context, id string, progress int) error { + bookID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return fmt.Errorf("failed to initialize book handler: %w", err) - } - defer handler.Close() - - return handler.updateBookProgress(ctx, args) -} - -func (h *BookHandler) updateBookProgress(ctx context.Context, args []string) error { - if len(args) < 2 { - return fmt.Errorf("usage: book progress ") - } - - var bookID int64 - if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil { - return fmt.Errorf("invalid book ID: %s", args[0]) - } - - var progress int - if _, err := fmt.Sscanf(args[1], "%d", &progress); err != nil { - return fmt.Errorf("invalid progress percentage: %s", args[1]) + return fmt.Errorf("invalid book ID: %s", id) } if progress < 0 || progress > 100 { @@ -368,35 +296,3 @@ func (h *BookHandler) updateBookProgress(ctx context.Context, args []string) err fmt.Println() return nil } - -func (h *BookHandler) printBook(book *models.Book) { - fmt.Printf("[%d] %s", book.ID, book.Title) - - if book.Author != "" { - fmt.Printf(" by %s", book.Author) - } - - if book.Status != "queued" { - fmt.Printf(" (%s)", book.Status) - } - - if book.Progress > 0 { - fmt.Printf(" [%d%%]", book.Progress) - } - - if book.Rating > 0 { - fmt.Printf(" ★%.1f", book.Rating) - } - - fmt.Println() - - if book.Notes != "" { - notes := book.Notes - if len(notes) > 80 { - notes = notes[:77] + "..." - } - fmt.Printf(" %s\n", notes) - } - - fmt.Println() -} diff --git a/internal/handlers/books_test.go b/internal/handlers/books_test.go index 47ea641..79572da 100644 --- a/internal/handlers/books_test.go +++ b/internal/handlers/books_test.go @@ -104,47 +104,21 @@ func TestBookHandler(t *testing.T) { }) }) - t.Run("Search & Add", func(t *testing.T) { + t.Run("BookHandler instance methods", func(t *testing.T) { _, cleanup := setupBookTest(t) defer cleanup() - t.Run("fails with empty args", func(t *testing.T) { - ctx := context.Background() - args := []string{} - - err := SearchAndAdd(ctx, args) - if err == nil { - t.Error("Expected error for empty args") - } - - if !strings.Contains(err.Error(), "usage: book add") { - t.Errorf("Expected usage error, got: %v", err) - } - }) + handler, err := NewBookHandler() + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() - t.Run("fails with empty search", func(t *testing.T) { + t.Run("Search & Add", func(t *testing.T) { ctx := context.Background() - args := []string{"-i"} - - err := SearchAndAdd(ctx, args) - if err == nil { - t.Error("Expected error for empty search query") - } - - if !strings.Contains(err.Error(), "search query cannot be empty") { - t.Errorf("Expected empty search query error, got: %v", err) - } - }) - - t.Run("with options", func(t *testing.T) { - _, cleanup := setupBookTest(t) - defer cleanup() - t.Run("fails with empty args", func(t *testing.T) { - ctx := context.Background() args := []string{} - - err := SearchAndAddWithOptions(ctx, args, false) + err := handler.SearchAndAddBook(ctx, args, false) if err == nil { t.Error("Expected error for empty args") } @@ -154,415 +128,344 @@ func TestBookHandler(t *testing.T) { } }) - t.Run("handles search service errors", func(t *testing.T) { - ctx := context.Background() - args := []string{"test", "book"} - - err := SearchAndAddWithOptions(ctx, args, false) - if err == nil { - t.Error("Expected error due to mocked service") - } - if strings.Contains(err.Error(), "usage:") { - t.Error("Should not show usage error for valid args") + t.Run("handles empty search", func(t *testing.T) { + args := []string{""} + err := handler.SearchAndAddBook(ctx, args, false) + if err != nil && !strings.Contains(err.Error(), "No books found") { + t.Errorf("Expected no error or 'No books found', got: %v", err) } }) - }) - }) - - t.Run("List", func(t *testing.T) { - _, cleanup := setupBookTest(t) - defer cleanup() - - ctx := context.Background() - - handler, err := NewBookHandler() - if err != nil { - t.Fatalf("Failed to create handler: %v", err) - } - defer handler.Close() - - _ = createTestBook(t, handler, ctx) - - book2 := &models.Book{ - Title: "Reading Book", - Author: "Reading Author", - Status: "reading", - Added: time.Now(), - } - id2, err := handler.repos.Books.Create(ctx, book2) - if err != nil { - t.Fatalf("Failed to create book2: %v", err) - } - book2.ID = id2 - - book3 := &models.Book{ - Title: "Finished Book", - Author: "Finished Author", - Status: "finished", - Added: time.Now(), - } - id3, err := handler.repos.Books.Create(ctx, book3) - if err != nil { - t.Fatalf("Failed to create book3: %v", err) - } - book3.ID = id3 - - t.Run("lists queued books by default", func(t *testing.T) { - args := []string{} - - err := ListBooks(ctx, args) - if err != nil { - t.Errorf("ListBooks failed: %v", err) - } - }) + t.Run("with options", func(t *testing.T) { + ctx := context.Background() + t.Run("fails with empty args", func(t *testing.T) { + args := []string{} + err := handler.SearchAndAddBook(ctx, args, false) + if err == nil { + t.Error("Expected error for empty args") + } - t.Run("filters by status - all", func(t *testing.T) { - args := []string{"all"} + if !strings.Contains(err.Error(), "usage: book add") { + t.Errorf("Expected usage error, got: %v", err) + } + }) - err := ListBooks(ctx, args) - if err != nil { - t.Errorf("ListBooks with status all failed: %v", err) - } + t.Run("handles search service errors", func(t *testing.T) { + args := []string{"test", "book"} + err := handler.SearchAndAddBook(ctx, args, false) + if err == nil { + t.Error("Expected error due to mocked service") + } + if strings.Contains(err.Error(), "usage:") { + t.Error("Should not show usage error for valid args") + } + }) + }) }) - t.Run("filters by status - reading", func(t *testing.T) { - args := []string{"reading"} + t.Run("List", func(t *testing.T) { - err := ListBooks(ctx, args) - if err != nil { - t.Errorf("ListBooks with status reading failed: %v", err) - } - }) + ctx := context.Background() - t.Run("filters by status - finished", func(t *testing.T) { - args := []string{"finished"} + _ = createTestBook(t, handler, ctx) - err := ListBooks(ctx, args) - if err != nil { - t.Errorf("ListBooks with status finished failed: %v", err) + book2 := &models.Book{ + Title: "Reading Book", + Author: "Reading Author", + Status: "reading", + Added: time.Now(), } - }) - - t.Run("filters by status - queued", func(t *testing.T) { - args := []string{"queued"} - - err := ListBooks(ctx, args) + id2, err := handler.repos.Books.Create(ctx, book2) if err != nil { - t.Errorf("ListBooks with status queued failed: %v", err) + t.Fatalf("Failed to create book2: %v", err) } - }) + book2.ID = id2 - t.Run("handles various flag formats", func(t *testing.T) { - statusVariants := [][]string{ - {"--all"}, {"-a"}, - {"--reading"}, {"-r"}, - {"--finished"}, {"-f"}, - {"--queued"}, {"-q"}, + book3 := &models.Book{ + Title: "Finished Book", + Author: "Finished Author", + Status: "finished", + Added: time.Now(), } - - for _, args := range statusVariants { - err := ListBooks(ctx, args) - if err != nil { - t.Errorf("ListBooks with args %v failed: %v", args, err) - } - } - }) - }) - - t.Run("Update", func(t *testing.T) { - t.Run("Update status", func(t *testing.T) { - _, cleanup := setupBookTest(t) - defer cleanup() - - ctx := context.Background() - - handler, err := NewBookHandler() + id3, err := handler.repos.Books.Create(ctx, book3) if err != nil { - t.Fatalf("Failed to create handler: %v", err) + t.Fatalf("Failed to create book3: %v", err) } - defer handler.Close() - - book := createTestBook(t, handler, ctx) - - t.Run("updates book status successfully", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "reading"} + book3.ID = id3 - err := UpdateBookStatus(ctx, args) + t.Run("lists queued books by default", func(t *testing.T) { + err := handler.ListBooks(ctx, "queued") if err != nil { - t.Errorf("UpdateBookStatus failed: %v", err) + t.Errorf("ListBooks failed: %v", err) } + }) - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + t.Run("filters by status - all", func(t *testing.T) { + err := handler.ListBooks(ctx, "") if err != nil { - t.Fatalf("Failed to get updated book: %v", err) - } - - if updatedBook.Status != "reading" { - t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) - } - - if updatedBook.Started == nil { - t.Error("Expected started time to be set") + t.Errorf("ListBooks with status all failed: %v", err) } }) - t.Run("updates to finished status", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "finished"} - - err := UpdateBookStatus(ctx, args) + t.Run("filters by status - reading", func(t *testing.T) { + err := handler.ListBooks(ctx, "reading") if err != nil { - t.Errorf("UpdateBookStatus failed: %v", err) + t.Errorf("ListBooks with status reading failed: %v", err) } + }) - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + t.Run("filters by status - finished", func(t *testing.T) { + err := handler.ListBooks(ctx, "finished") if err != nil { - t.Fatalf("Failed to get updated book: %v", err) - } - - if updatedBook.Status != "finished" { - t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) - } - - if updatedBook.Finished == nil { - t.Error("Expected finished time to be set") - } - - if updatedBook.Progress != 100 { - t.Errorf("Expected progress 100, got %d", updatedBook.Progress) + t.Errorf("ListBooks with status finished failed: %v", err) } }) - t.Run("fails with insufficient arguments", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10)} - - err := UpdateBookStatus(ctx, args) - if err == nil { - t.Error("Expected error for insufficient arguments") - } - - if !strings.Contains(err.Error(), "usage: book update") { - t.Errorf("Expected usage error, got: %v", err) + t.Run("filters by status - queued", func(t *testing.T) { + err := handler.ListBooks(ctx, "queued") + if err != nil { + t.Errorf("ListBooks with status queued failed: %v", err) } }) - t.Run("fails with invalid book ID", func(t *testing.T) { - args := []string{"invalid-id", "reading"} - - err := UpdateBookStatus(ctx, args) - if err == nil { - t.Error("Expected error for invalid book ID") + t.Run("handles various flag formats", func(t *testing.T) { + statusVariants := map[string]string{ + "--all": "", "-a": "", + "--reading": "reading", "-r": "reading", + "--finished": "finished", "-f": "finished", + "--queued": "queued", "-q": "queued", } - if !strings.Contains(err.Error(), "invalid book ID") { - t.Errorf("Expected invalid book ID error, got: %v", err) + for flag, status := range statusVariants { + err := handler.ListBooks(ctx, status) + if err != nil { + t.Errorf("ListBooks with flag %s (status %s) failed: %v", flag, status, err) + } } }) + }) - t.Run("fails with invalid status", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "invalid-status"} - - err := UpdateBookStatus(ctx, args) - if err == nil { - t.Error("Expected error for invalid status") - } - - if !strings.Contains(err.Error(), "invalid status") { - t.Errorf("Expected invalid status error, got: %v", err) - } - }) + t.Run("Update", func(t *testing.T) { + t.Run("Update status", func(t *testing.T) { + ctx := context.Background() + book := createTestBook(t, handler, ctx) - t.Run("fails with non-existent book ID", func(t *testing.T) { - args := []string{"99999", "reading"} + t.Run("updates book status successfully", func(t *testing.T) { + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "reading") + if err != nil { + t.Errorf("UpdateBookStatusByID failed: %v", err) + } - err := UpdateBookStatus(ctx, args) - if err == nil { - t.Error("Expected error for non-existent book ID") - } + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + if err != nil { + t.Fatalf("Failed to get updated book: %v", err) + } - if !strings.Contains(err.Error(), "failed to get book") { - t.Errorf("Expected book not found error, got: %v", err) - } - }) + if updatedBook.Status != "reading" { + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) + } - t.Run("validates all status options", func(t *testing.T) { - validStatuses := []string{"queued", "reading", "finished", "removed"} + if updatedBook.Started == nil { + t.Error("Expected started time to be set") + } + }) - for _, status := range validStatuses { - args := []string{strconv.FormatInt(book.ID, 10), status} + t.Run("updates to finished status", func(t *testing.T) { + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") + if err != nil { + t.Errorf("UpdateBookStatusByID failed: %v", err) + } - err := UpdateBookStatus(ctx, args) + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) if err != nil { - t.Errorf("UpdateBookStatus with status %s failed: %v", status, err) + t.Fatalf("Failed to get updated book: %v", err) } - } - }) - }) - t.Run("progress", func(t *testing.T) { - _, cleanup := setupBookTest(t) - defer cleanup() + if updatedBook.Status != "finished" { + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) + } - ctx := context.Background() + if updatedBook.Finished == nil { + t.Error("Expected finished time to be set") + } - handler, err := NewBookHandler() - if err != nil { - t.Fatalf("Failed to create handler: %v", err) - } - defer handler.Close() + if updatedBook.Progress != 100 { + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) + } + }) - book := createTestBook(t, handler, ctx) + t.Run("fails with invalid book ID", func(t *testing.T) { + err := handler.UpdateBookStatusByID(ctx, "invalid-id", "reading") + if err == nil { + t.Error("Expected error for invalid book ID") + } - t.Run("updates progress successfully", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "50"} + if !strings.Contains(err.Error(), "invalid book ID") { + t.Errorf("Expected invalid book ID error, got: %v", err) + } + }) - err := UpdateBookProgress(ctx, args) - if err != nil { - t.Errorf("UpdateBookProgress failed: %v", err) - } + t.Run("fails with invalid status", func(t *testing.T) { + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "invalid-status") + if err == nil { + t.Error("Expected error for invalid status") + } - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) - if err != nil { - t.Fatalf("Failed to get updated book: %v", err) - } + if !strings.Contains(err.Error(), "invalid status") { + t.Errorf("Expected invalid status error, got: %v", err) + } + }) - if updatedBook.Progress != 50 { - t.Errorf("Expected progress 50, got %d", updatedBook.Progress) - } + t.Run("fails with non-existent book ID", func(t *testing.T) { + err := handler.UpdateBookStatusByID(ctx, "99999", "reading") + if err == nil { + t.Error("Expected error for non-existent book ID") + } - if updatedBook.Status != "reading" { - t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) - } + if !strings.Contains(err.Error(), "failed to get book") { + t.Errorf("Expected book not found error, got: %v", err) + } + }) - if updatedBook.Started == nil { - t.Error("Expected started time to be set") - } + t.Run("validates all status options", func(t *testing.T) { + validStatuses := []string{"queued", "reading", "finished", "removed"} + + for _, status := range validStatuses { + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), status) + if err != nil { + t.Errorf("UpdateBookStatusByID with status %s failed: %v", status, err) + } + } + }) }) - t.Run("auto-completes book at 100%", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "100"} + t.Run("progress", func(t *testing.T) { + _, cleanup := setupBookTest(t) + defer cleanup() - err := UpdateBookProgress(ctx, args) - if err != nil { - t.Errorf("UpdateBookProgress failed: %v", err) - } + ctx := context.Background() - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + handler, err := NewBookHandler() if err != nil { - t.Fatalf("Failed to get updated book: %v", err) + t.Fatalf("Failed to create handler: %v", err) } + defer handler.Close() - if updatedBook.Progress != 100 { - t.Errorf("Expected progress 100, got %d", updatedBook.Progress) - } + book := createTestBook(t, handler, ctx) - if updatedBook.Status != "finished" { - t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) - } + t.Run("updates progress successfully", func(t *testing.T) { + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) + if err != nil { + t.Errorf("UpdateBookProgressByID failed: %v", err) + } - if updatedBook.Finished == nil { - t.Error("Expected finished time to be set") - } - }) + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + if err != nil { + t.Fatalf("Failed to get updated book: %v", err) + } - t.Run("resets to queued at 0%", func(t *testing.T) { - book.Status = "reading" - now := time.Now() - book.Started = &now - handler.repos.Books.Update(ctx, book) + if updatedBook.Progress != 50 { + t.Errorf("Expected progress 50, got %d", updatedBook.Progress) + } - args := []string{strconv.FormatInt(book.ID, 10), "0"} + if updatedBook.Status != "reading" { + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) + } - err := UpdateBookProgress(ctx, args) - if err != nil { - t.Errorf("UpdateBookProgress failed: %v", err) - } + if updatedBook.Started == nil { + t.Error("Expected started time to be set") + } + }) - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) - if err != nil { - t.Fatalf("Failed to get updated book: %v", err) - } + t.Run("auto-completes book at 100%", func(t *testing.T) { + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) + if err != nil { + t.Errorf("UpdateBookProgressByID failed: %v", err) + } - if updatedBook.Progress != 0 { - t.Errorf("Expected progress 0, got %d", updatedBook.Progress) - } + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + if err != nil { + t.Fatalf("Failed to get updated book: %v", err) + } - if updatedBook.Status != "queued" { - t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) - } + if updatedBook.Progress != 100 { + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) + } - if updatedBook.Started != nil { - t.Error("Expected started time to be nil") - } - }) + if updatedBook.Status != "finished" { + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) + } - t.Run("fails with insufficient arguments", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10)} + if updatedBook.Finished == nil { + t.Error("Expected finished time to be set") + } + }) - err := UpdateBookProgress(ctx, args) - if err == nil { - t.Error("Expected error for insufficient arguments") - } + t.Run("resets to queued at 0%", func(t *testing.T) { + book.Status = "reading" + now := time.Now() + book.Started = &now + handler.repos.Books.Update(ctx, book) - if !strings.Contains(err.Error(), "usage: book progress") { - t.Errorf("Expected usage error, got: %v", err) - } - }) + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 0) + if err != nil { + t.Errorf("UpdateBookProgressByID failed: %v", err) + } - t.Run("fails with invalid book ID", func(t *testing.T) { - args := []string{"invalid-id", "50"} + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) + if err != nil { + t.Fatalf("Failed to get updated book: %v", err) + } - err := UpdateBookProgress(ctx, args) - if err == nil { - t.Error("Expected error for invalid book ID") - } + if updatedBook.Progress != 0 { + t.Errorf("Expected progress 0, got %d", updatedBook.Progress) + } - if !strings.Contains(err.Error(), "invalid book ID") { - t.Errorf("Expected invalid book ID error, got: %v", err) - } - }) + if updatedBook.Status != "queued" { + t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) + } - t.Run("fails with invalid progress percentage", func(t *testing.T) { - args := []string{strconv.FormatInt(book.ID, 10), "invalid-progress"} + if updatedBook.Started != nil { + t.Error("Expected started time to be nil") + } + }) - err := UpdateBookProgress(ctx, args) - if err == nil { - t.Error("Expected error for invalid progress percentage") - } + t.Run("fails with invalid book ID", func(t *testing.T) { + err := handler.UpdateBookProgressByID(ctx, "invalid-id", 50) + if err == nil { + t.Error("Expected error for invalid book ID") + } - if !strings.Contains(err.Error(), "invalid progress percentage") { - t.Errorf("Expected invalid progress percentage error, got: %v", err) - } - }) + if !strings.Contains(err.Error(), "invalid book ID") { + t.Errorf("Expected invalid book ID error, got: %v", err) + } + }) - t.Run("fails with progress out of range", func(t *testing.T) { - testCases := []string{"-1", "101", "150"} + t.Run("fails with progress out of range", func(t *testing.T) { + testCases := []int{-1, 101, 150} - for _, progress := range testCases { - args := []string{strconv.FormatInt(book.ID, 10), progress} + for _, progress := range testCases { + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), progress) + if err == nil { + t.Errorf("Expected error for progress %d", progress) + } - err := UpdateBookProgress(ctx, args) - if err == nil { - t.Errorf("Expected error for progress %s", progress) + if !strings.Contains(err.Error(), "progress must be between 0 and 100") { + t.Errorf("Expected range error for progress %d, got: %v", progress, err) + } } + }) - if !strings.Contains(err.Error(), "progress must be between 0 and 100") { - t.Errorf("Expected range error for progress %s, got: %v", progress, err) + t.Run("fails with non-existent book ID", func(t *testing.T) { + err := handler.UpdateBookProgressByID(ctx, "99999", 50) + if err == nil { + t.Error("Expected error for non-existent book ID") } - } - }) - - t.Run("fails with non-existent book ID", func(t *testing.T) { - args := []string{"99999", "50"} - err := UpdateBookProgress(ctx, args) - if err == nil { - t.Error("Expected error for non-existent book ID") - } - - if !strings.Contains(err.Error(), "failed to get book") { - t.Errorf("Expected book not found error, got: %v", err) - } + if !strings.Contains(err.Error(), "failed to get book") { + t.Errorf("Expected book not found error, got: %v", err) + } + }) }) }) }) @@ -676,7 +579,7 @@ func TestBookHandler(t *testing.T) { t.Errorf("Expected initial status 'queued', got '%s'", book.Status) } - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "25"}) + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 25) if err != nil { t.Errorf("Failed to update progress: %v", err) } @@ -690,7 +593,7 @@ func TestBookHandler(t *testing.T) { t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) } - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "100"}) + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) if err != nil { t.Errorf("Failed to complete book: %v", err) } @@ -731,17 +634,17 @@ func TestBookHandler(t *testing.T) { go func() { time.Sleep(time.Millisecond * 10) - done <- ListBooks(ctx, []string{}) + done <- handler.ListBooks(ctx, "") }() go func() { time.Sleep(time.Millisecond * 15) - done <- UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "50"}) + done <- handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) }() go func() { time.Sleep(time.Millisecond * 20) - done <- UpdateBookStatus(ctx, []string{strconv.FormatInt(book.ID, 10), "finished"}) + done <- handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") }() for i := range 3 {