feat: initial commit

This commit is contained in:
2026-02-03 11:03:58 +02:00
commit 1f92ddfa1a
29 changed files with 4478 additions and 0 deletions

13
.github/FUNDING.yml vendored Executable file
View File

@@ -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&currency_code=ILS&source=url"

12
.github/workflows/manual-homebrew-release.yml vendored Executable file
View File

@@ -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 }}

20
.github/workflows/release.yml vendored Executable file
View File

@@ -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 }}

44
.github/workflows/test.yml vendored Executable file
View File

@@ -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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
.env.keys
.envrc
cospend-cli
cospend

52
1 Normal file
View File

@@ -0,0 +1,52 @@
[DEBUG] Request: GET https://spider.casraf.dev/ocs/v2.php/apps/cospend/api/v1/projects/home-2026
[DEBUG] Request: GET https://spider.casraf.dev/ocs/v2.php/apps/cospend/api/v1/projects/home-2026
[DEBUG] Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic casraf:***
[DEBUG] Headers: OCS-APIRequest=true, Accept=application/json, Auth=Basic casraf:***
[DEBUG] Response: 200 200 OK
[DEBUG] Response: 200 200 OK
[DEBUG] Response body: {"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}}}
[DEBUG] Response body: {"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}}}
[DEBUG] Project data: {"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}
Error: fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
[DEBUG] Project data: {"active_members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"members":[{"id":9,"name":"Chen Asraf","weight":1,"activated":true,"lastchanged":1767351568,"userid":"casraf","color":{"r":110,"g":166,"b":143}},{"id":11,"name":"Common","weight":1,"activated":true,"lastchanged":1767351577,"userid":null,"color":{"r":188,"g":92,"b":145}},{"id":13,"name":"Dekel Eden-Tsalik","weight":1,"activated":true,"lastchanged":1769772163,"userid":"dekeltsa","color":{"r":36,"g":142,"b":181}}],"balance":{"9":4357.2,"11":-4357.2,"13":0},"nb_bills":34,"total_spent":11292.17,"nb_trashbin_bills":0,"shares":[{"id":4,"projectid":"home-2026","userid":"dekeltsa","type":"u","accesslevel":4,"manuallyAdded":true,"label":null,"password":null,"userCloudId":null,"state":null,"name":"Dekel Eden-Tsalik"}],"currencies":[{"id":9,"name":"$","exchange_rate":3.0989945931841},{"id":11,"name":"\u20ac","exchange_rate":3.6588141198027},{"id":12,"name":"\u00a3","exchange_rate":4.2384035900296}],"categories":{"37":{"id":37,"projectid":"home-2026","name":"Grocery","color":"#ffaa00","icon":"\ud83d\uded2","order":0},"39":{"id":39,"projectid":"home-2026","name":"Rent","color":"#da8733","icon":"\ud83c\udfe0","order":0},"40":{"id":40,"projectid":"home-2026","name":"Bill","color":"#4aa6b0","icon":"\ud83c\udf29","order":0},"42":{"id":42,"projectid":"home-2026","name":"Health","color":"#bf090c","icon":"\ud83d\udc9a","order":0},"43":{"id":43,"projectid":"home-2026","name":"Shopping","color":"#e167d1","icon":"\ud83d\udecd","order":0},"46":{"id":46,"projectid":"home-2026","name":"Transport","color":"#6f2ee1","icon":"\ud83d\ude8c","order":0},"45":{"id":45,"projectid":"home-2026","name":"Pets","color":"#5de1a3","icon":"\ud83d\udc08","order":0},"41":{"id":41,"projectid":"home-2026","name":"Subscriptions","color":"#0055ff","icon":"\ud83d\udcb3","order":0},"38":{"id":38,"projectid":"home-2026","name":"Going Out","color":"#aa55ff","icon":"\ud83c\udf89","order":0},"44":{"id":44,"projectid":"home-2026","name":"Food","color":"#d0d5e1","icon":"\ud83c\udf74","order":0},"48":{"id":48,"projectid":"home-2026","name":"Cleaning","color":"#F8A4E0","icon":"\ud83e\uddf9","order":10},"60":{"id":60,"projectid":"home-2026","name":"Tufting","color":"#00740E","icon":"\ud83e\udea1","order":11}},"paymentmodes":{"16":{"id":16,"projectid":"home-2026","name":"Credit card","color":"#FF7F50","icon":"\ud83d\udcb3","order":0,"old_id":"c"},"17":{"id":17,"projectid":"home-2026","name":"Cash","color":"#556B2F","icon":"\ud83d\udcb5","order":0,"old_id":"b"},"18":{"id":18,"projectid":"home-2026","name":"Check","color":"#A9A9A9","icon":"\ud83c\udfab","order":0,"old_id":"f"},"19":{"id":19,"projectid":"home-2026","name":"Transfer","color":"#00CED1","icon":"\u21c4","order":0,"old_id":"t"},"20":{"id":20,"projectid":"home-2026","name":"Online service","color":"#9932CC","icon":"\ud83c\udf0e","order":0,"old_id":"o"}},"id":"home-2026","userid":"casraf","name":"Home 2026","email":"","autoexport":"m","lastchanged":1770107790,"deletiondisabled":false,"categorysort":"a","paymentmodesort":"a","currencyname":"\u20aa","archived_ts":null,"myaccesslevel":4}
Error: fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
Usage:
cospend-cli list [flags]
Aliases:
list, ls
Flags:
-a, --amount string Filter by amount (e.g., 50, >30, <=100, =25)
-b, --by string Filter by paying member username
-c, --category string Filter by category
-f, --for stringArray Filter by owed member username (repeatable)
-h, --help help for list
-m, --method string Filter by payment method
-n, --name string Filter by name (case-insensitive, contains)
-p, --project string Project ID (required)
Global Flags:
-d, --debug Enable debug output
fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category
Usage:
cospend-cli list [flags]
Aliases:
list, ls
Flags:
-a, --amount string Filter by amount (e.g., 50, >30, <=100, =25)
-b, --by string Filter by paying member username
-c, --category string Filter by category
-f, --for stringArray Filter by owed member username (repeatable)
-h, --help help for list
-m, --method string Filter by payment method
-n, --name string Filter by name (case-insensitive, contains)
-p, --project string Project ID (required)
Global Flags:
-d, --debug Enable debug output
fetching project: decoding project data: json: cannot unmarshal object into Go struct field Project.categories of type []api.Category

21
LICENSE Normal file
View File

@@ -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.

58
Makefile Executable file
View File

@@ -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

282
README.md Normal file
View File

@@ -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 <name> <amount> [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 <bill_id> [flags]
cospend rm <bill_id> [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!
<a href='https://ko-fi.com/casraf' target='_blank'>
<img height='36' style='border:0px;height:36px;'
src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3'
alt='Buy Me a Coffee at ko-fi.com' />
</a>
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).

161
cmd/add.go Normal file
View File

@@ -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 <name> <amount>",
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
}

475
cmd/add_test.go Normal file
View File

@@ -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 <name> <amount>" {
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")
}
}

7
cmd/common.go Normal file
View File

@@ -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

65
cmd/delete.go Normal file
View File

@@ -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 <bill_id>",
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
}

157
cmd/delete_test.go Normal file
View File

@@ -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 <bill_id>" {
t.Errorf("Use = %v, want %v", cmd.Use, "delete <bill_id>")
}
}
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 = ""
}

149
cmd/init.go Normal file
View File

@@ -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
}

348
cmd/list.go Normal file
View File

@@ -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))
}

286
cmd/list_test.go Normal file
View File

@@ -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 = ""
}

77
cmd/projects.go Normal file
View File

@@ -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
}

94
cmd/table.go Normal file
View File

@@ -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)
}

17
go.mod Normal file
View File

@@ -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
)

29
go.sum Normal file
View File

@@ -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=

439
internal/api/client.go Normal file
View File

@@ -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
}

436
internal/api/client_test.go Normal file
View File

@@ -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
}

293
internal/cache/cache.go vendored Normal file
View File

@@ -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)
}

292
internal/cache/cache_test.go vendored Normal file
View File

@@ -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")
}
}

184
internal/config/config.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

38
main.go Normal file
View File

@@ -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)
}
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.0.0