From d69f7d2751ce6552cd7e03a508fcae87fd5aaf4f Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 10 Feb 2026 09:56:10 +0200 Subject: [PATCH] feat: add csv/json format to ls --- README.md | 5 ++ cmd/list.go | 132 +++++++++++++++++++++++++++++++--------- cmd/list_test.go | 153 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 257 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c88a5a5..97c12c7 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,10 @@ cospend list -p myproject --recent 1m # Combine multiple filters cospend list -p myproject -b alice -c restaurant --amount ">=20" + +# Output as CSV or JSON +cospend list -p myproject --format csv +cospend list -p myproject --format json ``` #### List Command Flags @@ -253,6 +257,7 @@ cospend list -p myproject -b alice -c restaurant --amount ">=20" | | `--this-month` | Filter bills from the current month | | | `--this-week` | Filter bills from the current calendar week | | | `--recent` | Filter recent bills (e.g., `7d`, `2w`, `1m`) | +| | `--format` | Output format: `table` (default), `csv`, `json` | | `-h` | `--help` | Display help information | The output includes the bill ID for each expense, which can be used with the delete command. diff --git a/cmd/list.go b/cmd/list.go index 891e98b..2957584 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,6 +1,8 @@ package cmd import ( + "encoding/csv" + "encoding/json" "fmt" "regexp" "sort" @@ -27,6 +29,7 @@ var ( listThisMonth bool listThisWeek bool listRecent string + listFormat string ) // amountFilter holds parsed amount filter criteria @@ -69,6 +72,7 @@ Examples: cmd.Flags().BoolVar(&listThisMonth, "this-month", false, "Filter bills from the current month") cmd.Flags().BoolVar(&listThisWeek, "this-week", false, "Filter bills from the current calendar week") cmd.Flags().StringVar(&listRecent, "recent", "", "Filter recent bills (e.g., 7d, 2w, 1m)") + cmd.Flags().StringVar(&listFormat, "format", "table", "Output format: table, csv, json") return cmd } @@ -78,6 +82,12 @@ func runList(cmd *cobra.Command, _ []string) error { return fmt.Errorf("project is required (use -p or --project)") } + switch listFormat { + case "table", "csv", "json": + default: + return fmt.Errorf("unsupported format: %s (expected table, csv, or json)", listFormat) + } + // Parameters validated, silence usage for subsequent errors cmd.SilenceUsage = true @@ -138,9 +148,18 @@ func runList(cmd *cobra.Command, _ []string) error { // Apply filters filteredBills := applyFilters(bills, filters) - // Print table + // Output results formatter := format.NewAmountFormatter(locale, project.CurrencyName) - printBillsTable(cmd, project, filteredBills, formatter) + resolved := resolveBillNames(project, filteredBills) + + switch listFormat { + case "csv": + printBillsCSV(cmd, resolved) + case "json": + printBillsJSON(cmd, resolved) + default: + printBillsTable(cmd, resolved, formatter) + } return nil } @@ -421,12 +440,19 @@ func parseRecent(s string) (time.Time, error) { } } -func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillResponse, formatter *format.AmountFormatter) { - if len(bills) == 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.") - return - } +// resolvedBill holds a bill with human-readable names resolved from IDs +type resolvedBill struct { + ID int `json:"id"` + Date string `json:"date"` + Name string `json:"name"` + Amount float64 `json:"amount"` + PaidBy string `json:"paid_by"` + PaidFor []string `json:"paid_for"` + Category string `json:"category"` + PaymentMethod string `json:"payment_method"` +} +func resolveBillNames(project *api.Project, bills []api.BillResponse) []resolvedBill { // Sort by date (newest first), then by timestamp for same-date entries sort.Slice(bills, func(i, j int) bool { if bills[i].Date != bills[j].Date { @@ -440,34 +466,27 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR bills = bills[:listLimit] } - // Build lookup maps for names + // Build lookup maps memberNames := make(map[int]string) for _, m := range project.Members { memberNames[m.ID] = m.Name } - categoryNames := make(map[int]string) for _, c := range project.Categories { categoryNames[c.ID] = c.Name } - paymentModeNames := make(map[int]string) for _, pm := range project.PaymentModes { paymentModeNames[pm.ID] = pm.Name } - table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD") - - var totalAmount float64 + var result []resolvedBill for _, bill := range bills { - totalAmount += bill.Amount - // Get payer name payerName := memberNames[bill.PayerID] if payerName == "" { payerName = fmt.Sprintf("#%d", bill.PayerID) } - // Get owed member names var owerNames []string for _, ower := range bill.Owers { name := memberNames[ower.ID] @@ -476,33 +495,60 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR } owerNames = append(owerNames, name) } - owersStr := strings.Join(owerNames, ", ") - // Get category name catName := categoryNames[bill.CategoryID] if catName == "" && bill.CategoryID != 0 { catName = fmt.Sprintf("#%d", bill.CategoryID) } - if catName == "" { - catName = "-" - } - // Get payment method name methodName := paymentModeNames[bill.PaymentModeID] if methodName == "" && bill.PaymentModeID != 0 { methodName = fmt.Sprintf("#%d", bill.PaymentModeID) } - if methodName == "" { - methodName = "-" - } - // Sanitize and truncate name name := strings.Map(func(r rune) rune { if r == '\n' || r == '\r' || r == '\t' { return ' ' } return r }, strings.TrimSpace(bill.What)) + + result = append(result, resolvedBill{ + ID: bill.ID, + Date: bill.Date, + Name: name, + Amount: bill.Amount, + PaidBy: payerName, + PaidFor: owerNames, + Category: catName, + PaymentMethod: methodName, + }) + } + return result +} + +func printBillsTable(cmd *cobra.Command, bills []resolvedBill, formatter *format.AmountFormatter) { + if len(bills) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.") + return + } + + table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD") + + var totalAmount float64 + for _, bill := range bills { + totalAmount += bill.Amount + + catName := bill.Category + if catName == "" { + catName = "-" + } + methodName := bill.PaymentMethod + if methodName == "" { + methodName = "-" + } + + name := bill.Name if len(name) > 30 { name = name[:27] + "..." } @@ -512,8 +558,8 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR bill.Date, name, formatter.Format(bill.Amount), - payerName, - owersStr, + bill.PaidBy, + strings.Join(bill.PaidFor, ", "), catName, methodName, ) @@ -523,3 +569,33 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR table.Render(out) _, _ = fmt.Fprintf(out, "\nTotal: %d bill(s), %s\n", len(bills), formatter.Format(totalAmount)) } + +func printBillsCSV(cmd *cobra.Command, bills []resolvedBill) { + out := cmd.OutOrStdout() + w := csv.NewWriter(out) + + _ = w.Write([]string{"ID", "Date", "Name", "Amount", "Paid By", "Paid For", "Category", "Payment Method"}) + for _, bill := range bills { + _ = w.Write([]string{ + strconv.Itoa(bill.ID), + bill.Date, + bill.Name, + strconv.FormatFloat(bill.Amount, 'f', 2, 64), + bill.PaidBy, + strings.Join(bill.PaidFor, ", "), + bill.Category, + bill.PaymentMethod, + }) + } + w.Flush() +} + +func printBillsJSON(cmd *cobra.Command, bills []resolvedBill) { + out := cmd.OutOrStdout() + if bills == nil { + bills = []resolvedBill{} + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + _ = enc.Encode(bills) +} diff --git a/cmd/list_test.go b/cmd/list_test.go index a08d65e..01c1e63 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -2,7 +2,9 @@ package cmd import ( "bytes" + "encoding/json" "fmt" + "strings" "testing" "time" @@ -162,12 +164,14 @@ func TestPrintBillsTable(t *testing.T) { }, } + resolved := resolveBillNames(project, bills) + cmd := NewListCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) formatter := format.NewAmountFormatter("en_US", "USD") - printBillsTable(cmd, project, bills, formatter) + printBillsTable(cmd, resolved, formatter) output := buf.String() @@ -198,15 +202,12 @@ func TestPrintBillsTable(t *testing.T) { func TestPrintBillsTableEmpty(t *testing.T) { resetListFlags() - project := &api.Project{} - bills := []api.BillResponse{} - cmd := NewListCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) formatter := format.NewAmountFormatter("en_US", "") - printBillsTable(cmd, project, bills, formatter) + printBillsTable(cmd, nil, formatter) output := buf.String() if !bytes.Contains([]byte(output), []byte("No bills found")) { @@ -503,6 +504,147 @@ func TestBuildFiltersRecent(t *testing.T) { } } +func TestPrintBillsCSV(t *testing.T) { + resetListFlags() + + project := &api.Project{ + Members: []api.Member{ + {ID: 1, Name: "Alice", UserID: "alice"}, + {ID: 2, Name: "Bob", UserID: "bob"}, + }, + Categories: []api.Category{ + {ID: 1, Name: "Food"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + }, + } + + bills := []api.BillResponse{ + { + ID: 1, + What: "Groceries", + Amount: 50.00, + Date: "2026-02-03", + PayerID: 1, + Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}}, + CategoryID: 1, + PaymentModeID: 1, + }, + { + ID: 2, + What: "Coffee", + Amount: 5.50, + Date: "2026-02-04", + PayerID: 2, + Owers: []api.Ower{{ID: 2, Weight: 1}}, + }, + } + + resolved := resolveBillNames(project, bills) + + cmd := NewListCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + printBillsCSV(cmd, resolved) + + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + + if len(lines) != 3 { + t.Fatalf("Expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), output) + } + if lines[0] != "ID,Date,Name,Amount,Paid By,Paid For,Category,Payment Method" { + t.Errorf("Wrong CSV header: %s", lines[0]) + } + if !strings.Contains(lines[1], "Coffee") { + t.Errorf("First data row should contain 'Coffee' (newest first), got: %s", lines[1]) + } + if !strings.Contains(lines[2], "Groceries") { + t.Errorf("Second data row should contain 'Groceries', got: %s", lines[2]) + } +} + +func TestPrintBillsJSON(t *testing.T) { + resetListFlags() + + project := &api.Project{ + Members: []api.Member{ + {ID: 1, Name: "Alice", UserID: "alice"}, + }, + Categories: []api.Category{ + {ID: 1, Name: "Food"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + }, + } + + bills := []api.BillResponse{ + { + ID: 1, + What: "Groceries", + Amount: 50.00, + Date: "2026-02-03", + PayerID: 1, + Owers: []api.Ower{{ID: 1, Weight: 1}}, + CategoryID: 1, + PaymentModeID: 1, + }, + } + + resolved := resolveBillNames(project, bills) + + cmd := NewListCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + printBillsJSON(cmd, resolved) + + var result []resolvedBill + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Invalid JSON output: %v\n%s", err, buf.String()) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 bill, got %d", len(result)) + } + if result[0].Name != "Groceries" { + t.Errorf("Wrong name: %s", result[0].Name) + } + if result[0].Amount != 50.00 { + t.Errorf("Wrong amount: %f", result[0].Amount) + } + if result[0].PaidBy != "Alice" { + t.Errorf("Wrong paid_by: %s", result[0].PaidBy) + } + if result[0].Category != "Food" { + t.Errorf("Wrong category: %s", result[0].Category) + } + if result[0].PaymentMethod != "Cash" { + t.Errorf("Wrong payment_method: %s", result[0].PaymentMethod) + } +} + +func TestPrintBillsJSONEmpty(t *testing.T) { + resetListFlags() + + cmd := NewListCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + printBillsJSON(cmd, nil) + + var result []resolvedBill + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Invalid JSON output: %v\n%s", err, buf.String()) + } + if len(result) != 0 { + t.Errorf("Expected empty array, got %d items", len(result)) + } +} + func resetListFlags() { ProjectID = "" listPaidBy = "" @@ -516,4 +658,5 @@ func resetListFlags() { listThisMonth = false listThisWeek = false listRecent = "" + listFormat = "table" }