From a88fdf2b6f064062e4d4cdd4796c4bc5a2bdc0ac Mon Sep 17 00:00:00 2001 From: swarnabhasinha Date: Mon, 2 Feb 2026 16:23:48 +0530 Subject: [PATCH] feat: add CSV output format support --- cmd/root.go | 4 +- internal/formatter/formatter.go | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 8832bf2..aa2cda4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,7 +12,7 @@ var ( debug bool // Global flag for debug mode disableAuth bool // Global flag to disable authentication checks clientID string // Global flag for client ID (used when auth is disabled) - outputFormat string // Global flag for output format (table, json, yaml) + outputFormat string // Global flag for output format (table, json, yaml, csv) ) var rootCmd = &cobra.Command{ @@ -42,7 +42,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&clientID, "client-id", "", "Client ID to use (required when --disable-auth is enabled)") // Add global output format flag - rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format (table, json, yaml)") + rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format (table, json, yaml, csv)") // disable completion option rootCmd.CompletionOptions.DisableDefaultCmd = true diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index 8f6d829..d708852 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -1,8 +1,11 @@ package formatter import ( + "bytes" + "encoding/csv" "encoding/json" "fmt" + "sort" "strings" "gopkg.in/yaml.v2" @@ -15,6 +18,7 @@ const ( FormatTable OutputFormat = "table" FormatJSON OutputFormat = "json" FormatYAML OutputFormat = "yaml" + FormatCSV OutputFormat = "csv" ) // Formatter handles output formatting for different formats @@ -33,6 +37,8 @@ func New(format string) *Formatter { f.format = FormatJSON case "yaml", "yml": f.format = FormatYAML + case "csv": + f.format = FormatCSV case "table", "": f.format = FormatTable } @@ -47,6 +53,8 @@ func (f *Formatter) Format(data interface{}) (string, error) { return f.formatJSON(data) case FormatYAML: return f.formatYAML(data) + case FormatCSV: + return f.formatCSV(data) case FormatTable: // For table format, data should already be formatted as string if str, ok := data.(string); ok { @@ -76,6 +84,130 @@ func (f *Formatter) formatYAML(data interface{}) (string, error) { return string(yamlBytes), nil } +// formatCSV formats data as CSV (flattens structs/maps to one or more rows) +func (f *Formatter) formatCSV(data interface{}) (string, error) { + rows, err := dataToCSVRows(data) + if err != nil { + return "", fmt.Errorf("failed to convert to CSV: %v", err) + } + if len(rows) == 0 { + return "", nil + } + var buf bytes.Buffer + w := csv.NewWriter(&buf) + if err := w.WriteAll(rows); err != nil { + return "", fmt.Errorf("failed to write CSV: %v", err) + } + return strings.TrimSpace(buf.String()), nil +} + +// dataToCSVRows converts interface{} to [][]string (header + data rows) +func dataToCSVRows(data interface{}) ([][]string, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + var raw interface{} + if err := json.Unmarshal(jsonBytes, &raw); err != nil { + return nil, err + } + switch v := raw.(type) { + case []interface{}: + if len(v) == 0 { + return nil, nil + } + allKeys := make(map[string]bool) + var maps []map[string]string + for _, item := range v { + m, err := flattenToMap(item) + if err != nil { + return nil, err + } + for k := range m { + allKeys[k] = true + } + maps = append(maps, m) + } + headers := sortedKeysFromSet(allKeys) + rows := [][]string{headers} + for _, m := range maps { + row := make([]string, len(headers)) + for i, h := range headers { + row[i] = m[h] + } + rows = append(rows, row) + } + return rows, nil + case map[string]interface{}: + m := flattenMap("", v) + headers := sortedKeys(m) + row := make([]string, len(headers)) + for i, h := range headers { + row[i] = m[h] + } + return [][]string{headers, row}, nil + default: + return nil, fmt.Errorf("unsupported type for CSV: %T", data) + } +} + +func flattenToMap(data interface{}) (map[string]string, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + var raw map[string]interface{} + if err := json.Unmarshal(jsonBytes, &raw); err != nil { + return nil, err + } + return flattenMap("", raw), nil +} + +func sortedKeysFromSet(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func flattenMap(prefix string, m map[string]interface{}) map[string]string { + out := make(map[string]string) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + switch val := v.(type) { + case map[string]interface{}: + for k2, v2 := range flattenMap(key, val) { + out[k2] = v2 + } + case []interface{}: + parts := make([]string, len(val)) + for i, el := range val { + parts[i] = fmt.Sprint(el) + } + out[key] = strings.Join(parts, ",") + case nil: + out[key] = "" + default: + out[key] = fmt.Sprint(val) + } + } + return out +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // IsTable returns true if the format is table func (f *Formatter) IsTable() bool { return f.format == FormatTable @@ -90,3 +222,8 @@ func (f *Formatter) IsJSON() bool { func (f *Formatter) IsYAML() bool { return f.format == FormatYAML } + +// IsCSV returns true if the format is CSV +func (f *Formatter) IsCSV() bool { + return f.format == FormatCSV +}