feat: project info in info command

This commit is contained in:
2026-02-09 23:31:20 +02:00
parent b545c803bb
commit fbb96057e5
8 changed files with 208 additions and 27 deletions

View File

@@ -41,6 +41,7 @@ func resetFlags() {
convertTo = ""
paymentMethod = ""
comment = ""
infoCached = false
}
func setupTestEnv(t *testing.T, domain string) func() {

View File

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

View File

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

View File

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

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

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

View File

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

View File

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