mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: project info in info command
This commit is contained in:
@@ -41,6 +41,7 @@ func resetFlags() {
|
||||
convertTo = ""
|
||||
paymentMethod = ""
|
||||
comment = ""
|
||||
infoCached = false
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, domain string) func() {
|
||||
|
||||
75
cmd/info.go
75
cmd/info.go
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/cache"
|
||||
@@ -9,14 +10,20 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var infoCached bool
|
||||
|
||||
// NewInfoCommand creates the info command
|
||||
func NewInfoCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Show account and configuration info",
|
||||
Long: `Show the configured Nextcloud server, authenticated user, and user locale/language.`,
|
||||
Long: `Show the configured Nextcloud server, authenticated user, and user locale/language. When --project is set, also show project details.`,
|
||||
RunE: runInfo,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&infoCached, "cached", false, "Use cached data instead of fetching fresh from API")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runInfo(cmd *cobra.Command, _ []string) error {
|
||||
@@ -31,8 +38,11 @@ func runInfo(cmd *cobra.Command, _ []string) error {
|
||||
client.Debug = Debug
|
||||
client.DebugWriter = cmd.ErrOrStderr()
|
||||
|
||||
userInfo, ok := cache.LoadUserInfo()
|
||||
if !ok {
|
||||
var userInfo *api.UserInfo
|
||||
if infoCached {
|
||||
userInfo, _ = cache.LoadUserInfo()
|
||||
}
|
||||
if userInfo == nil {
|
||||
userInfo, err = client.GetUserInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching user info: %w", err)
|
||||
@@ -48,5 +58,62 @@ func runInfo(cmd *cobra.Command, _ []string) error {
|
||||
_, _ = fmt.Fprintf(out, "Locale: %s\n", userInfo.Locale)
|
||||
_, _ = fmt.Fprintf(out, "Language: %s\n", userInfo.Language)
|
||||
|
||||
if ProjectID != "" {
|
||||
var project *api.Project
|
||||
if infoCached {
|
||||
project, _ = cache.Load(ProjectID)
|
||||
}
|
||||
if project == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nProject: %s\n", project.Name)
|
||||
_, _ = fmt.Fprintf(out, "Currency: %s\n", project.CurrencyName)
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
membersTable := NewTable("ID", "Name", "UserID")
|
||||
for _, m := range project.Members {
|
||||
membersTable.AddRow(strconv.Itoa(m.ID), m.Name, m.UserID)
|
||||
}
|
||||
_, _ = fmt.Fprintln(out, "Members:")
|
||||
membersTable.Render(out)
|
||||
|
||||
if len(project.Categories) > 0 {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
catTable := NewTable("ID", "Icon", "Name")
|
||||
for _, c := range project.Categories {
|
||||
catTable.AddRow(strconv.Itoa(c.ID), c.Icon, c.Name)
|
||||
}
|
||||
_, _ = fmt.Fprintln(out, "Categories:")
|
||||
catTable.Render(out)
|
||||
}
|
||||
|
||||
if len(project.PaymentModes) > 0 {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
pmTable := NewTable("ID", "Icon", "Name")
|
||||
for _, pm := range project.PaymentModes {
|
||||
pmTable.AddRow(strconv.Itoa(pm.ID), pm.Icon, pm.Name)
|
||||
}
|
||||
_, _ = fmt.Fprintln(out, "Payment Modes:")
|
||||
pmTable.Render(out)
|
||||
}
|
||||
|
||||
if len(project.Currencies) > 0 {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
currTable := NewTable("ID", "Name", "Exchange Rate")
|
||||
for _, cur := range project.Currencies {
|
||||
currTable.AddRow(strconv.Itoa(cur.ID), cur.Name, strconv.FormatFloat(cur.ExchangeRate, 'f', -1, 64))
|
||||
}
|
||||
_, _ = fmt.Fprintln(out, "Currencies:")
|
||||
currTable.Render(out)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
)
|
||||
|
||||
func TestInfoCommand(t *testing.T) {
|
||||
@@ -103,3 +105,77 @@ func TestInfoCommandAPIError(t *testing.T) {
|
||||
t.Error("Expected error from API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoCommandWithProject(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
CurrencyName: "EUR",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
{ID: 2, Name: "Bob", UserID: "bob"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 5, Name: "Food", Icon: "\U0001F354", Color: "#ff0000"},
|
||||
{ID: 12, Name: "Transport", Icon: "\U0001F697", Color: "#00ff00"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 3, Name: "Credit Card", Icon: "\U0001F4B3", Color: "#0000ff"},
|
||||
},
|
||||
Currencies: []api.Currency{
|
||||
{ID: 1, Name: "USD", ExchangeRate: 1.1},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{
|
||||
"locale": "en_US",
|
||||
"language": "en",
|
||||
}))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewInfoCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
|
||||
expected := []string{
|
||||
"Project: Test Project",
|
||||
"Currency: EUR",
|
||||
"Members:",
|
||||
"Alice",
|
||||
"Bob",
|
||||
"Categories:",
|
||||
"Food",
|
||||
"Transport",
|
||||
"Payment Modes:",
|
||||
"Credit Card",
|
||||
"Currencies:",
|
||||
"USD",
|
||||
"1.1",
|
||||
}
|
||||
for _, exp := range expected {
|
||||
if !strings.Contains(output, exp) {
|
||||
t.Errorf("Output missing %q, got:\n%s", exp, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
cmd/table.go
11
cmd/table.go
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// Table border characters
|
||||
@@ -32,7 +34,7 @@ type Table struct {
|
||||
func NewTable(headers ...string) *Table {
|
||||
colWidths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
colWidths[i] = len(h)
|
||||
colWidths[i] = runewidth.StringWidth(h)
|
||||
}
|
||||
return &Table{
|
||||
headers: headers,
|
||||
@@ -55,8 +57,8 @@ func (t *Table) AddRow(values ...string) {
|
||||
|
||||
// Update column widths
|
||||
for i, v := range values {
|
||||
if len(v) > t.colWidths[i] {
|
||||
t.colWidths[i] = len(v)
|
||||
if w := runewidth.StringWidth(v); w > t.colWidths[i] {
|
||||
t.colWidths[i] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +90,8 @@ func (t *Table) printBorder(w io.Writer, left, mid, right string) {
|
||||
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)
|
||||
padded := runewidth.FillRight(val, t.colWidths[i])
|
||||
_, _ = fmt.Fprintf(w, " %s %s", padded, borderVertical)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -5,14 +5,16 @@ go 1.24.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adrg/xdg v0.4.0
|
||||
github.com/mattn/go-runewidth v0.0.19
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -2,11 +2,15 @@ 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/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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=
|
||||
|
||||
@@ -32,14 +32,18 @@ type Member struct {
|
||||
|
||||
// Category represents a bill category
|
||||
type Category struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// PaymentMode represents a payment method
|
||||
type PaymentMode struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// Currency represents a currency
|
||||
|
||||
@@ -570,28 +570,52 @@ func TestProjectUnmarshalObjectKeyed(t *testing.T) {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
// Verify categories have correct IDs from map keys
|
||||
catByName := make(map[string]int)
|
||||
// Verify categories have correct IDs, icons, and colors from map keys
|
||||
catByName := make(map[string]Category)
|
||||
for _, c := range project.Categories {
|
||||
catByName[c.Name] = c.ID
|
||||
catByName[c.Name] = c
|
||||
}
|
||||
if catByName["Food"] != 5 {
|
||||
t.Errorf("Category Food ID = %d, want 5", catByName["Food"])
|
||||
if catByName["Food"].ID != 5 {
|
||||
t.Errorf("Category Food ID = %d, want 5", catByName["Food"].ID)
|
||||
}
|
||||
if catByName["Transport"] != 12 {
|
||||
t.Errorf("Category Transport ID = %d, want 12", catByName["Transport"])
|
||||
if catByName["Food"].Icon != "\U0001F354" {
|
||||
t.Errorf("Category Food Icon = %q, want %q", catByName["Food"].Icon, "\U0001F354")
|
||||
}
|
||||
if catByName["Food"].Color != "#ff0000" {
|
||||
t.Errorf("Category Food Color = %q, want %q", catByName["Food"].Color, "#ff0000")
|
||||
}
|
||||
if catByName["Transport"].ID != 12 {
|
||||
t.Errorf("Category Transport ID = %d, want 12", catByName["Transport"].ID)
|
||||
}
|
||||
if catByName["Transport"].Icon != "\U0001F697" {
|
||||
t.Errorf("Category Transport Icon = %q, want %q", catByName["Transport"].Icon, "\U0001F697")
|
||||
}
|
||||
if catByName["Transport"].Color != "#00ff00" {
|
||||
t.Errorf("Category Transport Color = %q, want %q", catByName["Transport"].Color, "#00ff00")
|
||||
}
|
||||
|
||||
// Verify payment modes have correct IDs from map keys
|
||||
pmByName := make(map[string]int)
|
||||
// Verify payment modes have correct IDs, icons, and colors from map keys
|
||||
pmByName := make(map[string]PaymentMode)
|
||||
for _, pm := range project.PaymentModes {
|
||||
pmByName[pm.Name] = pm.ID
|
||||
pmByName[pm.Name] = pm
|
||||
}
|
||||
if pmByName["Credit Card"] != 3 {
|
||||
t.Errorf("PaymentMode Credit Card ID = %d, want 3", pmByName["Credit Card"])
|
||||
if pmByName["Credit Card"].ID != 3 {
|
||||
t.Errorf("PaymentMode Credit Card ID = %d, want 3", pmByName["Credit Card"].ID)
|
||||
}
|
||||
if pmByName["Cash"] != 7 {
|
||||
t.Errorf("PaymentMode Cash ID = %d, want 7", pmByName["Cash"])
|
||||
if pmByName["Credit Card"].Icon != "\U0001F4B3" {
|
||||
t.Errorf("PaymentMode Credit Card Icon = %q, want %q", pmByName["Credit Card"].Icon, "\U0001F4B3")
|
||||
}
|
||||
if pmByName["Credit Card"].Color != "#0000ff" {
|
||||
t.Errorf("PaymentMode Credit Card Color = %q, want %q", pmByName["Credit Card"].Color, "#0000ff")
|
||||
}
|
||||
if pmByName["Cash"].ID != 7 {
|
||||
t.Errorf("PaymentMode Cash ID = %d, want 7", pmByName["Cash"].ID)
|
||||
}
|
||||
if pmByName["Cash"].Icon != "\U0001F4B5" {
|
||||
t.Errorf("PaymentMode Cash Icon = %q, want %q", pmByName["Cash"].Icon, "\U0001F4B5")
|
||||
}
|
||||
if pmByName["Cash"].Color != "#00ff00" {
|
||||
t.Errorf("PaymentMode Cash Color = %q, want %q", pmByName["Cash"].Color, "#00ff00")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user