diff --git a/.github/workflows/back-compat-pr.yml b/.github/workflows/back-compat-pr.yml index 5f14b9c..503fdda 100644 --- a/.github/workflows/back-compat-pr.yml +++ b/.github/workflows/back-compat-pr.yml @@ -7,9 +7,6 @@ on: - "**/*.go" - ".github/workflows/back-compat-pr.yml" -permissions: - contents: read - env: GO_VERSION: '1.24.4' @@ -45,5 +42,5 @@ jobs: - name: Run current version run: | /var/tmp/omm_head --db-path=/var/tmp/throwaway.db 'test: a task from PR HEAD' - /var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks - ./.github/scripts/checknumtasks.sh "$(/var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks | wc -l | xargs)" 2 + /var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks list + ./.github/scripts/checknumtasks.sh "$(/var/tmp/omm_head --db-path=/var/tmp/throwaway.db tasks list | wc -l | xargs)" 2 diff --git a/.github/workflows/back-compat.yml b/.github/workflows/back-compat.yml index 751aa19..51e7f36 100644 --- a/.github/workflows/back-compat.yml +++ b/.github/workflows/back-compat.yml @@ -4,9 +4,6 @@ on: push: branches: ["main"] -permissions: - contents: read - env: GO_VERSION: '1.24.4' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ead017c..99b9143 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,15 +9,15 @@ on: - "**/*.go" - ".github/workflows/build.yml" -permissions: - contents: read - env: GO_VERSION: '1.24.4' jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Set up Go @@ -26,5 +26,3 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: go build run: go build -v ./... - - name: go test - run: go test -v ./... diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8440891..b44075f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,9 +9,6 @@ on: - "**/*.go" - ".github/workflows/lint.yml" -permissions: - contents: read - env: GO_VERSION: '1.24.4' diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 5270d55..aaa4cf9 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -9,9 +9,6 @@ on: - "**/*.go" - ".github/workflows/run.yml" -permissions: - contents: read - env: GO_VERSION: '1.24.4' @@ -33,5 +30,5 @@ jobs: run: | cat assets/sample-tasks.txt | ./omm import ./omm 'test: a task' - ./omm tasks - ./.github/scripts/checknumtasks.sh "$(./omm tasks | wc -l | xargs)" 11 + ./omm tasks list + ./.github/scripts/checknumtasks.sh "$(./omm tasks list | wc -l | xargs)" 11 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0ce8859 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +on: + push: + branches: ["main"] + pull_request: + paths: + - "go.*" + - "**/*.go" + - ".github/workflows/test.yml" + +env: + GO_VERSION: '1.24.4' + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - name: go test + env: + INTEGRATION: "1" + run: go test -v ./... diff --git a/.github/workflows/vulncheck.yml b/.github/workflows/vulncheck.yml index 0b69aaa..d93bac7 100644 --- a/.github/workflows/vulncheck.yml +++ b/.github/workflows/vulncheck.yml @@ -8,9 +8,6 @@ on: - "**/*.go" - ".github/workflows/vulncheck.yml" -permissions: - contents: read - env: GO_VERSION: '1.24.4' diff --git a/cmd/tasks.go b/cmd/tasks.go deleted file mode 100644 index 9de3f01..0000000 --- a/cmd/tasks.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmd - -import ( - "database/sql" - "fmt" - "io" - - pers "github.com/dhth/omm/internal/persistence" -) - -func printTasks(db *sql.DB, limit uint8, writer io.Writer) error { - tasks, err := pers.FetchActiveTasks(db, int(limit)) - if err != nil { - return err - } - for _, task := range tasks { - fmt.Fprintf(writer, "%s\n", task.Summary) - } - return nil -} diff --git a/go.mod b/go.mod index ae7aef5..a73ebcd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/dustin/go-humanize v1.0.1 + github.com/google/uuid v1.6.0 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -32,7 +33,6 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/cmd/assets/CHANGELOG.md b/internal/cmd/assets/CHANGELOG.md similarity index 100% rename from cmd/assets/CHANGELOG.md rename to internal/cmd/assets/CHANGELOG.md diff --git a/cmd/assets/guide/actions-adding-context.md b/internal/cmd/assets/guide/actions-adding-context.md similarity index 100% rename from cmd/assets/guide/actions-adding-context.md rename to internal/cmd/assets/guide/actions-adding-context.md diff --git a/cmd/assets/guide/actions-adding-tasks.md b/internal/cmd/assets/guide/actions-adding-tasks.md similarity index 100% rename from cmd/assets/guide/actions-adding-tasks.md rename to internal/cmd/assets/guide/actions-adding-tasks.md diff --git a/cmd/assets/guide/actions-choosing-a-prefix.md b/internal/cmd/assets/guide/actions-choosing-a-prefix.md similarity index 100% rename from cmd/assets/guide/actions-choosing-a-prefix.md rename to internal/cmd/assets/guide/actions-choosing-a-prefix.md diff --git a/cmd/assets/guide/actions-deleting-a-task.md b/internal/cmd/assets/guide/actions-deleting-a-task.md similarity index 100% rename from cmd/assets/guide/actions-deleting-a-task.md rename to internal/cmd/assets/guide/actions-deleting-a-task.md diff --git a/cmd/assets/guide/actions-duplicating-tasks.md b/internal/cmd/assets/guide/actions-duplicating-tasks.md similarity index 100% rename from cmd/assets/guide/actions-duplicating-tasks.md rename to internal/cmd/assets/guide/actions-duplicating-tasks.md diff --git a/cmd/assets/guide/actions-filtering-tasks.md b/internal/cmd/assets/guide/actions-filtering-tasks.md similarity index 100% rename from cmd/assets/guide/actions-filtering-tasks.md rename to internal/cmd/assets/guide/actions-filtering-tasks.md diff --git a/cmd/assets/guide/actions-markdown-in-context.md b/internal/cmd/assets/guide/actions-markdown-in-context.md similarity index 100% rename from cmd/assets/guide/actions-markdown-in-context.md rename to internal/cmd/assets/guide/actions-markdown-in-context.md diff --git a/cmd/assets/guide/actions-quick-filtering-via-a-list.md b/internal/cmd/assets/guide/actions-quick-filtering-via-a-list.md similarity index 100% rename from cmd/assets/guide/actions-quick-filtering-via-a-list.md rename to internal/cmd/assets/guide/actions-quick-filtering-via-a-list.md diff --git a/cmd/assets/guide/actions-updating-task-details.md b/internal/cmd/assets/guide/actions-updating-task-details.md similarity index 100% rename from cmd/assets/guide/actions-updating-task-details.md rename to internal/cmd/assets/guide/actions-updating-task-details.md diff --git a/cmd/assets/guide/cli-adding-a-task-via-the-cli.md b/internal/cmd/assets/guide/cli-adding-a-task-via-the-cli.md similarity index 100% rename from cmd/assets/guide/cli-adding-a-task-via-the-cli.md rename to internal/cmd/assets/guide/cli-adding-a-task-via-the-cli.md diff --git a/cmd/assets/guide/cli-importing-several-tasks-via-the-cli.md b/internal/cmd/assets/guide/cli-importing-several-tasks-via-the-cli.md similarity index 100% rename from cmd/assets/guide/cli-importing-several-tasks-via-the-cli.md rename to internal/cmd/assets/guide/cli-importing-several-tasks-via-the-cli.md diff --git a/cmd/assets/guide/config-a-sample-toml-config.md b/internal/cmd/assets/guide/config-a-sample-toml-config.md similarity index 100% rename from cmd/assets/guide/config-a-sample-toml-config.md rename to internal/cmd/assets/guide/config-a-sample-toml-config.md diff --git a/cmd/assets/guide/config-changing-the-defaults.md b/internal/cmd/assets/guide/config-changing-the-defaults.md similarity index 100% rename from cmd/assets/guide/config-changing-the-defaults.md rename to internal/cmd/assets/guide/config-changing-the-defaults.md diff --git a/cmd/assets/guide/config-flags-env-vars-and-config-file.md b/internal/cmd/assets/guide/config-flags-env-vars-and-config-file.md similarity index 100% rename from cmd/assets/guide/config-flags-env-vars-and-config-file.md rename to internal/cmd/assets/guide/config-flags-env-vars-and-config-file.md diff --git a/cmd/assets/guide/domain-an-archived-task.md b/internal/cmd/assets/guide/domain-an-archived-task.md similarity index 100% rename from cmd/assets/guide/domain-an-archived-task.md rename to internal/cmd/assets/guide/domain-an-archived-task.md diff --git a/cmd/assets/guide/domain-task-bookmarks.md b/internal/cmd/assets/guide/domain-task-bookmarks.md similarity index 100% rename from cmd/assets/guide/domain-task-bookmarks.md rename to internal/cmd/assets/guide/domain-task-bookmarks.md diff --git a/cmd/assets/guide/domain-task-details.md b/internal/cmd/assets/guide/domain-task-details.md similarity index 100% rename from cmd/assets/guide/domain-task-details.md rename to internal/cmd/assets/guide/domain-task-details.md diff --git a/cmd/assets/guide/domain-task-priorities.md b/internal/cmd/assets/guide/domain-task-priorities.md similarity index 100% rename from cmd/assets/guide/domain-task-priorities.md rename to internal/cmd/assets/guide/domain-task-priorities.md diff --git a/cmd/assets/guide/domain-task-state.md b/internal/cmd/assets/guide/domain-task-state.md similarity index 100% rename from cmd/assets/guide/domain-task-state.md rename to internal/cmd/assets/guide/domain-task-state.md diff --git a/cmd/assets/guide/domain-tasks.md b/internal/cmd/assets/guide/domain-tasks.md similarity index 100% rename from cmd/assets/guide/domain-tasks.md rename to internal/cmd/assets/guide/domain-tasks.md diff --git a/cmd/assets/guide/guide-and-thats-it.md b/internal/cmd/assets/guide/guide-and-thats-it.md similarity index 100% rename from cmd/assets/guide/guide-and-thats-it.md rename to internal/cmd/assets/guide/guide-and-thats-it.md diff --git a/cmd/assets/guide/guide-welcome-to-omm.md b/internal/cmd/assets/guide/guide-welcome-to-omm.md similarity index 100% rename from cmd/assets/guide/guide-welcome-to-omm.md rename to internal/cmd/assets/guide/guide-welcome-to-omm.md diff --git a/cmd/assets/guide/visuals-list-density.md b/internal/cmd/assets/guide/visuals-list-density.md similarity index 100% rename from cmd/assets/guide/visuals-list-density.md rename to internal/cmd/assets/guide/visuals-list-density.md diff --git a/cmd/assets/guide/visuals-toggling-context-pane.md b/internal/cmd/assets/guide/visuals-toggling-context-pane.md similarity index 100% rename from cmd/assets/guide/visuals-toggling-context-pane.md rename to internal/cmd/assets/guide/visuals-toggling-context-pane.md diff --git a/cmd/db.go b/internal/cmd/db.go similarity index 100% rename from cmd/db.go rename to internal/cmd/db.go diff --git a/cmd/guide.go b/internal/cmd/guide.go similarity index 100% rename from cmd/guide.go rename to internal/cmd/guide.go diff --git a/cmd/guide_test.go b/internal/cmd/guide_test.go similarity index 100% rename from cmd/guide_test.go rename to internal/cmd/guide_test.go diff --git a/cmd/import.go b/internal/cmd/import.go similarity index 100% rename from cmd/import.go rename to internal/cmd/import.go diff --git a/cmd/root.go b/internal/cmd/root.go similarity index 74% rename from cmd/root.go rename to internal/cmd/root.go index fb304e2..1f29e5f 100644 --- a/cmd/root.go +++ b/internal/cmd/root.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -32,23 +33,26 @@ const ( defaultDataDirWindows = "AppData/Local" configFileName = "omm/omm.toml" dbFileName = "omm/omm.db" - printTasksDefault = 20 + numListTasksDefault = 20 taskListTitleMaxLen = 8 ) var ( - errCouldntGetHomeDir = errors.New("couldn't get home directory") - errConfigFileExtIncorrect = errors.New("config file must be a TOML file") - errConfigFileDoesntExist = errors.New("config file does not exist") - errDBFileExtIncorrect = errors.New("db file needs to end with .db") - errMaxImportLimitExceeded = errors.New("import limit exceeded") - errNothingToImport = errors.New("nothing to import") - errListDensityIncorrect = errors.New("list density is incorrect; valid values: compact/spacious") - errCouldntCreateDBDirectory = errors.New("couldn't create directory for database") - errCouldntCreateDB = errors.New("couldn't create database") - errCouldntInitializeDB = errors.New("couldn't initialize database") - errCouldntOpenDB = errors.New("couldn't open database") - errCouldntSetupGuide = errors.New("couldn't set up guided walkthrough") + errCouldntGetHomeDir = errors.New("couldn't get home directory") + errConfigFileExtIncorrect = errors.New("config file must be a TOML file") + errConfigFileDoesntExist = errors.New("config file does not exist") + errDBFileExtIncorrect = errors.New("db file needs to end with .db") + errMaxImportLimitExceeded = errors.New("import limit exceeded") + errNothingToImport = errors.New("nothing to import") + errListDensityIncorrect = errors.New("list density is incorrect; valid values: compact/spacious") + errCouldntCreateDBDirectory = errors.New("couldn't create directory for database") + errCouldntCreateDB = errors.New("couldn't create database") + errCouldntInitializeDB = errors.New("couldn't initialize database") + errCouldntOpenDB = errors.New("couldn't open database") + errCouldntSetupGuide = errors.New("couldn't set up guided walkthrough") + errInvalidTaskIndexProvided = errors.New("invalid task index provided") + errInvalidTaskOutputFmtProvided = errors.New("invalid output format provided") + errInvalidTaskStatusFilterProvided = errors.New("invalid status filter provided") //go:embed assets/CHANGELOG.md updateContents string @@ -134,7 +138,6 @@ func NewRootCommand() (*cobra.Command, error) { db *sql.DB taskListColor string archivedTaskListColor string - printTasksNum uint8 taskListTitle string listDensityFlagInp string editorFlagInp string @@ -142,6 +145,10 @@ func NewRootCommand() (*cobra.Command, error) { showContextFlagInp bool confirmBeforeDeletion bool circularNav bool + taskOutputFmtStr string + tasksLimit uint16 + tasksOffset uint16 + statusFilterStr string ) rootCmd := &cobra.Command{ @@ -355,9 +362,62 @@ Sorry for breaking the upgrade step! tasksCmd := &cobra.Command{ Use: "tasks", - Short: "Output tasks tracked by omm to stdout", + Short: "Interact with tasks tracked by omm", + } + + listTasksCmd := &cobra.Command{ + Use: "list", + Short: "List tasks", RunE: func(_ *cobra.Command, _ []string) error { - return printTasks(db, printTasksNum, os.Stdout) + format, ok := parseTaskOutputFormat(taskOutputFmtStr) + if !ok { + return fmt.Errorf("%w; allowed values: %v", errInvalidTaskOutputFmtProvided, taskOutputFormats()) + } + + statusFilter, ok := types.ParseTaskStatusFilter(statusFilterStr) + if !ok { + return fmt.Errorf("%w; allowed values: %v", errInvalidTaskStatusFilterProvided, types.TaskStatusFilterValues()) + } + + return listTasks(db, statusFilter, tasksLimit, tasksOffset, format, os.Stdout) + }, + } + + searchTasksCmd := &cobra.Command{ + Use: "search ", + Short: "Search tasks based on a query", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + format, ok := parseTaskOutputFormat(taskOutputFmtStr) + if !ok { + return fmt.Errorf("%w; allowed values: %v", errInvalidTaskOutputFmtProvided, taskOutputFormats()) + } + + statusFilter, ok := types.ParseTaskStatusFilter(statusFilterStr) + if !ok { + return fmt.Errorf("%w; allowed values: %v", errInvalidTaskStatusFilterProvided, types.TaskStatusFilterValues()) + } + + return searchTasks(db, args[0], statusFilter, tasksLimit, tasksOffset, format, os.Stdout) + }, + } + + showTaskCmd := &cobra.Command{ + Use: "show ", + Short: "Show details for the task at a specific position in omm's list", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + index, err := strconv.ParseUint(args[0], 10, 0) + if err != nil || index == 0 { + return fmt.Errorf("%w; value needs to be an integer >= 1", errInvalidTaskIndexProvided) + } + + format, ok := parseTaskOutputFormat(taskOutputFmtStr) + if !ok { + return fmt.Errorf("%w; allowed values: %v", errInvalidTaskOutputFmtProvided, taskOutputFormats()) + } + + return showTask(db, index, format, os.Stdout) }, } @@ -447,15 +507,30 @@ Sorry for breaking the upgrade step! rootCmd.Flags().BoolVar(&confirmBeforeDeletion, "confirm-before-deletion", true, "whether to ask for confirmation before deleting a task") rootCmd.Flags().BoolVar(&circularNav, "circular-nav", false, "whether to enable circular navigation for lists (cycle back to the first entry from the last, and vice versa)") - tasksCmd.Flags().Uint8VarP(&printTasksNum, "num", "n", printTasksDefault, "number of tasks to print") - tasksCmd.Flags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) - tasksCmd.Flags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) + tasksCmd.PersistentFlags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) + tasksCmd.PersistentFlags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) + + listTasksCmd.Flags().StringVarP(&taskOutputFmtStr, "format", "f", "plain", fmt.Sprintf("output format to use, possible values: %v", taskOutputFormats())) + listTasksCmd.Flags().Uint16VarP(&tasksLimit, "limit", "l", numListTasksDefault, "number of tasks to list") + listTasksCmd.Flags().Uint16VarP(&tasksOffset, "offset", "o", 0, "offset to use") + listTasksCmd.Flags().StringVarP(&statusFilterStr, "status", "s", "active", fmt.Sprintf("status to use as a filter; possible values: %v", types.TaskStatusFilterValues())) + + showTaskCmd.Flags().StringVarP(&taskOutputFmtStr, "format", "f", "plain", fmt.Sprintf("output format to use, possible values: %v", taskOutputFormats())) + + searchTasksCmd.Flags().StringVarP(&taskOutputFmtStr, "format", "f", "plain", fmt.Sprintf("output format to use, possible values: %v", taskOutputFormats())) + searchTasksCmd.Flags().Uint16VarP(&tasksLimit, "limit", "l", numListTasksDefault, "number of tasks to list") + searchTasksCmd.Flags().Uint16VarP(&tasksOffset, "offset", "o", 0, "offset to use") + searchTasksCmd.Flags().StringVarP(&statusFilterStr, "status", "s", "active", fmt.Sprintf("status to use as a filter; possible values: %v", types.TaskStatusFilterValues())) importCmd.Flags().StringVarP(&configPath, "config-path", "c", defaultConfigPath, fmt.Sprintf("location of omm's TOML config file%s", configPathAdditionalCxt)) importCmd.Flags().StringVarP(&dbPath, "db-path", "d", defaultDBPath, fmt.Sprintf("location of omm's database file%s", dbPathAdditionalCxt)) guideCmd.Flags().StringVar(&editorFlagInp, "editor", "vi", "editor command to run when adding/editing context to a task") + tasksCmd.AddCommand(listTasksCmd) + tasksCmd.AddCommand(searchTasksCmd) + tasksCmd.AddCommand(showTaskCmd) + rootCmd.AddCommand(importCmd) rootCmd.AddCommand(tasksCmd) rootCmd.AddCommand(guideCmd) diff --git a/internal/cmd/tasks.go b/internal/cmd/tasks.go new file mode 100644 index 0000000..1f37664 --- /dev/null +++ b/internal/cmd/tasks.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + pers "github.com/dhth/omm/internal/persistence" + "github.com/dhth/omm/internal/types" +) + +var ( + errNoTaskAtIndex = errors.New("no task exists at given index") + errCouldntMarshalTaskToJSON = errors.New("couldn't marshall task to JSON") + errCouldntSearchTasks = errors.New("couldn't search tasks") +) + +func listTasks(db *sql.DB, + statusFilter types.TaskStatusFilter, + limit, + offset uint16, + format taskOutputFormat, + writer io.Writer, +) error { + tasks, err := pers.FetchTasks(db, statusFilter, limit, offset) + if err != nil { + return err + } + + if len(tasks) == 0 { + return nil + } + + switch format { + case taskOutputPlain: + summaries := make([]string, len(tasks)) + for i, task := range tasks { + summaries[i] = task.Summary + } + fmt.Fprintln(writer, strings.Join(summaries, "\n")) + case taskOutputJSON: + data, err := json.MarshalIndent(tasks, "", " ") + if err != nil { + return fmt.Errorf("%w: %s", errCouldntMarshalTaskToJSON, err.Error()) + } + fmt.Fprintf(writer, "%s\n", data) + } + + return nil +} + +func showTask(db *sql.DB, index uint64, format taskOutputFormat, writer io.Writer) error { + task, found, err := pers.FetchNthActiveTask(db, index-1) + if err != nil { + return err + } + + if !found { + return errNoTaskAtIndex + } + + taskDetails := task.GetDetails() + + switch format { + case taskOutputPlain: + fmt.Fprintln(writer, taskDetails.Summary) + if taskDetails.Context != nil { + fmt.Fprintf(writer, "\n%s", *taskDetails.Context) + } + case taskOutputJSON: + data, err := json.MarshalIndent(taskDetails, "", " ") + if err != nil { + return fmt.Errorf("%w: %s", errCouldntMarshalTaskToJSON, err.Error()) + } + fmt.Fprintf(writer, "%s\n", data) + } + + return nil +} + +func searchTasks(db *sql.DB, + query string, + statusFilter types.TaskStatusFilter, + limit, + offset uint16, + format taskOutputFormat, + writer io.Writer, +) error { + tasks, err := pers.FetchTasksThatMatchQuery(db, query, statusFilter, limit, offset) + if err != nil { + return fmt.Errorf("%w: %s", errCouldntSearchTasks, err.Error()) + } + + if len(tasks) == 0 { + return nil + } + + switch format { + case taskOutputPlain: + summaries := make([]string, len(tasks)) + for i, task := range tasks { + summaries[i] = task.Summary + } + fmt.Fprintln(writer, strings.Join(summaries, "\n")) + case taskOutputJSON: + data, err := json.MarshalIndent(tasks, "", " ") + if err != nil { + return fmt.Errorf("%w: %s", errCouldntMarshalTaskToJSON, err.Error()) + } + fmt.Fprintf(writer, "%s\n", data) + } + + return nil +} diff --git a/internal/cmd/types.go b/internal/cmd/types.go new file mode 100644 index 0000000..2354c7c --- /dev/null +++ b/internal/cmd/types.go @@ -0,0 +1,23 @@ +package cmd + +type taskOutputFormat uint8 + +const ( + taskOutputPlain taskOutputFormat = iota + taskOutputJSON +) + +func taskOutputFormats() []string { + return []string{"plain", "json"} +} + +func parseTaskOutputFormat(value string) (taskOutputFormat, bool) { + switch value { + case "plain": + return taskOutputPlain, true + case "json": + return taskOutputJSON, true + default: + return taskOutputPlain, false + } +} diff --git a/cmd/utils.go b/internal/cmd/utils.go similarity index 100% rename from cmd/utils.go rename to internal/cmd/utils.go diff --git a/internal/persistence/queries.go b/internal/persistence/queries.go index d2e21bc..b140f42 100644 --- a/internal/persistence/queries.go +++ b/internal/persistence/queries.go @@ -3,6 +3,8 @@ package persistence import ( "database/sql" "encoding/json" + "errors" + "fmt" "time" "github.com/dhth/omm/internal/types" @@ -13,6 +15,9 @@ const ( ContextMaxBytes = 1024 * 1024 ) +// TODO: wrap all unexpected sql errors with this +var errCouldntExecuteQuery = errors.New("couldn't execute query") + func fetchTaskSequence(db *sql.DB) ([]uint64, error) { var seq []byte seqRow := db.QueryRow("SELECT sequence from task_sequence where id=1;") @@ -133,7 +138,7 @@ func InsertTasks(db *sql.DB, tasks []types.Task, insertAtTop bool) (int64, error query := `INSERT INTO task (summary, context, active, created_at, updated_at) VALUES ` - values := make([]interface{}, 0, len(tasks)*4) + values := make([]any, 0, len(tasks)*4) for i, t := range tasks { if i > 0 { @@ -288,17 +293,25 @@ WHERE id = ? return nil } -func FetchActiveTasks(db *sql.DB, limit int) ([]types.Task, error) { +func FetchActiveTasks(db *sql.DB, limit, offset uint16) ([]types.Task, error) { var tasks []types.Task rows, err := db.Query(` -SELECT t.id, t.summary, t.context, t.created_at, t.updated_at -FROM task_sequence s -JOIN json_each(s.sequence) j ON CAST(j.value AS INTEGER) = t.id -JOIN task t ON t.id = j.value -ORDER BY j.key -LIMIT ?; -`, limit) +SELECT + t.id, + t.summary, + t.context, + t.created_at, + t.updated_at +FROM + task_sequence s + JOIN json_each(s.sequence) j ON CAST(j.value AS INTEGER) = t.id + JOIN task t ON t.id = j.value +ORDER BY + j.key +LIMIT + ? OFFSET ?; +`, limit, offset) if err != nil { return nil, err } @@ -329,7 +342,173 @@ LIMIT ?; return tasks, nil } -func FetchInActiveTasks(db *sql.DB, limit int) ([]types.Task, error) { +func FetchTasks(db *sql.DB, statusFilter types.TaskStatusFilter, limit, offset uint16) ([]types.Task, error) { + var tasks []types.Task + + var statusFilterStr string + switch statusFilter { + case types.TaskStatusActive: + statusFilterStr = "WHERE t.active IS true" + case types.TaskStatusInactive: + statusFilterStr = "WHERE t.active IS false" + } + + rows, err := db.Query(fmt.Sprintf(` +SELECT + t.id, + t.summary, + t.context, + t.active, + t.created_at, + t.updated_at +FROM + task t + %s +ORDER BY + t.updated_at DESC +LIMIT + ? OFFSET ?; +`, statusFilterStr), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var entry types.Task + err = rows.Scan(&entry.ID, + &entry.Summary, + &entry.Context, + &entry.Active, + &entry.CreatedAt, + &entry.UpdatedAt, + ) + if err != nil { + return nil, err + } + entry.CreatedAt = entry.CreatedAt.Local() + entry.UpdatedAt = entry.UpdatedAt.Local() + tasks = append(tasks, entry) + + } + err = rows.Err() + if err != nil { + return nil, err + } + + return tasks, nil +} + +func FetchTasksThatMatchQuery(db *sql.DB, query string, statusFilter types.TaskStatusFilter, limit, offset uint16) ([]types.Task, error) { + var tasks []types.Task + + var statusFilterStr string + switch statusFilter { + case types.TaskStatusActive: + statusFilterStr = "AND t.active IS true" + case types.TaskStatusInactive: + statusFilterStr = "AND t.active IS false" + } + + searchTerm := fmt.Sprintf("%%%s%%", query) + rows, err := db.Query(fmt.Sprintf(` +SELECT + t.id, + t.summary, + t.context, + t.active, + t.created_at, + t.updated_at +FROM + task t +WHERE + ( + t.summary LIKE ? + OR t.context LIKE ? + ) + %s +ORDER BY + t.updated_at DESC +LIMIT + ? OFFSET ?; +`, statusFilterStr), searchTerm, searchTerm, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var entry types.Task + err = rows.Scan(&entry.ID, + &entry.Summary, + &entry.Context, + &entry.Active, + &entry.CreatedAt, + &entry.UpdatedAt, + ) + if err != nil { + return nil, err + } + entry.CreatedAt = entry.CreatedAt.Local() + entry.UpdatedAt = entry.UpdatedAt.Local() + tasks = append(tasks, entry) + + } + err = rows.Err() + if err != nil { + return nil, err + } + + return tasks, nil +} + +func FetchNthActiveTask(db *sql.DB, index uint64) (types.Task, bool, error) { + var zero types.Task + + // QueryRow always returns a non-nil value + row := db.QueryRow(` +SELECT + id, + summary, + active, + context, + created_at, + updated_at +FROM + task +WHERE + id = ( + SELECT + json_extract(sequence, '$[' || ? || ']') + FROM + task_sequence + WHERE + id = 1 + ); +`, index) + + if row.Err() != nil { + return zero, false, fmt.Errorf("%w, %s", errCouldntExecuteQuery, row.Err().Error()) + } + + var task types.Task + err := row.Scan(&task.ID, + &task.Summary, + &task.Active, + &task.Context, + &task.CreatedAt, + &task.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return zero, false, nil + } else if err != nil { + return zero, true, err + } + + return task, true, nil +} + +func FetchInActiveTasks(db *sql.DB, limit uint16) ([]types.Task, error) { var tasks []types.Task rows, err := db.Query(` diff --git a/internal/types/types.go b/internal/types/types.go index 6666895..afa34c7 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -39,17 +39,17 @@ var ( ) type TaskDetails struct { - Summary string - Context *string + Summary string `json:"summary"` + Context *string `json:"context"` } type Task struct { - ID uint64 - Summary string - Context *string - Active bool - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 `json:"-"` + Summary string `json:"summary"` + Context *string `json:"context"` + Active bool `json:"active"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` } func (t Task) GetDetails() TaskDetails { @@ -189,3 +189,28 @@ func (p TaskPrefix) Description() string { func (p TaskPrefix) FilterValue() string { return string(p) } + +type TaskStatusFilter uint8 + +const ( + TaskStatusActive TaskStatusFilter = iota + TaskStatusInactive + TaskStatusAny +) + +func TaskStatusFilterValues() []string { + return []string{"active", "inactive", "any"} +} + +func ParseTaskStatusFilter(value string) (TaskStatusFilter, bool) { + switch value { + case "active": + return TaskStatusActive, true + case "inactive": + return TaskStatusInactive, true + case "any": + return TaskStatusAny, true + default: + return TaskStatusAny, false + } +} diff --git a/internal/ui/cmds.go b/internal/ui/cmds.go index 958e2df..ef8b032 100644 --- a/internal/ui/cmds.go +++ b/internal/ui/cmds.go @@ -76,13 +76,13 @@ func changeTaskStatus(db *sql.DB, listIndex int, id uint64, active bool, updated } } -func fetchTasks(db *sql.DB, active bool, limit int) tea.Cmd { +func fetchTasks(db *sql.DB, active bool, limit uint16) tea.Cmd { return func() tea.Msg { var tasks []types.Task var err error switch active { case true: - tasks, err = pers.FetchActiveTasks(db, limit) + tasks, err = pers.FetchActiveTasks(db, limit, 0) case false: tasks, err = pers.FetchInActiveTasks(db, limit) } diff --git a/main.go b/main.go index 05f29c2..0073c8f 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ import ( "os" "runtime/debug" - "github.com/dhth/omm/cmd" + "github.com/dhth/omm/internal/cmd" ) var version = "dev" diff --git a/tests/cli/common.go b/tests/cli/common.go new file mode 100644 index 0000000..f7c30a1 --- /dev/null +++ b/tests/cli/common.go @@ -0,0 +1,41 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +var ( + errCouldntCreateTempTestDir = errors.New("couldn't create temporary test directory") + errCouldntBuildBinary = errors.New("couldn't build binary") +) + +func skipIntegration(t *testing.T) { + t.Helper() + if os.Getenv("INTEGRATION") != "1" { + t.Skip("Skipping integration tests") + } +} + +func setUpTestBinary() (string, string, error) { + var zero string + tempDir, err := os.MkdirTemp("", "") + if err != nil { + return zero, zero, fmt.Errorf("%w: %s", errCouldntCreateTempTestDir, err.Error()) + } + + binPath := filepath.Join(tempDir, "omm") + buildArgs := []string{"build", "-o", binPath, "../.."} + + c := exec.Command("go", buildArgs...) + err = c.Run() + if err != nil { + return zero, zero, fmt.Errorf("%w: %s", errCouldntBuildBinary, err.Error()) + } + + return tempDir, binPath, nil +} diff --git a/tests/cli/root_cmd_test.go b/tests/cli/root_cmd_test.go new file mode 100644 index 0000000..05c19c8 --- /dev/null +++ b/tests/cli/root_cmd_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootCmd(t *testing.T) { + skipIntegration(t) + + tempDir, binPath, err := setUpTestBinary() + if err != nil { + require.NoErrorf(t, err, "error setting up test binary: %w", err) + } + + defer func() { + err := os.RemoveAll(tempDir) + if err != nil { + fmt.Printf("couldn't clean up temporary directory (%s): %s", binPath, err) + } + }() + + // SUCCESSES + t.Run("Help", func(t *testing.T) { + // GIVEN + // WHEN + c := exec.Command(binPath, "-h") + err := c.Run() + + // THEN + assert.NoError(t, err) + }) +} diff --git a/tests/cli/tasks_cmd_test.go b/tests/cli/tasks_cmd_test.go new file mode 100644 index 0000000..6f3e674 --- /dev/null +++ b/tests/cli/tasks_cmd_test.go @@ -0,0 +1,168 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTasksCmd(t *testing.T) { + skipIntegration(t) + + tempDir, binPath, err := setUpTestBinary() + if err != nil { + require.NoErrorf(t, err, "error setting up test binary: %w", err) + } + + defer func() { + err := os.RemoveAll(tempDir) + if err != nil { + t.Fatalf("couldn't clean up temporary directory (%s): %s", binPath, err) + } + }() + + //-------------// + // SUCCESSES // + //-------------// + + t.Run("Help", func(t *testing.T) { + // GIVEN + // WHEN + c := exec.Command(binPath, "tasks", "-h") + err := c.Run() + + // THEN + assert.NoError(t, err) + }) + + t.Run("Listing tasks works", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + numTasks := 10 + for i := range numTasks { + c := exec.Command(binPath, "-d", dbPath, fmt.Sprintf("prefix: task %d", 9-i)) + err := c.Run() + require.NoError(t, err) + } + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "list") + output, err := c.CombinedOutput() + + // THEN + require.NoError(t, err) + + numLines := len(strings.Split(strings.TrimSpace(string(output)), "\n")) + assert.Equal(t, numTasks, numLines) + }) + + t.Run("Showing task details for a valid index works", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + numTasks := 10 + for i := range numTasks { + c := exec.Command(binPath, "-d", dbPath, fmt.Sprintf("prefix: task %d", 10-i)) + err := c.Run() + require.NoError(t, err) + } + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "show", "2", "-f", "plain") + output, err := c.CombinedOutput() + + // THEN + require.NoError(t, err) + + assert.Equal(t, "prefix: task 2", strings.TrimSpace(string(output))) + }) + + t.Run("Showing task details in json format works", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + numTasks := 10 + for i := range numTasks { + c := exec.Command(binPath, "-d", dbPath, fmt.Sprintf("prefix: task %d", 10-i)) + err := c.Run() + require.NoError(t, err) + } + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "show", "2", "-f", "json") + output, err := c.CombinedOutput() + + // THEN + require.NoError(t, err) + + expected := ` +{ + "summary": "prefix: task 2", + "context": null +} +` + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(output))) + }) + + //------------// + // FAILURES // + //------------// + + t.Run("Showing task details for index 0 fails", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "show", "0") + output, err := c.CombinedOutput() + + // THEN + assert.Error(t, err) + + assert.Contains(t, string(output), "invalid task index provided;") + }) + + t.Run("Showing task details for an invalid index fails", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + numTasks := 10 + for i := range numTasks { + c := exec.Command(binPath, "-d", dbPath, fmt.Sprintf("prefix: task %d", 10-i)) + err := c.Run() + require.NoError(t, err) + } + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "show", "11") + output, err := c.CombinedOutput() + + // THEN + assert.Error(t, err) + + assert.Contains(t, string(output), "no task exists at given index") + }) + + t.Run("Showing task details with an invalid output format fails", func(t *testing.T) { + // GIVEN + dbPath := filepath.Join(tempDir, fmt.Sprintf("omm-%s.db", uuid.New().String())) + + // WHEN + c := exec.Command(binPath, "-d", dbPath, "tasks", "show", "1", "-f", "unknown") + output, err := c.CombinedOutput() + + // THEN + assert.Error(t, err) + + assert.Contains(t, string(output), "invalid output format provided;") + }) +}