diff --git a/docs/site/commands/cu_export_tasks.md b/docs/site/commands/cu_export_tasks.md index 00613af..dc8718d 100644 --- a/docs/site/commands/cu_export_tasks.md +++ b/docs/site/commands/cu_export_tasks.md @@ -8,13 +8,16 @@ Export tasks to CSV, JSON, or Markdown format. Examples: # Export all tasks from a list to CSV - cu export tasks --list mylist --format csv --output tasks.csv - + cu export tasks --list mylist --format csv --file tasks.csv + # Export tasks with specific status to JSON cu export tasks --list mylist --status open --format json > open-tasks.json - + + # -o selects the format here, the same as everywhere else in cu + cu export tasks --list mylist -o json + # Generate a Markdown report of high priority tasks - cu export tasks --priority high --format markdown --output report.md + cu export tasks --priority high --format markdown --file report.md ``` cu export tasks [flags] @@ -24,10 +27,10 @@ cu export tasks [flags] ``` --assignee string Filter by assignee + -F, --file string Write to a file instead of stdout -f, --format string Export format (csv, json, markdown) (default "csv") -h, --help help for tasks -l, --list string List ID to export tasks from - -o, --output string Output file (default: stdout) --priority string Filter by priority -s, --space string Space ID to export tasks from --status string Filter by status @@ -38,10 +41,11 @@ cu export tasks [flags] ``` --config string config file (default is $HOME/.config/cu/config.yaml) --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") ``` ### SEE ALSO * [cu export](cu_export.md) - Export data to various formats -###### Auto generated by spf13/cobra on 28-Aug-2026 +###### Auto generated by spf13/cobra on 29-Aug-2026 diff --git a/internal/cmd/export.go b/internal/cmd/export.go index 30eb544..6661a68 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -28,34 +28,32 @@ var exportTasksCmd = &cobra.Command{ Examples: # Export all tasks from a list to CSV - cu export tasks --list mylist --format csv --output tasks.csv - + cu export tasks --list mylist --format csv --file tasks.csv + # Export tasks with specific status to JSON cu export tasks --list mylist --status open --format json > open-tasks.json - + + # -o selects the format here, the same as everywhere else in cu + cu export tasks --list mylist -o json + # Generate a Markdown report of high priority tasks - cu export tasks --priority high --format markdown --output report.md`, + cu export tasks --priority high --format markdown --file report.md`, Run: func(cmd *cobra.Command, args []string) { ctx := context.Background() // Get flags listID, _ := cmd.Flags().GetString("list") spaceID, _ := cmd.Flags().GetString("space") - format, _ := cmd.Flags().GetString("format") - outputFile, _ := cmd.Flags().GetString("output") + outputFile, _ := cmd.Flags().GetString("file") status, _ := cmd.Flags().GetString("status") priority, _ := cmd.Flags().GetString("priority") assignee, _ := cmd.Flags().GetString("assignee") - // Validate format - format = strings.ToLower(format) - if format != "csv" && format != "json" && format != "markdown" && format != "md" { - fmt.Fprintf(os.Stderr, "Invalid format: %s. Must be csv, json, or markdown\n", format) + format, err := resolveExportFormat(cmd) + if err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } - if format == "md" { - format = "markdown" - } // Create API client client, err := api.NewClient() @@ -185,6 +183,71 @@ Examples: }, } +// exportFormats are the formats `export tasks` can produce, keyed by every +// spelling accepted for them. This is a different set from what the global +// --output offers elsewhere in cu: a task export has no "table" or "yaml" +// rendering, and "markdown" exists only here. +var exportFormats = map[string]string{ + "csv": "csv", + "json": "json", + "markdown": "markdown", + "md": "markdown", +} + +// resolveExportFormat picks the export format from --format and the global +// --output, which mean the same thing for this command. +// +// export used to define its own -o/--output as the *destination file*, which +// shadowed the global format flag: `cu export tasks -o json` wrote a file +// literally named "json". Freeing -o fixes that, but simply not reading it +// would replace one silent failure with another — a user who asked for JSON +// would receive the default CSV and no warning. So export honours it, and +// anything it cannot produce is a loud error rather than a quiet fallback. +func resolveExportFormat(cmd *cobra.Command) (string, error) { + formatFlag, _ := cmd.Flags().GetString("format") + outputFlag, _ := cmd.Flags().GetString("output") + formatSet := cmd.Flags().Changed("format") + outputSet := cmd.Flags().Changed("output") + + if formatSet && outputSet { + f, fok := exportFormats[strings.ToLower(formatFlag)] + if !fok { + return "", unsupportedExportFormat("--format", formatFlag) + } + o, ook := exportFormats[strings.ToLower(outputFlag)] + if !ook { + return "", unsupportedExportFormat("--output", outputFlag) + } + // Both name a format, so disagreeing is ambiguous rather than + // resolvable — guessing a winner is how silently-wrong output happens. + if f != o { + return "", fmt.Errorf("conflicting formats: --format %s and --output %s select the same setting; pass only one", formatFlag, outputFlag) + } + return f, nil + } + + flag, value := "--format", formatFlag + if outputSet { + flag, value = "--output", outputFlag + } + resolved, ok := exportFormats[strings.ToLower(value)] + if !ok { + return "", unsupportedExportFormat(flag, value) + } + return resolved, nil +} + +// unsupportedExportFormat explains a rejected format, and recognises the one +// mistake this flag change makes likely: a value that looks like a path is +// almost certainly someone reaching for the old `-o `. +func unsupportedExportFormat(flag, value string) error { + base := fmt.Errorf("%s %q is not an export format (csv, json, markdown)", flag, value) + if strings.ContainsAny(value, `/\.`) { + return fmt.Errorf("%w\nIf you meant a destination file, that is now --file %s", base, value) + } + return base +} + func filterTasksForExport(tasks []clickup.Task, status, priority, assignee string) []clickup.Task { var filtered []clickup.Task @@ -350,7 +413,9 @@ func init() { exportTasksCmd.Flags().StringP("list", "l", "", "List ID to export tasks from") exportTasksCmd.Flags().StringP("space", "s", "", "Space ID to export tasks from") exportTasksCmd.Flags().StringP("format", "f", "csv", "Export format (csv, json, markdown)") - exportTasksCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") + // Deliberately not "output"/-o: that is the global format flag, and + // redefining it here shadowed it, so `-o json` wrote a file named "json". + exportTasksCmd.Flags().StringP("file", "F", "", "Write to a file instead of stdout") exportTasksCmd.Flags().String("status", "", "Filter by status") exportTasksCmd.Flags().String("priority", "", "Filter by priority") exportTasksCmd.Flags().String("assignee", "", "Filter by assignee") diff --git a/internal/cmd/export_test.go b/internal/cmd/export_test.go index 40fc011..098fb66 100644 --- a/internal/cmd/export_test.go +++ b/internal/cmd/export_test.go @@ -1,12 +1,15 @@ package cmd import ( + "io" "os" "strings" "testing" "github.com/raksul/go-clickup/clickup" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExportCmd_Structure(t *testing.T) { @@ -138,9 +141,101 @@ func TestExportCmd_CommandFlags(t *testing.T) { assert.NotNil(t, formatFlag) assert.Equal(t, "csv", formatFlag.DefValue) - outputFlag := cmd.Flags().Lookup("output") - assert.NotNil(t, outputFlag) - assert.Equal(t, "", outputFlag.DefValue) + fileFlag := cmd.LocalFlags().Lookup("file") + assert.NotNil(t, fileFlag, "the destination file lives on --file") + assert.Equal(t, "F", fileFlag.Shorthand) + assert.Equal(t, "", fileFlag.DefValue) + }) + + // The regression guard for #28. Defining a local "output" flag here shadowed + // the global format flag, so `cu export tasks -o json` wrote a file named + // "json" instead of emitting JSON. -o must stay inherited. + t.Run("output is not redefined locally", func(t *testing.T) { + assert.Nil(t, exportTasksCmd.LocalFlags().Lookup("output"), + "export must not shadow the global -o/--output format flag") + }) +} + +func TestResolveExportFormat(t *testing.T) { + // newCmd mirrors the real flag surface: --format is local to export, while + // --output is inherited from the root command. + newCmd := func(t *testing.T, args ...string) *cobra.Command { + t.Helper() + c := &cobra.Command{Use: "tasks", Run: func(*cobra.Command, []string) {}} + c.Flags().StringP("format", "f", "csv", "") + c.Flags().StringP("output", "o", "table", "") + c.SetArgs(args) + c.SetOut(io.Discard) + c.SetErr(io.Discard) + require.NoError(t, c.Execute()) + return c + } + + t.Run("defaults to csv", func(t *testing.T) { + got, err := resolveExportFormat(newCmd(t)) + require.NoError(t, err) + assert.Equal(t, "csv", got) + }) + + t.Run("-o selects the format", func(t *testing.T) { + // The bug in #28: this used to write a file named "json". + got, err := resolveExportFormat(newCmd(t, "-o", "json")) + require.NoError(t, err) + assert.Equal(t, "json", got) + }) + + t.Run("--format still works", func(t *testing.T) { + got, err := resolveExportFormat(newCmd(t, "--format", "markdown")) + require.NoError(t, err) + assert.Equal(t, "markdown", got) + }) + + t.Run("md is an alias for markdown", func(t *testing.T) { + got, err := resolveExportFormat(newCmd(t, "--format", "md")) + require.NoError(t, err) + assert.Equal(t, "markdown", got) + }) + + t.Run("matching is case-insensitive", func(t *testing.T) { + got, err := resolveExportFormat(newCmd(t, "-o", "JSON")) + require.NoError(t, err) + assert.Equal(t, "json", got) + }) + + t.Run("agreeing flags are accepted", func(t *testing.T) { + got, err := resolveExportFormat(newCmd(t, "--format", "json", "-o", "json")) + require.NoError(t, err) + assert.Equal(t, "json", got) + }) + + t.Run("disagreeing flags are an error, not a guess", func(t *testing.T) { + _, err := resolveExportFormat(newCmd(t, "--format", "csv", "-o", "json")) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicting formats") + }) + + t.Run("a format export cannot produce is rejected", func(t *testing.T) { + // "yaml" and "table" are valid globally but meaningless for an export, + // so they must fail loudly rather than fall back to csv. + for _, v := range []string{"yaml", "table"} { + _, err := resolveExportFormat(newCmd(t, "-o", v)) + require.Error(t, err, v) + assert.Contains(t, err.Error(), "is not an export format") + } + }) + + t.Run("a path-shaped value points at --file", func(t *testing.T) { + // The predictable mistake after this change: reaching for the old + // `-o `. Say where the file flag went instead of just rejecting. + _, err := resolveExportFormat(newCmd(t, "-o", "tasks.csv")) + require.Error(t, err) + assert.Contains(t, err.Error(), "--file tasks.csv") + }) + + t.Run("a bad --format is rejected too", func(t *testing.T) { + _, err := resolveExportFormat(newCmd(t, "--format", "xlsx")) + require.Error(t, err) + assert.Contains(t, err.Error(), "--format") }) }