From df828c6e85822f2b099c884f7d4e68f06bbf4b20 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 3 Feb 2026 11:03:58 +0200 Subject: [PATCH] feat: initial commit --- .github/FUNDING.yml | 13 + .github/workflows/manual-homebrew-release.yml | 12 + .github/workflows/release.yml | 20 + .github/workflows/test.yml | 44 ++ .gitignore | 5 + LICENSE | 21 + Makefile | 58 +++ README.md | 282 +++++++++++ cmd/add.go | 161 ++++++ cmd/add_test.go | 475 ++++++++++++++++++ cmd/common.go | 7 + cmd/delete.go | 65 +++ cmd/delete_test.go | 157 ++++++ cmd/init.go | 149 ++++++ cmd/list.go | 348 +++++++++++++ cmd/list_test.go | 286 +++++++++++ cmd/projects.go | 77 +++ cmd/table.go | 94 ++++ go.mod | 17 + go.sum | 29 ++ internal/api/client.go | 439 ++++++++++++++++ internal/api/client_test.go | 436 ++++++++++++++++ internal/cache/cache.go | 293 +++++++++++ internal/cache/cache_test.go | 292 +++++++++++ internal/config/config.go | 184 +++++++ internal/config/config_test.go | 423 ++++++++++++++++ main.go | 38 ++ version.txt | 1 + 28 files changed, 4426 insertions(+) create mode 100755 .github/FUNDING.yml create mode 100755 .github/workflows/manual-homebrew-release.yml create mode 100755 .github/workflows/release.yml create mode 100755 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100755 Makefile create mode 100644 README.md create mode 100644 cmd/add.go create mode 100644 cmd/add_test.go create mode 100644 cmd/common.go create mode 100644 cmd/delete.go create mode 100644 cmd/delete_test.go create mode 100644 cmd/init.go create mode 100644 cmd/list.go create mode 100644 cmd/list_test.go create mode 100644 cmd/projects.go create mode 100644 cmd/table.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/client.go create mode 100644 internal/api/client_test.go create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 main.go create mode 100644 version.txt diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100755 index 0000000..6be5fe8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: chenasraf +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: casraf +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: + - "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22¤cy_code=ILS&source=url" diff --git a/.github/workflows/manual-homebrew-release.yml b/.github/workflows/manual-homebrew-release.yml new file mode 100755 index 0000000..073449b --- /dev/null +++ b/.github/workflows/manual-homebrew-release.yml @@ -0,0 +1,12 @@ +name: Manual Homebrew Release + +on: + workflow_dispatch: + +jobs: + homebrew: + uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master + with: + homebrew-tap-repo: chenasraf/homebrew-tap + secrets: + REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100755 index 0000000..4778eef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +permissions: + contents: write + pull-requests: write + +jobs: + release: + uses: chenasraf/workflows/.github/workflows/go-release.yml@master + with: + name: cospend-cli + homebrew-tap-repo: chenasraf/homebrew-tap + secrets: + REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100755 index 0000000..dabb2dd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Test + +on: + push: + branches: + - develop + pull_request: + branches: + - master + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - name: Build + run: go build -v + + - name: Test + run: go test -v ./... + + - name: Create dist/ dir + run: mkdir dist + + - name: Generate build files + uses: chenasraf/go-cross-build@v1 + with: + platforms: 'linux/amd64, darwin/amd64, windows/amd64' # , darwin/arm64' # ' + package: '' + name: 'cospend-cli' + compress: 'true' + dest: 'dist' + - name: Upload builds + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e487c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.env.keys +.envrc +cospend-cli +cospend diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee25c1f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2026 Chen Asraf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..b6f8df4 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +BIN := $(subst -cli,,$(notdir $(CURDIR))) + +all: + @if [ ! -f ".git/hooks/pre-commit" ]; then \ + $(MAKE) precommit-install; \ + fi + $(MAKE) build + $(MAKE) run + +.PHONY: build +build: + go build -o $(BIN) + +.PHONY: run +run: build + ./$(BIN) + +.PHONY: test +test: + go test -v ./... + +.PHONY: install +install: build + cp $(BIN) ~/.local/bin/ + +.PHONY: uninstall +uninstall: + rm -f ~/.local/bin/$(BIN) + +.PHONY: lint +lint: + golangci-lint run ./... + +.PHONY: precommit-install +precommit-install: + @echo "Installing pre-commit hooks..." + @echo "#!/bin/sh\n\nmake precommit" > .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "Pre-commit hooks installed." + +.PHONY: precommit +precommit: + @STAGED_FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$$'); \ + if [ -z "$$STAGED_FILES" ]; then \ + echo "No staged Go files to check."; \ + else \ + set -e; \ + echo "Running pre-commit checks..."; \ + echo "go fmt"; \ + go fmt ./...; \ + git add $$STAGED_FILES; \ + echo "go vet"; \ + go vet ./...; \ + echo "golangci-lint"; \ + golangci-lint run ./...; \ + echo "go test"; \ + go test -v ./...; \ + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b1613f --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# cospend-cli + +**`cospend-cli`** is a command-line interface for managing expenses in +[Nextcloud Cospend](https://apps.nextcloud.com/apps/cospend) projects. It provides a quick way to +add and list expenses directly from your terminal without opening the web interface. + +![Release](https://img.shields.io/github/v/release/chenasraf/cospend-cli) +![Downloads](https://img.shields.io/github/downloads/chenasraf/cospend-cli/total) +![License](https://img.shields.io/github/license/chenasraf/cospend-cli) + +--- + +## Features + +- **Add** and **list** expenses in Cospend projects via the **REST API** +- **Filter** expenses by payer, owed members, amount, name, category, or payment method +- Resolve categories, payment methods, and members by **name or ID** +- **Case-insensitive** matching for all lookups +- **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution +- **Local caching** of project data with 1-hour TTL for faster subsequent calls +- Cross-platform support: **macOS**, **Linux**, and **Windows** + +--- + +## Installation + +### Download Precompiled Binaries + +Precompiled binaries for `cospend-cli` are available for **Linux**, **macOS**, and **Windows**: + +- Visit the [Releases Page](https://github.com/chenasraf/cospend-cli/releases/latest) to download + the latest version for your platform. + +### Homebrew (macOS/Linux) + +```bash +brew install chenasraf/tap/cospend-cli +``` + +### Build from Source + +```bash +go install github.com/chenasraf/cospend-cli@latest +``` + +--- + +## Configuration + +### Quick Setup (Recommended) + +Run the interactive setup wizard: + +```bash +cospend init +``` + +This will prompt for your Nextcloud credentials and save them to a config file. + +You can specify the config format with `--format`: + +```bash +cospend init --format yaml +cospend init --format toml +cospend init --format json # default +``` + +### Config File + +The config file is searched in the following locations (in order of preference): + +| OS | Primary Location | Fallback Location | +| ------- | ------------------------------------------------- | -------------------------------------------- | +| Linux | `~/.config/cospend/cospend.{json,yaml,toml}` | - | +| macOS | `~/Library/Application Support/cospend/cospend.*` | `~/.config/cospend/cospend.{json,yaml,toml}` | +| Windows | `%APPDATA%\cospend\cospend.*` | - | + +Example config files: + +```json +{ + "domain": "https://cloud.example.com", + "user": "alice", + "password": "your-app-password" +} +``` + +```yaml +domain: https://cloud.example.com +user: alice +password: your-app-password +``` + +```toml +domain = "https://cloud.example.com" +user = "alice" +password = "your-app-password" +``` + +### Environment Variables + +You can also use environment variables, which override config file values: + +| Variable | Description | +| -------------------- | ------------------------------------ | +| `NEXTCLOUD_DOMAIN` | Your Nextcloud instance URL | +| `NEXTCLOUD_USER` | Your Nextcloud username | +| `NEXTCLOUD_PASSWORD` | Your Nextcloud password or app token | + +```bash +export NEXTCLOUD_DOMAIN="https://cloud.example.com" +export NEXTCLOUD_USER="alice" +export NEXTCLOUD_PASSWORD="your-app-password" +``` + +> **Tip:** For security, consider using a Nextcloud +> [app password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices) +> instead of your main password. + +--- + +## Usage + +### Adding Expenses + +```bash +cospend add [flags] +``` + +#### Examples + +```bash +# Add a simple expense +cospend add "Groceries" 25.50 -p myproject + +# Add an expense with category and split between members +cospend add "Dinner" 45.00 -p myproject -c restaurant -f alice -f bob + +# Add an expense paid by someone else +cospend add "Gas" 60.00 -p roadtrip -b charlie -f alice -f bob -f charlie + +# Add an expense with payment method and comment +cospend add "Hotel" 150.00 -p vacation -m "credit card" -o "2 nights" + +# Add an expense in a different currency +cospend add "Souvenirs" 30.00 -p vacation -C usd +``` + +#### Add Command Flags + +| Short | Long | Description | +| ----- | ------------ | --------------------------------------------------------- | +| `-p` | `--project` | Project ID (required) | +| `-c` | `--category` | Category by ID or case-insensitive name | +| `-b` | `--by` | Paying member username (defaults to authenticated user) | +| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) | +| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) | +| `-m` | `--method` | Payment method by ID or case-insensitive name | +| `-o` | `--comment` | Additional details about the bill | +| `-h` | `--help` | Display help information | + +--- + +### Listing Expenses + +```bash +cospend list [flags] +cospend ls [flags] # alias +``` + +#### Examples + +```bash +# List all expenses in a project +cospend list -p myproject + +# Filter by paying member +cospend list -p myproject -b alice + +# Filter by category +cospend list -p myproject -c groceries + +# Filter by amount (supports =, >, <, >=, <=) +cospend list -p myproject --amount ">50" +cospend list -p myproject --amount "<=100" + +# Filter by name (case-insensitive, contains) +cospend list -p myproject -n dinner + +# Combine multiple filters +cospend list -p myproject -b alice -c restaurant --amount ">=20" +``` + +#### List Command Flags + +| Short | Long | Description | +| ----- | ------------ | --------------------------------------------------- | +| `-p` | `--project` | Project ID (required) | +| `-b` | `--by` | Filter by paying member username | +| `-f` | `--for` | Filter by owed member username (repeatable) | +| `-a` | `--amount` | Filter by amount (e.g., `50`, `>30`, `<=100`, `=25`) | +| `-n` | `--name` | Filter by name (case-insensitive, contains) | +| `-c` | `--category` | Filter by category name or ID | +| `-m` | `--method` | Filter by payment method name or ID | +| `-h` | `--help` | Display help information | + +The output includes the bill ID for each expense, which can be used with the delete command. + +--- + +### Deleting Expenses + +```bash +cospend delete [flags] +cospend rm [flags] # alias +``` + +#### Examples + +```bash +# Delete a bill by ID (use 'cospend list' to find bill IDs) +cospend delete 123 -p myproject +``` + +#### Delete Command Flags + +| Short | Long | Description | +| ----- | ----------- | --------------------- | +| `-p` | `--project` | Project ID (required) | +| `-h` | `--help` | Display help information | + +--- + +## Caching + +Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated +API calls. The cache is stored in: + +| OS | Location | +| ------- | ------------------------------- | +| Linux | `~/.cache/cospend-cli/` | +| macOS | `~/Library/Caches/cospend-cli/` | +| Windows | `%LOCALAPPDATA%\cospend-cli\` | + +Cache entries expire after **1 hour**. To force a refresh, simply delete the cache file for your +project. + +--- + +## Currency Codes + +When using the `-C` flag, you can specify currencies by: + +1. **Numeric ID** - The Cospend currency ID +2. **Name** - The currency name as configured in Cospend (case-insensitive) +3. **Currency code** - Standard codes like `usd`, `eur`, `gbp`, `jpy`, etc. + +Currency codes are automatically mapped to their symbols (e.g., `usd` -> `$`, `eur` -> `€`) and +matched against your project's configured currencies. + +--- + +## Contributing + +I am developing this package on my free time, so any support, whether code, issues, or just stars is +very helpful to sustaining its life. If you are feeling incredibly generous and would like to donate +just a small amount to help sustain this project, I would be very very thankful! + + + Buy Me a Coffee at ko-fi.com + + +I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature, +don't hesitate to open an appropriate issue and I will do my best to reply promptly. + +--- + +## License + +`cospend-cli` is licensed under the [MIT License](/LICENSE). diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..563c851 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "fmt" + "strconv" + "time" + + "github.com/chenasraf/cospend-cli/internal/api" + "github.com/chenasraf/cospend-cli/internal/cache" + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +var ( + category string + paidBy string + paidFor []string + convertTo string + paymentMethod string + comment string +) + +// NewAddCommand creates the add command +func NewAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add an expense to a Cospend project", + Long: `Add an expense to a Cospend project. + +Examples: + cospend add "Groceries" 25.50 -p myproject + cospend add "Dinner" 45.00 -p myproject -c restaurant -b alice -f bob -f charlie`, + Args: cobra.ExactArgs(2), + RunE: runAdd, + } + + cmd.Flags().StringVarP(&category, "category", "c", "", "Category by ID or name") + cmd.Flags().StringVarP(&paidBy, "by", "b", "", "Paying member username (defaults to authenticated user)") + cmd.Flags().StringArrayVarP(&paidFor, "for", "f", nil, "Owed member username (repeatable; defaults to payer only)") + cmd.Flags().StringVarP(&convertTo, "convert", "C", "", "Currency to convert to") + cmd.Flags().StringVarP(&paymentMethod, "method", "m", "", "Payment method by ID or name") + cmd.Flags().StringVarP(&comment, "comment", "o", "", "Additional details about the bill") + + return cmd +} + +func runAdd(cmd *cobra.Command, args []string) error { + if ProjectID == "" { + return fmt.Errorf("project is required (use -p or --project)") + } + + expenseName := args[0] + amountStr := args[1] + + // Parse amount + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + return fmt.Errorf("invalid amount: %s", amountStr) + } + + // Parameters validated, silence usage for subsequent errors + cmd.SilenceUsage = true + + // Load configuration + cfg, err := config.Load() + if err != nil { + return err + } + + // Get API client + client := api.NewClient(cfg) + client.Debug = Debug + client.DebugWriter = cmd.ErrOrStderr() + + // Get project (from cache or API) + project, ok := cache.Load(ProjectID) + if !ok { + project, err = client.GetProject(ProjectID) + if err != nil { + return fmt.Errorf("fetching project: %w", err) + } + if err := cache.Save(ProjectID, project); err != nil { + // Non-fatal: log warning but continue + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err) + } + } + + // Resolve payer + payerUsername := paidBy + if payerUsername == "" { + payerUsername = cfg.User + } + payerID, err := cache.ResolveMember(project, payerUsername) + if err != nil { + return fmt.Errorf("resolving payer: %w", err) + } + + // Resolve owed members + var owedIDs []int + if len(paidFor) == 0 { + // Default to payer only + owedIDs = []int{payerID} + } else { + for _, username := range paidFor { + memberID, err := cache.ResolveMember(project, username) + if err != nil { + return fmt.Errorf("resolving owed member: %w", err) + } + owedIDs = append(owedIDs, memberID) + } + } + + // Build bill + bill := api.Bill{ + What: expenseName, + Amount: amount, + PayerID: payerID, + OwedTo: owedIDs, + Date: time.Now().Format("2006-01-02"), + } + + // Resolve optional category + if category != "" { + categoryID, err := cache.ResolveCategory(project, category) + if err != nil { + return fmt.Errorf("resolving category: %w", err) + } + bill.CategoryID = categoryID + } + + // Resolve optional payment method + if paymentMethod != "" { + methodID, err := cache.ResolvePaymentMode(project, paymentMethod) + if err != nil { + return fmt.Errorf("resolving payment method: %w", err) + } + bill.PaymentModeID = methodID + } + + // Resolve optional currency + if convertTo != "" { + currencyID, err := cache.ResolveCurrency(project, convertTo) + if err != nil { + return fmt.Errorf("resolving currency: %w", err) + } + bill.OriginalCurrencyID = currencyID + } + + // Add optional comment + if comment != "" { + bill.Comment = comment + } + + // Create the bill + if err := client.CreateBill(ProjectID, bill); err != nil { + return fmt.Errorf("creating bill: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully added expense: %s (%.2f)\n", expenseName, amount) + return nil +} diff --git a/cmd/add_test.go b/cmd/add_test.go new file mode 100644 index 0000000..888e499 --- /dev/null +++ b/cmd/add_test.go @@ -0,0 +1,475 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chenasraf/cospend-cli/internal/api" +) + +// OCSResponse for test responses +type ocsResponse struct { + OCS struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + } `json:"ocs"` +} + +func makeOCSResponse(statusCode int, data any) ocsResponse { + dataBytes, _ := json.Marshal(data) + resp := ocsResponse{} + resp.OCS.Meta.Status = "ok" + resp.OCS.Meta.StatusCode = statusCode + resp.OCS.Meta.Message = "OK" + resp.OCS.Data = dataBytes + return resp +} + +func resetFlags() { + // Reset global flag variables between tests + ProjectID = "" + category = "" + paidBy = "" + paidFor = nil + convertTo = "" + paymentMethod = "" + comment = "" +} + +func setupTestEnv(t *testing.T, domain string) func() { + t.Helper() + + // Reset flags + resetFlags() + + // Set test env vars (t.Setenv auto-restores after test) + t.Setenv("NEXTCLOUD_DOMAIN", domain) + t.Setenv("NEXTCLOUD_USER", "testuser") + t.Setenv("NEXTCLOUD_PASSWORD", "testpass") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + return func() { + resetFlags() + } +} + +func TestNewAddCommand(t *testing.T) { + resetFlags() + defer resetFlags() + + cmd := NewAddCommand() + + if cmd.Use != "add " { + t.Errorf("Wrong Use: %s", cmd.Use) + } + + // Check flags exist (project is now a persistent flag on root) + flags := []string{"category", "by", "for", "convert", "method", "comment"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("Missing flag: %s", flag) + } + } + + // Check short flags (project is now on root) + shortFlags := map[string]string{ + "c": "category", + "b": "by", + "f": "for", + "C": "convert", + "m": "method", + "o": "comment", + } + for short, long := range shortFlags { + flag := cmd.Flags().ShorthandLookup(short) + if flag == nil { + t.Errorf("Missing short flag: -%s", short) + } else if flag.Name != long { + t.Errorf("Short flag -%s maps to %s, want %s", short, flag.Name, long) + } + } +} + +func TestAddCommandMissingProject(t *testing.T) { + resetFlags() + defer resetFlags() + + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test expense", "10.00"}) + + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for missing project flag") + } +} + +func TestAddCommandInvalidAmount(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test expense", "not-a-number"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for invalid amount") + } +} + +func TestAddCommandSuccess(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + {ID: 2, Name: "Alice", UserID: "alice"}, + }, + Categories: []api.Category{ + {ID: 1, Name: "Food"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + }, + } + + var receivedBill map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + return + } + + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" { + _ = r.ParseForm() + receivedBill = make(map[string]string) + for k, v := range r.Form { + if len(v) > 0 { + receivedBill[k] = v[0] + } + } + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1})) + return + } + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"Groceries", "25.50"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify bill data + if receivedBill["what"] != "Groceries" { + t.Errorf("Wrong what: %s", receivedBill["what"]) + } + if receivedBill["amount"] != "25.50" { + t.Errorf("Wrong amount: %s", receivedBill["amount"]) + } + if receivedBill["payer"] != "1" { + t.Errorf("Wrong payer: %s", receivedBill["payer"]) + } + // Default owed to payer + if receivedBill["payedFor"] != "1" { + t.Errorf("Wrong payedFor: %s", receivedBill["payedFor"]) + } + + // Check output + if !bytes.Contains(stdout.Bytes(), []byte("Successfully added expense")) { + t.Errorf("Missing success message in output: %s", stdout.String()) + } +} + +func TestAddCommandWithAllFlags(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + {ID: 2, Name: "Alice", UserID: "alice"}, + {ID: 3, Name: "Bob", UserID: "bob"}, + }, + Categories: []api.Category{ + {ID: 5, Name: "Restaurant"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 3, Name: "Credit Card"}, + }, + Currencies: []api.Currency{ + {ID: 2, Name: "€"}, + }, + } + + var receivedBill map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + return + } + + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" { + _ = r.ParseForm() + receivedBill = make(map[string]string) + for k, v := range r.Form { + if len(v) > 0 { + receivedBill[k] = v[0] + } + } + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1})) + return + } + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{ + "Dinner", + "45.00", + "-c", "restaurant", + "-b", "alice", + "-f", "alice", + "-f", "bob", + "-m", "credit card", + "-o", "Team dinner", + "-C", "eur", + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify bill data + if receivedBill["what"] != "Dinner" { + t.Errorf("Wrong what: %s", receivedBill["what"]) + } + if receivedBill["amount"] != "45.00" { + t.Errorf("Wrong amount: %s", receivedBill["amount"]) + } + if receivedBill["payer"] != "2" { // Alice's ID + t.Errorf("Wrong payer: %s", receivedBill["payer"]) + } + if receivedBill["payedFor"] != "2,3" { // Alice and Bob + t.Errorf("Wrong payedFor: %s", receivedBill["payedFor"]) + } + if receivedBill["categoryid"] != "5" { + t.Errorf("Wrong categoryid: %s", receivedBill["categoryid"]) + } + if receivedBill["paymentmodeid"] != "3" { + t.Errorf("Wrong paymentmodeid: %s", receivedBill["paymentmodeid"]) + } + if receivedBill["comment"] != "Team dinner" { + t.Errorf("Wrong comment: %s", receivedBill["comment"]) + } + if receivedBill["original_currency_id"] != "2" { + t.Errorf("Wrong original_currency_id: %s", receivedBill["original_currency_id"]) + } +} + +func TestAddCommandMemberNotFound(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00", "-b", "nonexistent"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for nonexistent member") + } +} + +func TestAddCommandCategoryNotFound(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + Categories: []api.Category{ + {ID: 1, Name: "Food"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00", "-c", "nonexistent"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for nonexistent category") + } +} + +func TestAddCommandPaymentModeNotFound(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00", "-m", "bitcoin"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for nonexistent payment mode") + } +} + +func TestAddCommandCurrencyNotFound(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + Currencies: []api.Currency{ + {ID: 1, Name: "$"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00", "-C", "btc"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for nonexistent currency") + } +} + +func TestAddCommandMissingEnvVars(t *testing.T) { + resetFlags() + defer resetFlags() + + // Clear all env vars using t.Setenv (restores automatically) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for missing env vars") + } +} + +func TestAddCommandAPIError(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "testuser", UserID: "testuser"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + return + } + + // Return error for bill creation + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewAddCommand() + cmd.SetArgs([]string{"Test", "10.00"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error from API") + } +} diff --git a/cmd/common.go b/cmd/common.go new file mode 100644 index 0000000..fba6698 --- /dev/null +++ b/cmd/common.go @@ -0,0 +1,7 @@ +package cmd + +// Debug enables debug output when true +var Debug bool + +// ProjectID is the project to operate on (shared across commands) +var ProjectID string diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..7cd11a1 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/chenasraf/cospend-cli/internal/api" + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +// NewDeleteCommand creates the delete command +func NewDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete an expense from a Cospend project", + Long: `Delete an expense from a Cospend project by its bill ID. + +Use 'cospend list' to find the bill ID you want to delete. + +Examples: + cospend delete 123 -p myproject`, + Args: cobra.ExactArgs(1), + RunE: runDelete, + } + + return cmd +} + +func runDelete(cmd *cobra.Command, args []string) error { + if ProjectID == "" { + return fmt.Errorf("project is required (use -p or --project)") + } + + billIDStr := args[0] + + // Parse bill ID + billID, err := strconv.Atoi(billIDStr) + if err != nil { + return fmt.Errorf("invalid bill ID: %s", billIDStr) + } + + // Parameters validated, silence usage for subsequent errors + cmd.SilenceUsage = true + + // Load configuration + cfg, err := config.Load() + if err != nil { + return err + } + + // Get API client + client := api.NewClient(cfg) + client.Debug = Debug + client.DebugWriter = cmd.ErrOrStderr() + + // Delete the bill + if err := client.DeleteBill(ProjectID, billID); err != nil { + return fmt.Errorf("deleting bill: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted bill #%d\n", billID) + return nil +} diff --git a/cmd/delete_test.go b/cmd/delete_test.go new file mode 100644 index 0000000..4f5c995 --- /dev/null +++ b/cmd/delete_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewDeleteCommand(t *testing.T) { + cmd := NewDeleteCommand() + + if cmd.Use != "delete " { + t.Errorf("Use = %v, want %v", cmd.Use, "delete ") + } +} + +func TestDeleteCommandMissingProject(t *testing.T) { + resetDeleteFlags() + + cmd := NewDeleteCommand() + cmd.SetArgs([]string{"123"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for missing project flag") + } +} + +func TestDeleteCommandMissingBillID(t *testing.T) { + resetDeleteFlags() + + ProjectID = "myproject" + cmd := NewDeleteCommand() + cmd.SetArgs([]string{}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for missing bill ID argument") + } +} + +func TestDeleteCommandInvalidBillID(t *testing.T) { + resetDeleteFlags() + + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("API should not be called with invalid bill ID") + })) + defer server.Close() + + t.Setenv("NEXTCLOUD_DOMAIN", server.URL) + t.Setenv("NEXTCLOUD_USER", "testuser") + t.Setenv("NEXTCLOUD_PASSWORD", "testpass") + + ProjectID = "myproject" + cmd := NewDeleteCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"not-a-number"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for invalid bill ID") + } +} + +func TestDeleteCommandSuccess(t *testing.T) { + resetDeleteFlags() + + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("Expected DELETE method, got %s", r.Method) + } + if r.URL.Path != "/ocs/v2.php/apps/cospend/api/v1/projects/myproject/bills/123" { + t.Errorf("Unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "ocs": map[string]interface{}{ + "meta": map[string]interface{}{ + "status": "ok", + "statuscode": 200, + "message": "OK", + }, + "data": "OK", + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + t.Setenv("NEXTCLOUD_DOMAIN", server.URL) + t.Setenv("NEXTCLOUD_USER", "testuser") + t.Setenv("NEXTCLOUD_PASSWORD", "testpass") + + ProjectID = "myproject" + cmd := NewDeleteCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"123"}) + + err := cmd.Execute() + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("Successfully deleted bill #123")) { + t.Errorf("Expected success message in output, got: %s", output) + } +} + +func TestDeleteCommandAPIError(t *testing.T) { + resetDeleteFlags() + + // Create mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "ocs": map[string]interface{}{ + "meta": map[string]interface{}{ + "status": "failure", + "statuscode": 404, + "message": "Bill not found", + }, + "data": nil, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + t.Setenv("NEXTCLOUD_DOMAIN", server.URL) + t.Setenv("NEXTCLOUD_USER", "testuser") + t.Setenv("NEXTCLOUD_PASSWORD", "testpass") + + ProjectID = "myproject" + cmd := NewDeleteCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"999"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for API failure") + } +} + +func resetDeleteFlags() { + ProjectID = "" +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..0c6e4cb --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var configFormat string + +// NewInitCommand creates the init command +func NewInitCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize configuration file", + Long: `Initialize a configuration file with your Nextcloud credentials. + +This command will interactively prompt for your Nextcloud domain, username, +and password, then save them to a config file. + +Config file location: + Linux: ~/.config/cospend/cospend.{ext} + macOS: ~/Library/Application Support/cospend/cospend.{ext} + Windows: %APPDATA%\cospend\cospend.{ext}`, + RunE: runInit, + } + + cmd.Flags().StringVarP(&configFormat, "format", "f", "json", "Config file format (json, yaml, toml)") + + return cmd +} + +func runInit(cmd *cobra.Command, _ []string) error { + // Validate format + switch configFormat { + case "json", "yaml", "yml", "toml": + // valid + default: + return fmt.Errorf("unsupported format: %s (use json, yaml, or toml)", configFormat) + } + + // Parameters validated, silence usage for subsequent errors + cmd.SilenceUsage = true + + // Check if config already exists + if existingPath := config.GetConfigPath(); existingPath != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file already exists: %s\n", existingPath) + overwrite, err := promptYesNo(cmd, "Overwrite?") + if err != nil { + return err + } + if !overwrite { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + // Remove existing config + if err := os.Remove(existingPath); err != nil { + return fmt.Errorf("removing existing config: %w", err) + } + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Setting up Cospend CLI configuration...") + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + + // Prompt for domain + domain, err := promptString(cmd, "Nextcloud domain (e.g., https://cloud.example.com)") + if err != nil { + return err + } + domain = strings.TrimRight(domain, "/") + + // Prompt for username + user, err := promptString(cmd, "Username") + if err != nil { + return err + } + + // Prompt for password (hidden input) + password, err := promptPassword(cmd, "Password (or app token)") + if err != nil { + return err + } + + cfg := &config.Config{ + Domain: domain, + User: user, + Password: password, + } + + path, err := config.Save(cfg, configFormat) + if err != nil { + return fmt.Errorf("saving config: %w", err) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Configuration saved to: %s\n", path) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "You can now use cospend commands without setting environment variables.") + + return nil +} + +func promptString(cmd *cobra.Command, prompt string) (string, error) { + reader := bufio.NewReader(cmd.InOrStdin()) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: ", prompt) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(input), nil +} + +func promptPassword(cmd *cobra.Command, prompt string) (string, error) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: ", prompt) + + // Try to read password with hidden input + if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + password, err := term.ReadPassword(int(f.Fd())) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) // Print newline after hidden input + if err != nil { + return "", err + } + return string(password), nil + } + + // Fallback to regular input (for non-terminal/testing) + reader := bufio.NewReader(cmd.InOrStdin()) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(input), nil +} + +func promptYesNo(cmd *cobra.Command, prompt string) (bool, error) { + reader := bufio.NewReader(cmd.InOrStdin()) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s [y/N]: ", prompt) + input, err := reader.ReadString('\n') + if err != nil { + return false, err + } + input = strings.TrimSpace(strings.ToLower(input)) + return input == "y" || input == "yes", nil +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..b3622d7 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/chenasraf/cospend-cli/internal/api" + "github.com/chenasraf/cospend-cli/internal/cache" + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +var ( + listPaidBy string + listPaidFor []string + listAmount string + listName string + listPaymentMethod string + listCategory string +) + +// amountFilter holds parsed amount filter criteria +type amountFilter struct { + operator string + value float64 +} + +// NewListCommand creates the list command +func NewListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List expenses in a Cospend project", + Long: `List expenses in a Cospend project with optional filters. + +Examples: + cospend list -p myproject + cospend list -p myproject -b alice + cospend list -p myproject -c groceries + cospend list -p myproject --amount ">50" + cospend list -p myproject --amount "<=100" -n dinner`, + RunE: runList, + } + + cmd.Flags().StringVarP(&listPaidBy, "by", "b", "", "Filter by paying member username") + cmd.Flags().StringArrayVarP(&listPaidFor, "for", "f", nil, "Filter by owed member username (repeatable)") + cmd.Flags().StringVarP(&listAmount, "amount", "a", "", "Filter by amount (e.g., 50, >30, <=100, =25)") + cmd.Flags().StringVarP(&listName, "name", "n", "", "Filter by name (case-insensitive, contains)") + cmd.Flags().StringVarP(&listPaymentMethod, "method", "m", "", "Filter by payment method") + cmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") + + return cmd +} + +func runList(cmd *cobra.Command, _ []string) error { + if ProjectID == "" { + return fmt.Errorf("project is required (use -p or --project)") + } + + // Parameters validated, silence usage for subsequent errors + cmd.SilenceUsage = true + + // Load configuration + cfg, err := config.Load() + if err != nil { + return err + } + + // Get API client + client := api.NewClient(cfg) + client.Debug = Debug + client.DebugWriter = cmd.ErrOrStderr() + + // Get project (from cache or API) + project, ok := cache.Load(ProjectID) + if !ok { + project, err = client.GetProject(ProjectID) + if err != nil { + return fmt.Errorf("fetching project: %w", err) + } + if err := cache.Save(ProjectID, project); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err) + } + } + + // Fetch bills + bills, err := client.GetBills(ProjectID) + if err != nil { + return fmt.Errorf("fetching bills: %w", err) + } + + // Build filters + filters, err := buildFilters(project) + if err != nil { + return err + } + + // Apply filters + filteredBills := applyFilters(bills, filters) + + // Print table + printBillsTable(cmd, project, filteredBills) + + return nil +} + +// billFilter is a function that returns true if a bill should be included +type billFilter func(bill api.BillResponse) bool + +func buildFilters(project *api.Project) ([]billFilter, error) { + var filters []billFilter + + // Filter by payer + if listPaidBy != "" { + payerID, err := cache.ResolveMember(project, listPaidBy) + if err != nil { + return nil, fmt.Errorf("resolving payer filter: %w", err) + } + filters = append(filters, func(bill api.BillResponse) bool { + return bill.PayerID == payerID + }) + } + + // Filter by owed members + if len(listPaidFor) > 0 { + var owedIDs []int + for _, username := range listPaidFor { + memberID, err := cache.ResolveMember(project, username) + if err != nil { + return nil, fmt.Errorf("resolving owed member filter: %w", err) + } + owedIDs = append(owedIDs, memberID) + } + filters = append(filters, func(bill api.BillResponse) bool { + for _, requiredID := range owedIDs { + found := false + for _, ower := range bill.Owers { + if ower.ID == requiredID { + found = true + break + } + } + if !found { + return false + } + } + return true + }) + } + + // Filter by amount + if listAmount != "" { + af, err := parseAmountFilter(listAmount) + if err != nil { + return nil, fmt.Errorf("parsing amount filter: %w", err) + } + filters = append(filters, func(bill api.BillResponse) bool { + return matchAmount(bill.Amount, af) + }) + } + + // Filter by name (case-insensitive contains) + if listName != "" { + lowerName := strings.ToLower(listName) + filters = append(filters, func(bill api.BillResponse) bool { + return strings.Contains(strings.ToLower(bill.What), lowerName) + }) + } + + // Filter by payment method + if listPaymentMethod != "" { + methodID, err := cache.ResolvePaymentMode(project, listPaymentMethod) + if err != nil { + return nil, fmt.Errorf("resolving payment method filter: %w", err) + } + filters = append(filters, func(bill api.BillResponse) bool { + return bill.PaymentModeID == methodID + }) + } + + // Filter by category + if listCategory != "" { + categoryID, err := cache.ResolveCategory(project, listCategory) + if err != nil { + return nil, fmt.Errorf("resolving category filter: %w", err) + } + filters = append(filters, func(bill api.BillResponse) bool { + return bill.CategoryID == categoryID + }) + } + + return filters, nil +} + +func applyFilters(bills []api.BillResponse, filters []billFilter) []api.BillResponse { + if len(filters) == 0 { + return bills + } + + var result []api.BillResponse + for _, bill := range bills { + include := true + for _, filter := range filters { + if !filter(bill) { + include = false + break + } + } + if include { + result = append(result, bill) + } + } + return result +} + +func parseAmountFilter(s string) (amountFilter, error) { + s = strings.TrimSpace(s) + + // Match operators: >=, <=, >, <, =, or just a number + re := regexp.MustCompile(`^(>=|<=|>|<|=)?(.+)$`) + matches := re.FindStringSubmatch(s) + if matches == nil { + return amountFilter{}, fmt.Errorf("invalid amount filter format: %s", s) + } + + operator := matches[1] + if operator == "" { + operator = "=" + } + + value, err := strconv.ParseFloat(strings.TrimSpace(matches[2]), 64) + if err != nil { + return amountFilter{}, fmt.Errorf("invalid amount value: %s", matches[2]) + } + + return amountFilter{operator: operator, value: value}, nil +} + +func matchAmount(amount float64, af amountFilter) bool { + switch af.operator { + case "=": + return amount == af.value + case ">": + return amount > af.value + case "<": + return amount < af.value + case ">=": + return amount >= af.value + case "<=": + return amount <= af.value + default: + return false + } +} + +func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillResponse) { + if len(bills) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.") + return + } + + // 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 { + return bills[i].Date > bills[j].Date + } + return bills[i].Timestamp > bills[j].Timestamp + }) + + // Build lookup maps for names + 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") + + for _, bill := range bills { + // 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] + if name == "" { + name = fmt.Sprintf("#%d", ower.ID) + } + 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 = "-" + } + + // Truncate name if too long + name := bill.What + if len(name) > 30 { + name = name[:27] + "..." + } + + table.AddRow( + fmt.Sprintf("%d", bill.ID), + bill.Date, + name, + fmt.Sprintf("%.2f", bill.Amount), + payerName, + owersStr, + catName, + methodName, + ) + } + + out := cmd.OutOrStdout() + table.Render(out) + _, _ = fmt.Fprintf(out, "\nTotal: %d bill(s)\n", len(bills)) +} diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..906bf42 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/chenasraf/cospend-cli/internal/api" +) + +func TestParseAmountFilter(t *testing.T) { + tests := []struct { + name string + input string + wantOp string + wantVal float64 + wantErr bool + }{ + {"plain number", "50", "=", 50, false}, + {"equals", "=25", "=", 25, false}, + {"greater than", ">30", ">", 30, false}, + {"less than", "<100", "<", 100, false}, + {"greater or equal", ">=50", ">=", 50, false}, + {"less or equal", "<=75.5", "<=", 75.5, false}, + {"with spaces", " >= 100 ", ">=", 100, false}, + {"decimal", "25.99", "=", 25.99, false}, + {"invalid number", ">abc", "", 0, true}, + {"empty string", "", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + af, err := parseAmountFilter(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseAmountFilter() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if af.operator != tt.wantOp { + t.Errorf("parseAmountFilter() operator = %v, want %v", af.operator, tt.wantOp) + } + if af.value != tt.wantVal { + t.Errorf("parseAmountFilter() value = %v, want %v", af.value, tt.wantVal) + } + } + }) + } +} + +func TestMatchAmount(t *testing.T) { + tests := []struct { + name string + amount float64 + filter amountFilter + want bool + }{ + {"equals match", 50, amountFilter{"=", 50}, true}, + {"equals no match", 50, amountFilter{"=", 51}, false}, + {"greater match", 60, amountFilter{">", 50}, true}, + {"greater no match", 50, amountFilter{">", 50}, false}, + {"greater edge", 50, amountFilter{">", 49.99}, true}, + {"less match", 40, amountFilter{"<", 50}, true}, + {"less no match", 50, amountFilter{"<", 50}, false}, + {"greater equal match exact", 50, amountFilter{">=", 50}, true}, + {"greater equal match above", 51, amountFilter{">=", 50}, true}, + {"greater equal no match", 49, amountFilter{">=", 50}, false}, + {"less equal match exact", 50, amountFilter{"<=", 50}, true}, + {"less equal match below", 49, amountFilter{"<=", 50}, true}, + {"less equal no match", 51, amountFilter{"<=", 50}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchAmount(tt.amount, tt.filter); got != tt.want { + t.Errorf("matchAmount() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplyFilters(t *testing.T) { + bills := []api.BillResponse{ + {ID: 1, What: "Groceries", Amount: 50, PayerID: 1, CategoryID: 1}, + {ID: 2, What: "Dinner", Amount: 100, PayerID: 2, CategoryID: 2}, + {ID: 3, What: "Lunch", Amount: 25, PayerID: 1, CategoryID: 2}, + {ID: 4, What: "Coffee", Amount: 5, PayerID: 3, CategoryID: 1}, + } + + t.Run("no filters", func(t *testing.T) { + result := applyFilters(bills, nil) + if len(result) != 4 { + t.Errorf("applyFilters() returned %d bills, want 4", len(result)) + } + }) + + t.Run("single filter", func(t *testing.T) { + filters := []billFilter{ + func(b api.BillResponse) bool { return b.PayerID == 1 }, + } + result := applyFilters(bills, filters) + if len(result) != 2 { + t.Errorf("applyFilters() returned %d bills, want 2", len(result)) + } + }) + + t.Run("multiple filters AND", func(t *testing.T) { + filters := []billFilter{ + func(b api.BillResponse) bool { return b.PayerID == 1 }, + func(b api.BillResponse) bool { return b.Amount > 30 }, + } + result := applyFilters(bills, filters) + if len(result) != 1 { + t.Errorf("applyFilters() returned %d bills, want 1", len(result)) + } + if result[0].ID != 1 { + t.Errorf("applyFilters() returned bill ID %d, want 1", result[0].ID) + } + }) + + t.Run("filter with no matches", func(t *testing.T) { + filters := []billFilter{ + func(b api.BillResponse) bool { return b.Amount > 1000 }, + } + result := applyFilters(bills, filters) + if len(result) != 0 { + t.Errorf("applyFilters() returned %d bills, want 0", len(result)) + } + }) +} + +func TestPrintBillsTable(t *testing.T) { + // Reset global flags + 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"}, + {ID: 2, Name: "Transport"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + {ID: 2, Name: "Card"}, + }, + } + + 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, + }, + } + + cmd := NewListCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + printBillsTable(cmd, project, bills) + + output := buf.String() + + // Check that key elements are present + if !bytes.Contains([]byte(output), []byte("Groceries")) { + t.Error("Output should contain bill name 'Groceries'") + } + if !bytes.Contains([]byte(output), []byte("Alice")) { + t.Error("Output should contain payer name 'Alice'") + } + if !bytes.Contains([]byte(output), []byte("Bob")) { + t.Error("Output should contain ower name 'Bob'") + } + if !bytes.Contains([]byte(output), []byte("Food")) { + t.Error("Output should contain category 'Food'") + } + if !bytes.Contains([]byte(output), []byte("Cash")) { + t.Error("Output should contain payment method 'Cash'") + } + if !bytes.Contains([]byte(output), []byte("50.00")) { + t.Error("Output should contain amount '50.00'") + } + if !bytes.Contains([]byte(output), []byte("Total: 1 bill(s)")) { + t.Error("Output should contain total count") + } +} + +func TestPrintBillsTableEmpty(t *testing.T) { + resetListFlags() + + project := &api.Project{} + bills := []api.BillResponse{} + + cmd := NewListCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + printBillsTable(cmd, project, bills) + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("No bills found")) { + t.Error("Output should indicate no bills found") + } +} + +func TestBuildFiltersNameFilter(t *testing.T) { + resetListFlags() + + project := &api.Project{} + + // Set name filter + listName = "grocery" + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + // Test the filter + bill1 := api.BillResponse{What: "Grocery shopping"} + bill2 := api.BillResponse{What: "Dinner"} + + if !filters[0](bill1) { + t.Error("Filter should match 'Grocery shopping'") + } + if filters[0](bill2) { + t.Error("Filter should not match 'Dinner'") + } + + resetListFlags() +} + +func TestBuildFiltersAmountFilter(t *testing.T) { + resetListFlags() + + project := &api.Project{} + + // Set amount filter + listAmount = ">50" + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + // Test the filter + bill1 := api.BillResponse{Amount: 100} + bill2 := api.BillResponse{Amount: 50} + bill3 := api.BillResponse{Amount: 25} + + if !filters[0](bill1) { + t.Error("Filter should match amount 100") + } + if filters[0](bill2) { + t.Error("Filter should not match amount 50 (not strictly greater)") + } + if filters[0](bill3) { + t.Error("Filter should not match amount 25") + } + + resetListFlags() +} + +func resetListFlags() { + ProjectID = "" + listPaidBy = "" + listPaidFor = nil + listAmount = "" + listName = "" + listPaymentMethod = "" + listCategory = "" +} diff --git a/cmd/projects.go b/cmd/projects.go new file mode 100644 index 0000000..c75f415 --- /dev/null +++ b/cmd/projects.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + + "github.com/chenasraf/cospend-cli/internal/api" + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +var showAllProjects bool + +// NewProjectsCommand creates the projects command +func NewProjectsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "projects", + Aliases: []string{"proj"}, + Short: "List available Cospend projects", + Long: `List all Cospend projects you have access to.`, + RunE: runProjects, + } + + cmd.Flags().BoolVarP(&showAllProjects, "all", "a", false, "Show all projects including archived") + + return cmd +} + +func runProjects(cmd *cobra.Command, _ []string) error { + // Parameters validated, silence usage for subsequent errors + cmd.SilenceUsage = true + + // Load configuration + cfg, err := config.Load() + if err != nil { + return err + } + + // Get API client + client := api.NewClient(cfg) + client.Debug = Debug + client.DebugWriter = cmd.ErrOrStderr() + + // Fetch projects + projects, err := client.GetProjects() + if err != nil { + return fmt.Errorf("fetching projects: %w", err) + } + + // Filter out archived projects unless --all is set + var filtered []api.ProjectSummary + for _, proj := range projects { + if showAllProjects || !proj.IsArchived() { + filtered = append(filtered, proj) + } + } + + // Print table + out := cmd.OutOrStdout() + if len(filtered) == 0 { + _, _ = fmt.Fprintln(out, "No projects found.") + return nil + } + + table := NewTable("ID", "NAME", "CURRENCY") + for _, proj := range filtered { + currency := proj.CurrName + if currency == "" { + currency = "-" + } + table.AddRow(proj.ID, proj.Name, currency) + } + + table.Render(out) + _, _ = fmt.Fprintf(out, "\nTotal: %d project(s)\n", len(filtered)) + + return nil +} diff --git a/cmd/table.go b/cmd/table.go new file mode 100644 index 0000000..1b53485 --- /dev/null +++ b/cmd/table.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "fmt" + "io" + "strings" +) + +// Table border characters +const ( + borderHorizontal = "─" + borderVertical = "│" + borderTopLeft = "┌" + borderTopRight = "┐" + borderBottomLeft = "└" + borderBottomRight = "┘" + borderTopMid = "┬" + borderBottomMid = "┴" + borderLeftMid = "├" + borderRightMid = "┤" + borderCross = "┼" +) + +// Table handles formatted table output +type Table struct { + headers []string + rows [][]string + colWidths []int +} + +// NewTable creates a new table with the given headers +func NewTable(headers ...string) *Table { + colWidths := make([]int, len(headers)) + for i, h := range headers { + colWidths[i] = len(h) + } + return &Table{ + headers: headers, + colWidths: colWidths, + } +} + +// AddRow adds a row to the table +func (t *Table) AddRow(values ...string) { + // Pad with empty strings if needed + for len(values) < len(t.headers) { + values = append(values, "") + } + // Truncate if too many + if len(values) > len(t.headers) { + values = values[:len(t.headers)] + } + + t.rows = append(t.rows, values) + + // Update column widths + for i, v := range values { + if len(v) > t.colWidths[i] { + t.colWidths[i] = len(v) + } + } +} + +// Render writes the table to the given writer +func (t *Table) Render(w io.Writer) { + t.printBorder(w, borderTopLeft, borderTopMid, borderTopRight) + t.printRow(w, t.headers) + t.printBorder(w, borderLeftMid, borderCross, borderRightMid) + + for _, row := range t.rows { + t.printRow(w, row) + } + + t.printBorder(w, borderBottomLeft, borderBottomMid, borderBottomRight) +} + +func (t *Table) printBorder(w io.Writer, left, mid, right string) { + _, _ = fmt.Fprint(w, left) + for i, width := range t.colWidths { + _, _ = fmt.Fprint(w, strings.Repeat(borderHorizontal, width+2)) + if i < len(t.colWidths)-1 { + _, _ = fmt.Fprint(w, mid) + } + } + _, _ = fmt.Fprintln(w, right) +} + +func (t *Table) printRow(w io.Writer, values []string) { + _, _ = fmt.Fprint(w, borderVertical) + for i, val := range values { + _, _ = fmt.Fprintf(w, " %-*s %s", t.colWidths[i], val, borderVertical) + } + _, _ = fmt.Fprintln(w) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2191612 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/chenasraf/cospend-cli + +go 1.24.0 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/adrg/xdg v0.4.0 + github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.39.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7c591ac --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..cb87ebb --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,439 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/chenasraf/cospend-cli/internal/config" +) + +// Client is the Cospend API client +type Client struct { + config *config.Config + httpClient *http.Client + Debug bool + DebugWriter io.Writer +} + +// Member represents a project member +type Member struct { + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"userid"` + Activated bool `json:"activated"` +} + +// Category represents a bill category +type Category struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// PaymentMode represents a payment method +type PaymentMode struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Currency represents a currency +type Currency struct { + ID int `json:"id"` + Name string `json:"name"` + ExchangeRate float64 `json:"exchange_rate"` +} + +// Project represents a Cospend project +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Members []Member `json:"members"` + Categories []Category // custom unmarshal + PaymentModes []PaymentMode // custom unmarshal + Currencies []Currency `json:"currencies"` +} + +// UnmarshalJSON custom unmarshaler to handle categories/paymentmodes as object or array +func (p *Project) UnmarshalJSON(data []byte) error { + // Use a map for flexible parsing + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Parse simple fields + if v, ok := raw["id"]; ok { + _ = json.Unmarshal(v, &p.ID) + } + if v, ok := raw["name"]; ok { + _ = json.Unmarshal(v, &p.Name) + } + if v, ok := raw["members"]; ok { + _ = json.Unmarshal(v, &p.Members) + } + if v, ok := raw["currencies"]; ok { + _ = json.Unmarshal(v, &p.Currencies) + } + + // Parse categories (can be array or object) + if v, ok := raw["categories"]; ok { + p.Categories = parseCategories(v) + } + + // Parse payment modes (can be array or object) + if v, ok := raw["paymentmodes"]; ok { + p.PaymentModes = parsePaymentModes(v) + } + + return nil +} + +// MarshalJSON custom marshaler for Project +func (p Project) MarshalJSON() ([]byte, error) { + type ProjectAlias struct { + ID string `json:"id"` + Name string `json:"name"` + Members []Member `json:"members"` + Categories []Category `json:"categories"` + PaymentModes []PaymentMode `json:"paymentmodes"` + Currencies []Currency `json:"currencies"` + } + return json.Marshal(ProjectAlias(p)) +} + +func parseCategories(data json.RawMessage) []Category { + // API returns categories as object keyed by ID + var obj map[string]Category + if err := json.Unmarshal(data, &obj); err == nil { + result := make([]Category, 0, len(obj)) + for _, cat := range obj { + result = append(result, cat) + } + return result + } + // Fallback to array (for tests) + var arr []Category + if err := json.Unmarshal(data, &arr); err == nil { + return arr + } + return nil +} + +func parsePaymentModes(data json.RawMessage) []PaymentMode { + // API returns payment modes as object keyed by ID + var obj map[string]PaymentMode + if err := json.Unmarshal(data, &obj); err == nil { + result := make([]PaymentMode, 0, len(obj)) + for _, pm := range obj { + result = append(result, pm) + } + return result + } + // Fallback to array (for tests) + var arr []PaymentMode + if err := json.Unmarshal(data, &arr); err == nil { + return arr + } + return nil +} + +// Bill represents a bill to create +type Bill struct { + What string `json:"what"` + Amount float64 `json:"amount"` + PayerID int `json:"payer_id"` + OwedTo []int `json:"-"` // Will be formatted as comma-separated string + Date string `json:"date"` + Comment string `json:"comment,omitempty"` + PaymentModeID int `json:"paymentmodeid,omitempty"` + CategoryID int `json:"categoryid,omitempty"` + OriginalCurrencyID int `json:"original_currency_id,omitempty"` +} + +// BillResponse represents a bill returned from the API +type BillResponse struct { + ID int `json:"id"` + What string `json:"what"` + Amount float64 `json:"amount"` + Date string `json:"date"` + PayerID int `json:"payer_id"` + Owers []Ower `json:"owers"` + Comment string `json:"comment"` + PaymentModeID int `json:"paymentmodeid"` + CategoryID int `json:"categoryid"` + Repeat string `json:"repeat"` + Timestamp int64 `json:"timestamp"` +} + +// Ower represents a member who owes part of a bill +type Ower struct { + ID int `json:"id"` + Weight float64 `json:"weight"` +} + +// OCSResponse wraps the OCS API response format +type OCSResponse struct { + OCS struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + } `json:"ocs"` +} + +// NewClient creates a new API client +func NewClient(cfg *config.Config) *Client { + return &Client{ + config: cfg, + httpClient: &http.Client{}, + } +} + +func (c *Client) debugf(format string, args ...interface{}) { + if c.Debug && c.DebugWriter != nil { + _, _ = fmt.Fprintf(c.DebugWriter, "[DEBUG] "+format+"\n", args...) + } +} + +func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) { + baseURL := strings.TrimSuffix(c.config.Domain, "/") + fullURL := fmt.Sprintf("%s%s", baseURL, path) + + c.debugf("Request: %s %s", method, fullURL) + + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.SetBasicAuth(c.config.User, c.config.Password) + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + c.debugf("Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic %s:***", c.config.User) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.debugf("Request error: %v", err) + return nil, err + } + + c.debugf("Response: %d %s", resp.StatusCode, resp.Status) + + return resp, nil +} + +// GetProject fetches project details including members, categories, and payment modes +func (c *Client) GetProject(projectID string) (*Project, error) { + path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s", url.PathEscape(projectID)) + + resp, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("fetching project: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ocsResp OCSResponse + if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + if ocsResp.OCS.Meta.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message) + } + + var project Project + if err := json.Unmarshal(ocsResp.OCS.Data, &project); err != nil { + return nil, fmt.Errorf("decoding project data: %w", err) + } + + return &project, nil +} + +// ProjectSummary represents a project in the list response +type ProjectSummary struct { + ID string `json:"id"` + Name string `json:"name"` + CurrName string `json:"currencyname"` + ArchivedTS *int64 `json:"archived_ts"` +} + +// IsArchived returns true if the project is archived +func (p *ProjectSummary) IsArchived() bool { + return p.ArchivedTS != nil +} + +// GetProjects fetches all projects the user has access to +func (c *Client) GetProjects() ([]ProjectSummary, error) { + path := "/ocs/v2.php/apps/cospend/api/v1/projects" + + resp, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("fetching projects: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ocsResp OCSResponse + if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + if ocsResp.OCS.Meta.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message) + } + + c.debugf("Projects response: %s", string(ocsResp.OCS.Data)) + + var projects []ProjectSummary + if err := json.Unmarshal(ocsResp.OCS.Data, &projects); err != nil { + return nil, fmt.Errorf("decoding projects data: %w", err) + } + + return projects, nil +} + +// CreateBill creates a new bill in the project +func (c *Client) CreateBill(projectID string, bill Bill) error { + path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID)) + + // Build form data + data := url.Values{} + data.Set("what", bill.What) + data.Set("amount", strconv.FormatFloat(bill.Amount, 'f', 2, 64)) + data.Set("payer", strconv.Itoa(bill.PayerID)) + data.Set("date", bill.Date) + data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + data.Set("repeat", "n") + + // Format owed member IDs as comma-separated string + owedIDs := make([]string, len(bill.OwedTo)) + for i, id := range bill.OwedTo { + owedIDs[i] = strconv.Itoa(id) + } + data.Set("payedFor", strings.Join(owedIDs, ",")) + + if bill.Comment != "" { + data.Set("comment", bill.Comment) + } + if bill.PaymentModeID != 0 { + data.Set("paymentmodeid", strconv.Itoa(bill.PaymentModeID)) + } + if bill.CategoryID != 0 { + data.Set("categoryid", strconv.Itoa(bill.CategoryID)) + } + if bill.OriginalCurrencyID != 0 { + data.Set("original_currency_id", strconv.Itoa(bill.OriginalCurrencyID)) + } + + c.debugf("Request body: %s", data.Encode()) + + resp, err := c.doRequest("POST", path, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("creating bill: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ocsResp OCSResponse + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&ocsResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if ocsResp.OCS.Meta.StatusCode != 200 { + return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message) + } + + return nil +} + +// GetBills fetches all bills for a project +func (c *Client) GetBills(projectID string) ([]BillResponse, error) { + path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills", url.PathEscape(projectID)) + + resp, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("fetching bills: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ocsResp OCSResponse + if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + if ocsResp.OCS.Meta.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message) + } + + // API returns: {"nb_bills": N, "bills": [...], "allBillIds": [...], "timestamp": N} + var billsWrapper struct { + Bills []BillResponse `json:"bills"` + } + if err := json.Unmarshal(ocsResp.OCS.Data, &billsWrapper); err != nil { + return nil, fmt.Errorf("decoding bills data: %w", err) + } + + return billsWrapper.Bills, nil +} + +// DeleteBill deletes a bill from the project +func (c *Client) DeleteBill(projectID string, billID int) error { + path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID) + + resp, err := c.doRequest("DELETE", path, nil) + if err != nil { + return fmt.Errorf("deleting bill: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var ocsResp OCSResponse + if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if ocsResp.OCS.Meta.StatusCode != 200 { + return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message) + } + + return nil +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..497ba53 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,436 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chenasraf/cospend-cli/internal/config" +) + +func TestNewClient(t *testing.T) { + cfg := &config.Config{ + Domain: "https://cloud.example.com", + User: "testuser", + Password: "testpass", + } + + client := NewClient(cfg) + + if client == nil { + t.Fatal("NewClient() returned nil") + } + if client.config != cfg { + t.Error("NewClient() config not set correctly") + } + if client.httpClient == nil { + t.Error("NewClient() httpClient is nil") + } +} + +func TestGetProject(t *testing.T) { + projectData := Project{ + ID: "test-project", + Name: "Test Project", + Members: []Member{ + {ID: 1, Name: "Alice", UserID: "alice", Activated: true}, + {ID: 2, Name: "Bob", UserID: "bob", Activated: true}, + }, + Categories: []Category{ + {ID: 1, Name: "Food"}, + {ID: 2, Name: "Transport"}, + }, + PaymentModes: []PaymentMode{ + {ID: 1, Name: "Cash"}, + {ID: 2, Name: "Credit Card"}, + }, + Currencies: []Currency{ + {ID: 1, Name: "$", ExchangeRate: 1.0}, + }, + } + + tests := []struct { + name string + projectID string + responseStatus int + responseBody any + wantErr bool + }{ + { + name: "successful request", + projectID: "test-project", + responseStatus: http.StatusOK, + responseBody: OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "ok", + StatusCode: 200, + Message: "OK", + }, + Data: mustMarshal(projectData), + }, + }, + wantErr: false, + }, + { + name: "project not found", + projectID: "nonexistent", + responseStatus: http.StatusNotFound, + responseBody: "Not Found", + wantErr: true, + }, + { + name: "api error", + projectID: "test-project", + responseStatus: http.StatusOK, + responseBody: OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "failure", + StatusCode: 404, + Message: "Project not found", + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request headers + if r.Header.Get("OCS-APIRequest") != "true" { + t.Error("Missing OCS-APIRequest header") + } + + // Verify Basic Auth + user, pass, ok := r.BasicAuth() + if !ok { + t.Error("Missing Basic Auth") + } + if user != "testuser" || pass != "testpass" { + t.Errorf("Wrong credentials: %s:%s", user, pass) + } + + // Verify path + expectedPath := "/ocs/v2.php/apps/cospend/api/v1/projects/" + tt.projectID + if r.URL.Path != expectedPath { + t.Errorf("Wrong path: got %s, want %s", r.URL.Path, expectedPath) + } + + w.WriteHeader(tt.responseStatus) + if s, ok := tt.responseBody.(string); ok { + _, _ = w.Write([]byte(s)) + } else { + _ = json.NewEncoder(w).Encode(tt.responseBody) + } + })) + defer server.Close() + + cfg := &config.Config{ + Domain: server.URL, + User: "testuser", + Password: "testpass", + } + client := NewClient(cfg) + + project, err := client.GetProject(tt.projectID) + if (err != nil) != tt.wantErr { + t.Errorf("GetProject() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && project != nil { + if project.ID != projectData.ID { + t.Errorf("GetProject() ID = %v, want %v", project.ID, projectData.ID) + } + if len(project.Members) != len(projectData.Members) { + t.Errorf("GetProject() Members count = %v, want %v", len(project.Members), len(projectData.Members)) + } + } + }) + } +} + +func TestCreateBill(t *testing.T) { + tests := []struct { + name string + bill Bill + responseStatus int + responseBody any + wantErr bool + checkRequest func(t *testing.T, r *http.Request) + }{ + { + name: "successful creation", + bill: Bill{ + What: "Test expense", + Amount: 25.50, + PayerID: 1, + OwedTo: []int{1, 2}, + Date: "2024-01-15", + Comment: "Test comment", + PaymentModeID: 1, + CategoryID: 2, + }, + responseStatus: http.StatusOK, + responseBody: OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "ok", + StatusCode: 200, + Message: "OK", + }, + Data: mustMarshal(map[string]int{"id": 123}), + }, + }, + wantErr: false, + checkRequest: func(t *testing.T, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Wrong method: got %s, want POST", r.Method) + } + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("Wrong Content-Type: %s", r.Header.Get("Content-Type")) + } + + _ = r.ParseForm() + if r.FormValue("what") != "Test expense" { + t.Errorf("Wrong what: %s", r.FormValue("what")) + } + if r.FormValue("amount") != "25.50" { + t.Errorf("Wrong amount: %s", r.FormValue("amount")) + } + if r.FormValue("payer") != "1" { + t.Errorf("Wrong payer: %s", r.FormValue("payer")) + } + if r.FormValue("payedFor") != "1,2" { + t.Errorf("Wrong payedFor: %s", r.FormValue("payedFor")) + } + if r.FormValue("comment") != "Test comment" { + t.Errorf("Wrong comment: %s", r.FormValue("comment")) + } + }, + }, + { + name: "minimal bill", + bill: Bill{ + What: "Simple expense", + Amount: 10.00, + PayerID: 1, + OwedTo: []int{1}, + Date: "2024-01-15", + }, + responseStatus: http.StatusOK, + responseBody: OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "ok", + StatusCode: 200, + Message: "OK", + }, + }, + }, + wantErr: false, + checkRequest: func(t *testing.T, r *http.Request) { + _ = r.ParseForm() + // Optional fields should be empty + if r.FormValue("comment") != "" { + t.Errorf("Comment should be empty: %s", r.FormValue("comment")) + } + if r.FormValue("paymentmodeid") != "" { + t.Errorf("paymentmodeid should be empty: %s", r.FormValue("paymentmodeid")) + } + if r.FormValue("categoryid") != "" { + t.Errorf("categoryid should be empty: %s", r.FormValue("categoryid")) + } + }, + }, + { + name: "server error", + bill: Bill{ + What: "Test", + Amount: 10.00, + PayerID: 1, + OwedTo: []int{1}, + Date: "2024-01-15", + }, + responseStatus: http.StatusInternalServerError, + responseBody: "Internal Server Error", + wantErr: true, + }, + { + name: "api error response", + bill: Bill{ + What: "Test", + Amount: 10.00, + PayerID: 1, + OwedTo: []int{1}, + Date: "2024-01-15", + }, + responseStatus: http.StatusOK, + responseBody: OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "failure", + StatusCode: 400, + Message: "Invalid bill data", + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify common headers + if r.Header.Get("OCS-APIRequest") != "true" { + t.Error("Missing OCS-APIRequest header") + } + + if tt.checkRequest != nil { + tt.checkRequest(t, r) + } + + w.WriteHeader(tt.responseStatus) + if s, ok := tt.responseBody.(string); ok { + _, _ = w.Write([]byte(s)) + } else { + _ = json.NewEncoder(w).Encode(tt.responseBody) + } + })) + defer server.Close() + + cfg := &config.Config{ + Domain: server.URL, + User: "testuser", + Password: "testpass", + } + client := NewClient(cfg) + + err := client.CreateBill("test-project", tt.bill) + if (err != nil) != tt.wantErr { + t.Errorf("CreateBill() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCreateBillWithCurrency(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.FormValue("original_currency_id") != "5" { + t.Errorf("Wrong original_currency_id: %s", r.FormValue("original_currency_id")) + } + + response := OCSResponse{ + OCS: struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data json.RawMessage `json:"data"` + }{ + Meta: struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + }{ + Status: "ok", + StatusCode: 200, + Message: "OK", + }, + }, + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + cfg := &config.Config{ + Domain: server.URL, + User: "testuser", + Password: "testpass", + } + client := NewClient(cfg) + + bill := Bill{ + What: "Currency test", + Amount: 100.00, + PayerID: 1, + OwedTo: []int{1}, + Date: "2024-01-15", + OriginalCurrencyID: 5, + } + + err := client.CreateBill("test-project", bill) + if err != nil { + t.Errorf("CreateBill() unexpected error: %v", err) + } +} + +func mustMarshal(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..b59038c --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,293 @@ +package cache + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/adrg/xdg" + "github.com/chenasraf/cospend-cli/internal/api" +) + +const ( + cacheTTL = 1 * time.Hour + appName = "cospend" +) + +// currencyCodeToSymbol maps currency codes to their symbols +var currencyCodeToSymbol = map[string]string{ + "aed": "د.إ", + "afn": "؋", + "all": "Lek", + "amd": "դր.", + "ars": "$", + "aud": "$", + "azn": "ман.", + "bam": "KM", + "bdt": "৳", + "bgn": "лв.", + "bhd": "د.ب.", + "bif": "FBu", + "bnd": "$", + "bob": "Bs", + "brl": "R$", + "bwp": "P", + "byn": "руб.", + "bzd": "$", + "cad": "$", + "cdf": "FrCD", + "chf": "CHF", + "clp": "$", + "cny": "¥", + "cop": "$", + "crc": "₡", + "cup": "$", + "cve": "CV$", + "czk": "Kč", + "djf": "Fdj", + "dkk": "kr", + "dop": "RD$", + "dzd": "د.ج.", + "egp": "ج.م.", + "etb": "Br", + "eur": "€", + "gbp": "£", + "gel": "GEL", + "ghs": "GH₵", + "gnf": "FG", + "gtq": "Q", + "hkd": "$", + "hnl": "L", + "huf": "Ft", + "idr": "Rp", + "ils": "₪", + "inr": "₹", + "iqd": "د.ع.", + "irr": "﷼", + "isk": "kr", + "jmd": "$", + "jod": "د.أ.", + "jpy": "¥", + "kes": "Ksh", + "khr": "៛", + "kmf": "FC", + "krw": "₩", + "kwd": "د.ك.", + "kzt": "тңг.", + "lbp": "ل.ل.", + "lkr": "Rs", + "lyd": "د.ل.", + "mad": "د.م.", + "mdl": "MDL", + "mga": "MGA", + "mkd": "MKD", + "mmk": "K", + "mop": "MOP$", + "mur": "MURs", + "mxn": "$", + "myr": "RM", + "mzn": "MTn", + "nad": "N$", + "ngn": "₦", + "nio": "C$", + "nok": "kr", + "npr": "Rs", + "nzd": "$", + "omr": "ر.ع.", + "pab": "B/.", + "pen": "S/.", + "php": "₱", + "pkr": "₨", + "pln": "zł", + "pyg": "₲", + "qar": "ر.ق.", + "ron": "RON", + "rsd": "дин.", + "rub": "₽", + "rwf": "FR", + "sar": "﷼", + "sdg": "SDG", + "sek": "kr", + "sgd": "$", + "sos": "Ssh", + "thb": "฿", + "tnd": "د.ت.", + "top": "T$", + "try": "₺", + "ttd": "$", + "twd": "NT$", + "tzs": "TSh", + "uah": "₴", + "ugx": "USh", + "usd": "$", + "uyu": "$", + "uzs": "UZS", + "vnd": "₫", + "xaf": "FCFA", + "xcd": "EC$", + "xof": "CFA", + "yer": "ر.ي.", + "zar": "R", +} + +// CachedProject stores project data with timestamp +type CachedProject struct { + Project *api.Project `json:"project"` + CachedAt time.Time `json:"cached_at"` +} + +// getCacheHome returns the cache home directory, checking XDG_CACHE_HOME env var first +func getCacheHome() string { + if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" { + return dir + } + return xdg.CacheHome +} + +// getCachePath returns the cache file path for a project +func getCachePath(projectID string) (string, error) { + cacheDir := filepath.Join(getCacheHome(), appName) + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return "", fmt.Errorf("creating cache directory: %w", err) + } + return filepath.Join(cacheDir, fmt.Sprintf("%s.json", projectID)), nil +} + +// Load retrieves cached project data if it exists and is not expired +func Load(projectID string) (*api.Project, bool) { + path, err := getCachePath(projectID) + if err != nil { + return nil, false + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + + var cached CachedProject + if err := json.Unmarshal(data, &cached); err != nil { + return nil, false + } + + // Check if cache is expired + if time.Since(cached.CachedAt) > cacheTTL { + return nil, false + } + + return cached.Project, true +} + +// Save stores project data in the cache +func Save(projectID string, project *api.Project) error { + path, err := getCachePath(projectID) + if err != nil { + return err + } + + cached := CachedProject{ + Project: project, + CachedAt: time.Now(), + } + + data, err := json.MarshalIndent(cached, "", " ") + if err != nil { + return fmt.Errorf("marshaling cache data: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing cache file: %w", err) + } + + return nil +} + +// ResolveMember finds a member by username (case-insensitive) and returns their ID +func ResolveMember(project *api.Project, username string) (int, error) { + lowerUsername := strings.ToLower(username) + for _, m := range project.Members { + if strings.ToLower(m.Name) == lowerUsername || strings.ToLower(m.UserID) == lowerUsername { + return m.ID, nil + } + } + return 0, fmt.Errorf("member not found: %s", username) +} + +// ResolveCategory finds a category by name (case-insensitive) or ID and returns the ID +func ResolveCategory(project *api.Project, nameOrID string) (int, error) { + // Try parsing as ID first + if id, err := strconv.Atoi(nameOrID); err == nil { + for _, c := range project.Categories { + if c.ID == id { + return id, nil + } + } + } + + // Try matching by name (case-insensitive) + lowerName := strings.ToLower(nameOrID) + for _, c := range project.Categories { + if strings.ToLower(c.Name) == lowerName { + return c.ID, nil + } + } + + return 0, fmt.Errorf("category not found: %s", nameOrID) +} + +// ResolvePaymentMode finds a payment mode by name (case-insensitive) or ID and returns the ID +func ResolvePaymentMode(project *api.Project, nameOrID string) (int, error) { + // Try parsing as ID first + if id, err := strconv.Atoi(nameOrID); err == nil { + for _, pm := range project.PaymentModes { + if pm.ID == id { + return id, nil + } + } + } + + // Try matching by name (case-insensitive) + lowerName := strings.ToLower(nameOrID) + for _, pm := range project.PaymentModes { + if strings.ToLower(pm.Name) == lowerName { + return pm.ID, nil + } + } + + return 0, fmt.Errorf("payment mode not found: %s", nameOrID) +} + +// ResolveCurrency finds a currency by name (case-insensitive), ID, or currency code symbol and returns the ID +func ResolveCurrency(project *api.Project, nameOrID string) (int, error) { + // Try parsing as ID first + if id, err := strconv.Atoi(nameOrID); err == nil { + for _, cur := range project.Currencies { + if cur.ID == id { + return id, nil + } + } + } + + // Try matching by name (case-insensitive) + lowerName := strings.ToLower(nameOrID) + for _, cur := range project.Currencies { + if strings.ToLower(cur.Name) == lowerName { + return cur.ID, nil + } + } + + // Try matching by currency code symbol (e.g., "usd" -> "$") + if symbol, ok := currencyCodeToSymbol[lowerName]; ok { + for _, cur := range project.Currencies { + if strings.Contains(cur.Name, symbol) { + return cur.ID, nil + } + } + } + + return 0, fmt.Errorf("currency not found: %s", nameOrID) +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..90d4e5a --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,292 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/chenasraf/cospend-cli/internal/api" +) + +func TestResolveMember(t *testing.T) { + project := &api.Project{ + Members: []api.Member{ + {ID: 1, Name: "Alice", UserID: "alice"}, + {ID: 2, Name: "Bob", UserID: "bob"}, + {ID: 3, Name: "Charlie", UserID: "charlie123"}, + }, + } + + tests := []struct { + name string + username string + wantID int + wantErr bool + }{ + {"by name exact", "Alice", 1, false}, + {"by name lowercase", "alice", 1, false}, + {"by name uppercase", "ALICE", 1, false}, + {"by name mixed case", "aLiCe", 1, false}, + {"by userid", "bob", 2, false}, + {"by userid different from name", "charlie123", 3, false}, + {"by name when userid differs", "Charlie", 3, false}, + {"not found", "unknown", 0, true}, + {"empty string", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, err := ResolveMember(project, tt.username) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveMember() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotID != tt.wantID { + t.Errorf("ResolveMember() = %v, want %v", gotID, tt.wantID) + } + }) + } +} + +func TestResolveCategory(t *testing.T) { + project := &api.Project{ + Categories: []api.Category{ + {ID: 1, Name: "Groceries"}, + {ID: 2, Name: "Restaurant"}, + {ID: 10, Name: "Transport"}, + }, + } + + tests := []struct { + name string + nameOrID string + wantID int + wantErr bool + }{ + {"by id", "1", 1, false}, + {"by id second", "2", 2, false}, + {"by id double digit", "10", 10, false}, + {"by name exact", "Groceries", 1, false}, + {"by name lowercase", "groceries", 1, false}, + {"by name uppercase", "RESTAURANT", 2, false}, + {"by name mixed case", "tRaNsPoRt", 10, false}, + {"id not found", "99", 0, true}, + {"name not found", "Unknown", 0, true}, + {"empty string", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, err := ResolveCategory(project, tt.nameOrID) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveCategory() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotID != tt.wantID { + t.Errorf("ResolveCategory() = %v, want %v", gotID, tt.wantID) + } + }) + } +} + +func TestResolvePaymentMode(t *testing.T) { + project := &api.Project{ + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + {ID: 2, Name: "Credit Card"}, + {ID: 3, Name: "Bank Transfer"}, + }, + } + + tests := []struct { + name string + nameOrID string + wantID int + wantErr bool + }{ + {"by id", "1", 1, false}, + {"by id second", "2", 2, false}, + {"by name exact", "Cash", 1, false}, + {"by name lowercase", "cash", 1, false}, + {"by name with space", "Credit Card", 2, false}, + {"by name with space lowercase", "credit card", 2, false}, + {"by name uppercase", "BANK TRANSFER", 3, false}, + {"id not found", "99", 0, true}, + {"name not found", "Bitcoin", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, err := ResolvePaymentMode(project, tt.nameOrID) + if (err != nil) != tt.wantErr { + t.Errorf("ResolvePaymentMode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotID != tt.wantID { + t.Errorf("ResolvePaymentMode() = %v, want %v", gotID, tt.wantID) + } + }) + } +} + +func TestResolveCurrency(t *testing.T) { + project := &api.Project{ + Currencies: []api.Currency{ + {ID: 1, Name: "$", ExchangeRate: 1.0}, + {ID: 2, Name: "€", ExchangeRate: 0.85}, + {ID: 3, Name: "£", ExchangeRate: 0.73}, + {ID: 4, Name: "US Dollar ($)", ExchangeRate: 1.0}, + {ID: 5, Name: "Japanese Yen (¥)", ExchangeRate: 110.0}, + }, + } + + tests := []struct { + name string + nameOrID string + wantID int + wantErr bool + }{ + {"by id", "1", 1, false}, + {"by id second", "2", 2, false}, + {"by name exact symbol", "$", 1, false}, + {"by name exact euro", "€", 2, false}, + {"by name with description", "US Dollar ($)", 4, false}, + {"by currency code usd", "usd", 1, false}, + {"by currency code USD uppercase", "USD", 1, false}, + {"by currency code eur", "eur", 2, false}, + {"by currency code gbp", "gbp", 3, false}, + {"by currency code jpy", "jpy", 5, false}, + {"id not found", "99", 0, true}, + {"name not found", "Bitcoin", 0, true}, + {"unknown currency code", "xyz", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, err := ResolveCurrency(project, tt.nameOrID) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveCurrency() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotID != tt.wantID { + t.Errorf("ResolveCurrency() = %v, want %v", gotID, tt.wantID) + } + }) + } +} + +func TestCurrencyCodeToSymbolMapping(t *testing.T) { + // Test that common currency codes are mapped + expectedMappings := map[string]string{ + "usd": "$", + "eur": "€", + "gbp": "£", + "jpy": "¥", + "cny": "¥", + "inr": "₹", + "krw": "₩", + "brl": "R$", + } + + for code, expectedSymbol := range expectedMappings { + if symbol, ok := currencyCodeToSymbol[code]; !ok { + t.Errorf("Currency code %q not found in mapping", code) + } else if symbol != expectedSymbol { + t.Errorf("Currency code %q maps to %q, want %q", code, symbol, expectedSymbol) + } + } +} + +func TestSaveAndLoad(t *testing.T) { + // Use a temp directory for testing + tempDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tempDir) + + project := &api.Project{ + ID: "test-project", + Name: "Test Project", + Members: []api.Member{ + {ID: 1, Name: "Alice", UserID: "alice"}, + }, + Categories: []api.Category{ + {ID: 1, Name: "Food"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 1, Name: "Cash"}, + }, + Currencies: []api.Currency{ + {ID: 1, Name: "$", ExchangeRate: 1.0}, + }, + } + + // Test Save + err := Save("test-project", project) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify file exists + cachePath := filepath.Join(tempDir, "cospend", "test-project.json") + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + t.Errorf("Cache file not created at %s", cachePath) + } + + // Test Load + loaded, ok := Load("test-project") + if !ok { + t.Fatal("Load() returned false, expected true") + } + + if loaded.ID != project.ID { + t.Errorf("Load() ID = %v, want %v", loaded.ID, project.ID) + } + if loaded.Name != project.Name { + t.Errorf("Load() Name = %v, want %v", loaded.Name, project.Name) + } + if len(loaded.Members) != len(project.Members) { + t.Errorf("Load() Members count = %v, want %v", len(loaded.Members), len(project.Members)) + } +} + +func TestLoadNonExistent(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tempDir) + + _, ok := Load("non-existent-project") + if ok { + t.Error("Load() returned true for non-existent project, expected false") + } +} + +func TestLoadExpired(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tempDir) + + project := &api.Project{ + ID: "expired-project", + Name: "Expired Project", + } + + // Save the project + err := Save("expired-project", project) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Modify the cache file to have an old timestamp + cachePath := filepath.Join(tempDir, "cospend", "expired-project.json") + oldTime := time.Now().Add(-2 * time.Hour) // 2 hours ago, TTL is 1 hour + _ = os.Chtimes(cachePath, oldTime, oldTime) + + // Manually update the cached_at field in the file + // Replace the timestamp in the JSON (crude but works for testing) + oldTimestamp := time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano) + newData := []byte(`{"project":{"id":"expired-project","name":"Expired Project","members":null,"categories":null,"paymentmodes":null,"currencies":null},"cached_at":"` + oldTimestamp + `"}`) + _ = os.WriteFile(cachePath, newData, 0644) + + _, ok := Load("expired-project") + if ok { + t.Error("Load() returned true for expired cache, expected false") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..45aa357 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,184 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/adrg/xdg" + "gopkg.in/yaml.v3" +) + +const appName = "cospend" + +// Config holds the Nextcloud configuration +type Config struct { + Domain string `json:"domain" yaml:"domain" toml:"domain"` + User string `json:"user" yaml:"user" toml:"user"` + Password string `json:"password" yaml:"password" toml:"password"` +} + +// configExtensions lists supported config file extensions in order of preference +var configExtensions = []string{".json", ".yaml", ".yml", ".toml"} + +// GetConfigDir returns the primary config directory path (used for saving) +func GetConfigDir() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, appName) + } + return filepath.Join(xdg.ConfigHome, appName) +} + +// getConfigDirs returns all config directories to search, in order of preference +func getConfigDirs() []string { + dirs := []string{GetConfigDir()} + + // Also check ~/.config/cospend/ as fallback (even on macOS) + if home, err := os.UserHomeDir(); err == nil { + dotConfigDir := filepath.Join(home, ".config", appName) + // Only add if it's different from the primary dir + if dotConfigDir != dirs[0] { + dirs = append(dirs, dotConfigDir) + } + } + + return dirs +} + +// GetConfigPath returns the path to an existing config file, or empty string if none found +func GetConfigPath() string { + for _, configDir := range getConfigDirs() { + for _, ext := range configExtensions { + path := filepath.Join(configDir, appName+ext) + if _, err := os.Stat(path); err == nil { + return path + } + } + } + return "" +} + +// LoadFromFile reads configuration from a config file +func LoadFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + ext := filepath.Ext(path) + + switch ext { + case ".json": + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing JSON config: %w", err) + } + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing YAML config: %w", err) + } + case ".toml": + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing TOML config: %w", err) + } + default: + return nil, fmt.Errorf("unsupported config format: %s", ext) + } + + return &cfg, nil +} + +// Load reads configuration with the following precedence: +// 1. Environment variables (override config file) +// 2. Config file +func Load() (*Config, error) { + var cfg Config + + // Try to load from config file first + if configPath := GetConfigPath(); configPath != "" { + fileCfg, err := LoadFromFile(configPath) + if err != nil { + return nil, err + } + cfg = *fileCfg + } + + // Environment variables override config file values + if domain := os.Getenv("NEXTCLOUD_DOMAIN"); domain != "" { + cfg.Domain = domain + } + if user := os.Getenv("NEXTCLOUD_USER"); user != "" { + cfg.User = user + } + if password := os.Getenv("NEXTCLOUD_PASSWORD"); password != "" { + cfg.Password = password + } + + // Validate required fields + if cfg.Domain == "" { + return nil, errors.New("domain is required (set in config file or NEXTCLOUD_DOMAIN env var)") + } + if cfg.User == "" { + return nil, errors.New("user is required (set in config file or NEXTCLOUD_USER env var)") + } + if cfg.Password == "" { + return nil, errors.New("password is required (set in config file or NEXTCLOUD_PASSWORD env var)") + } + + return &cfg, nil +} + +// Save writes configuration to a file in the specified format +func Save(cfg *Config, format string) (string, error) { + configDir := GetConfigDir() + if err := os.MkdirAll(configDir, 0700); err != nil { + return "", fmt.Errorf("creating config directory: %w", err) + } + + var data []byte + var ext string + var err error + + switch format { + case "json": + ext = ".json" + data, err = json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", fmt.Errorf("encoding JSON: %w", err) + } + data = append(data, '\n') + case "yaml", "yml": + ext = ".yaml" + data, err = yaml.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("encoding YAML: %w", err) + } + case "toml": + ext = ".toml" + data, err = tomlMarshal(cfg) + if err != nil { + return "", fmt.Errorf("encoding TOML: %w", err) + } + default: + return "", fmt.Errorf("unsupported format: %s", format) + } + + path := filepath.Join(configDir, appName+ext) + if err := os.WriteFile(path, data, 0600); err != nil { + return "", fmt.Errorf("writing config file: %w", err) + } + + return path, nil +} + +// tomlMarshal encodes config to TOML format +func tomlMarshal(cfg *Config) ([]byte, error) { + content := fmt.Sprintf(`domain = %q +user = %q +password = %q +`, cfg.Domain, cfg.User, cfg.Password) + return []byte(content), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..eb81451 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,423 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadFromEnvVars(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "https://cloud.example.com") + t.Setenv("NEXTCLOUD_USER", "testuser") + t.Setenv("NEXTCLOUD_PASSWORD", "testpass") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://cloud.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://cloud.example.com") + } + if cfg.User != "testuser" { + t.Errorf("User = %v, want %v", cfg.User, "testuser") + } + if cfg.Password != "testpass" { + t.Errorf("Password = %v, want %v", cfg.Password, "testpass") + } +} + +func TestLoadFromJSONFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + // Create config directory and file + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + configContent := `{ + "domain": "https://json.example.com", + "user": "jsonuser", + "password": "jsonpass" +}` + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://json.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://json.example.com") + } + if cfg.User != "jsonuser" { + t.Errorf("User = %v, want %v", cfg.User, "jsonuser") + } +} + +func TestLoadFromYAMLFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + configContent := `domain: https://yaml.example.com +user: yamluser +password: yamlpass +` + configPath := filepath.Join(configDir, "cospend.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://yaml.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://yaml.example.com") + } + if cfg.User != "yamluser" { + t.Errorf("User = %v, want %v", cfg.User, "yamluser") + } +} + +func TestLoadFromTOMLFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + configContent := `domain = "https://toml.example.com" +user = "tomluser" +password = "tomlpass" +` + configPath := filepath.Join(configDir, "cospend.toml") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://toml.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://toml.example.com") + } + if cfg.User != "tomluser" { + t.Errorf("User = %v, want %v", cfg.User, "tomluser") + } +} + +func TestEnvVarsOverrideConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "https://env.example.com") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + configContent := `{ + "domain": "https://file.example.com", + "user": "fileuser", + "password": "filepass" +}` + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Domain should come from env var + if cfg.Domain != "https://env.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://env.example.com") + } + // User/Password should come from file + if cfg.User != "fileuser" { + t.Errorf("User = %v, want %v", cfg.User, "fileuser") + } + if cfg.Password != "filepass" { + t.Errorf("Password = %v, want %v", cfg.Password, "filepass") + } +} + +func TestLoadMissingRequired(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) // Isolate from real home + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + _, err := Load() + if err == nil { + t.Error("Load() expected error for missing required fields") + } +} + +func TestSaveJSON(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + + cfg := &Config{ + Domain: "https://test.example.com", + User: "testuser", + Password: "testpass", + } + + path, err := Save(cfg, "json") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + expectedPath := filepath.Join(tempDir, "cospend", "cospend.json") + if path != expectedPath { + t.Errorf("Save() path = %v, want %v", path, expectedPath) + } + + // Verify file contents + loaded, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile() error = %v", err) + } + if loaded.Domain != cfg.Domain { + t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain) + } +} + +func TestSaveYAML(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + + cfg := &Config{ + Domain: "https://test.example.com", + User: "testuser", + Password: "testpass", + } + + path, err := Save(cfg, "yaml") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + expectedPath := filepath.Join(tempDir, "cospend", "cospend.yaml") + if path != expectedPath { + t.Errorf("Save() path = %v, want %v", path, expectedPath) + } + + loaded, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile() error = %v", err) + } + if loaded.Domain != cfg.Domain { + t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain) + } +} + +func TestSaveTOML(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + + cfg := &Config{ + Domain: "https://test.example.com", + User: "testuser", + Password: "testpass", + } + + path, err := Save(cfg, "toml") + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + expectedPath := filepath.Join(tempDir, "cospend", "cospend.toml") + if path != expectedPath { + t.Errorf("Save() path = %v, want %v", path, expectedPath) + } + + loaded, err := LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile() error = %v", err) + } + if loaded.Domain != cfg.Domain { + t.Errorf("Domain = %v, want %v", loaded.Domain, cfg.Domain) + } +} + +func TestGetConfigPath(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) // Isolate from real home + t.Setenv("XDG_CONFIG_HOME", tempDir) + + // No config file exists + if path := GetConfigPath(); path != "" { + t.Errorf("GetConfigPath() = %v, want empty string", path) + } + + // Create JSON config + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + jsonPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(jsonPath, []byte("{}"), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + if path := GetConfigPath(); path != jsonPath { + t.Errorf("GetConfigPath() = %v, want %v", path, jsonPath) + } +} + +func TestConfigFilePrecedence(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + // Create both JSON and YAML - JSON should take precedence + jsonContent := `{"domain": "https://json.example.com", "user": "jsonuser", "password": "jsonpass"}` + yamlContent := `domain: https://yaml.example.com +user: yamluser +password: yamlpass` + + if err := os.WriteFile(filepath.Join(configDir, "cospend.json"), []byte(jsonContent), 0600); err != nil { + t.Fatalf("Failed to write JSON config: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "cospend.yaml"), []byte(yamlContent), 0600); err != nil { + t.Fatalf("Failed to write YAML config: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // JSON should take precedence + if cfg.Domain != "https://json.example.com" { + t.Errorf("Domain = %v, want %v (JSON should take precedence)", cfg.Domain, "https://json.example.com") + } +} + +func TestFallbackToDotConfig(t *testing.T) { + // Create a temp dir to act as HOME + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + // Set XDG_CONFIG_HOME to a different location (simulating macOS default behavior) + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + // Create config in ~/.config/cospend/ (fallback location) + dotConfigDir := filepath.Join(tempHome, ".config", "cospend") + if err := os.MkdirAll(dotConfigDir, 0700); err != nil { + t.Fatalf("Failed to create .config dir: %v", err) + } + + configContent := `{"domain": "https://dotconfig.example.com", "user": "dotconfiguser", "password": "dotconfigpass"}` + configPath := filepath.Join(dotConfigDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Should find config in fallback ~/.config/cospend/ + foundPath := GetConfigPath() + if foundPath != configPath { + t.Errorf("GetConfigPath() = %v, want %v", foundPath, configPath) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://dotconfig.example.com" { + t.Errorf("Domain = %v, want %v", cfg.Domain, "https://dotconfig.example.com") + } +} + +func TestXDGTakesPrecedenceOverDotConfig(t *testing.T) { + // Create a temp dir to act as HOME + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + // Set XDG_CONFIG_HOME + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + t.Setenv("NEXTCLOUD_DOMAIN", "") + t.Setenv("NEXTCLOUD_USER", "") + t.Setenv("NEXTCLOUD_PASSWORD", "") + + // Create config in both locations + xdgConfigDir := filepath.Join(xdgDir, "cospend") + if err := os.MkdirAll(xdgConfigDir, 0700); err != nil { + t.Fatalf("Failed to create XDG config dir: %v", err) + } + xdgContent := `{"domain": "https://xdg.example.com", "user": "xdguser", "password": "xdgpass"}` + xdgPath := filepath.Join(xdgConfigDir, "cospend.json") + if err := os.WriteFile(xdgPath, []byte(xdgContent), 0600); err != nil { + t.Fatalf("Failed to write XDG config file: %v", err) + } + + dotConfigDir := filepath.Join(tempHome, ".config", "cospend") + if err := os.MkdirAll(dotConfigDir, 0700); err != nil { + t.Fatalf("Failed to create .config dir: %v", err) + } + dotContent := `{"domain": "https://dotconfig.example.com", "user": "dotconfiguser", "password": "dotconfigpass"}` + if err := os.WriteFile(filepath.Join(dotConfigDir, "cospend.json"), []byte(dotContent), 0600); err != nil { + t.Fatalf("Failed to write .config file: %v", err) + } + + // XDG should take precedence + foundPath := GetConfigPath() + if foundPath != xdgPath { + t.Errorf("GetConfigPath() = %v, want %v (XDG should take precedence)", foundPath, xdgPath) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Domain != "https://xdg.example.com" { + t.Errorf("Domain = %v, want %v (XDG should take precedence)", cfg.Domain, "https://xdg.example.com") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..19671f8 --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + _ "embed" + "os" + "strings" + + "github.com/chenasraf/cospend-cli/cmd" + "github.com/spf13/cobra" +) + +//go:embed version.txt +var version string + +func main() { + rootCmd := &cobra.Command{ + Use: "cospend", + Short: "A CLI tool for Nextcloud Cospend", + Long: `cospend is a command-line interface for adding expenses to Nextcloud Cospend projects.`, + Version: strings.TrimSpace(version), + TraverseChildren: true, + } + + rootCmd.AddCommand(cmd.NewAddCommand()) + rootCmd.AddCommand(cmd.NewInitCommand()) + rootCmd.AddCommand(cmd.NewListCommand()) + rootCmd.AddCommand(cmd.NewDeleteCommand()) + rootCmd.AddCommand(cmd.NewProjectsCommand()) + + rootCmd.PersistentFlags().BoolVarP(&cmd.Debug, "debug", "d", false, "Enable debug output") + rootCmd.PersistentFlags().StringVarP(&cmd.ProjectID, "project", "p", "", "Project ID") + rootCmd.Flags().BoolP("version", "v", false, "Print version information") + rootCmd.SetVersionTemplate("{{.Version}}\n") + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.0