From 59cb401946e143e59ddd8b3c35f0c65d3dad4fcb Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 23 Mar 2026 22:37:22 +0200 Subject: [PATCH] feat: add default-project setting --- README.md | 38 ++++++ cmd/config.go | 114 ++++++++++++++++ cmd/config_test.go | 276 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 35 ++++- main.go | 10 ++ 5 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/config_test.go diff --git a/README.md b/README.md index b2110be..e3c1513 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ add and list expenses directly from your terminal without opening the web interf - **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 +- **Default project** - set once with `config set`, no need to pass `-p` every time - **Global project flag** - set `-p` before the command for easy shell aliases - **Secure browser login** - OAuth-style authentication with 2FA support - Cross-platform support: **macOS**, **Linux**, and **Windows** @@ -152,6 +153,18 @@ cospend-home add "Groceries" 25.50 cospend-home list ``` +You can also set a default project so you don't need `-p` at all: + +```bash +cospend config set default-project myproject + +# Now these work without -p: +cospend add "Groceries" 25.50 +cospend list +``` + +The `-p` flag always takes precedence over the default project. + --- ### Adding Expenses @@ -370,6 +383,31 @@ cospend projects --all --- +### Managing Configuration + +```bash +cospend config set +cospend config get +``` + +#### Supported Keys + +| Key | Description | +| ------------------- | -------------------------------------------------- | +| `default-project` | Default project ID (used when `-p` is not specified) | + +#### Examples + +```bash +# Set a default project +cospend config set default-project myproject + +# View the current default project +cospend config get default-project +``` + +--- + ## Caching Project data (members, categories, payment methods, currencies) is cached locally to avoid repeated diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..eba602a --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "fmt" + + "github.com/chenasraf/cospend-cli/internal/config" + "github.com/spf13/cobra" +) + +// NewConfigCommand creates the config command with subcommands +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", + Long: `View and modify cospend-cli configuration settings.`, + } + + cmd.AddCommand(newConfigSetCommand()) + cmd.AddCommand(newConfigGetCommand()) + + return cmd +} + +func newConfigSetCommand() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Long: `Set a configuration value. + +Supported keys: + default-project Default project ID (used when -p is not specified) + +Examples: + cospend config set default-project myproject`, + Args: cobra.ExactArgs(2), + RunE: runConfigSet, + } +} + +func newConfigGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Long: `Get a configuration value. + +Supported keys: + default-project Default project ID (used when -p is not specified) + +Examples: + cospend config get default-project`, + Args: cobra.ExactArgs(1), + RunE: runConfigGet, + } +} + +func runConfigSet(cmd *cobra.Command, args []string) error { + key := args[0] + value := args[1] + + cmd.SilenceUsage = true + + configPath := config.GetConfigPath() + if configPath == "" { + return fmt.Errorf("no config file found (run 'cospend init' first)") + } + + cfg, err := config.LoadFromFile(configPath) + if err != nil { + return fmt.Errorf("reading config: %w", err) + } + + switch key { + case "default-project": + cfg.DefaultProject = value + default: + return fmt.Errorf("unknown config key: %s", key) + } + + if _, err := config.SaveToPath(cfg, configPath); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", key, value) + return nil +} + +func runConfigGet(cmd *cobra.Command, args []string) error { + key := args[0] + + cmd.SilenceUsage = true + + configPath := config.GetConfigPath() + if configPath == "" { + return fmt.Errorf("no config file found (run 'cospend init' first)") + } + + cfg, err := config.LoadFromFile(configPath) + if err != nil { + return fmt.Errorf("reading config: %w", err) + } + + switch key { + case "default-project": + if cfg.DefaultProject == "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "(not set)") + } else { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cfg.DefaultProject) + } + default: + return fmt.Errorf("unknown config key: %s", key) + } + + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..b0eb254 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,276 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestNewConfigCommand(t *testing.T) { + cmd := NewConfigCommand() + + if cmd.Use != "config" { + t.Errorf("Wrong Use: %s", cmd.Use) + } + + // Should have set and get subcommands + subCmds := cmd.Commands() + names := make(map[string]bool) + for _, c := range subCmds { + names[c.Name()] = true + } + if !names["set"] { + t.Error("Missing 'set' subcommand") + } + if !names["get"] { + t.Error("Missing 'get' subcommand") + } +} + +func TestConfigSetDefaultProject(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + // Create initial config file + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + initialConfig := `{"domain": "https://example.com", "user": "alice", "password": "pass"}` + if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"set", "default-project", "myproject"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("Set default-project = myproject")) { + t.Errorf("Expected success message, got: %s", stdout.String()) + } + + // Verify it was saved + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + if !bytes.Contains(data, []byte("myproject")) { + t.Errorf("Config file should contain 'myproject', got: %s", string(data)) + } +} + +func TestConfigGetDefaultProject(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + configContent := `{"domain": "https://example.com", "user": "alice", "password": "pass", "default_project": "myproject"}` + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"get", "default-project"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("myproject")) { + t.Errorf("Expected 'myproject', got: %s", stdout.String()) + } +} + +func TestConfigGetDefaultProjectNotSet(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + configContent := `{"domain": "https://example.com", "user": "alice", "password": "pass"}` + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"get", "default-project"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("(not set)")) { + t.Errorf("Expected '(not set)', got: %s", stdout.String()) + } +} + +func TestConfigSetUnknownKey(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"x","user":"u","password":"p"}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + cmd.SetArgs([]string{"set", "unknown-key", "value"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error for unknown key") + } +} + +func TestConfigNoConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + cmd := NewConfigCommand() + cmd.SetArgs([]string{"get", "default-project"}) + + err := cmd.Execute() + if err == nil { + t.Error("Expected error when no config file exists") + } +} + +func TestConfigSetPreservesExistingFields(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + initialConfig := `{"domain": "https://example.com", "user": "alice", "password": "secret123"}` + if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"set", "default-project", "myproject"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify existing fields are preserved + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + content := string(data) + if !bytes.Contains(data, []byte("https://example.com")) { + t.Errorf("Domain should be preserved, got: %s", content) + } + if !bytes.Contains(data, []byte("alice")) { + t.Errorf("User should be preserved, got: %s", content) + } + if !bytes.Contains(data, []byte("secret123")) { + t.Errorf("Password should be preserved, got: %s", content) + } +} + +func TestConfigSetYAML(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.yaml") + initialConfig := "domain: https://example.com\nuser: alice\npassword: pass\n" + if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"set", "default-project", "yamlproject"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + if !bytes.Contains(data, []byte("yamlproject")) { + t.Errorf("Config should contain 'yamlproject', got: %s", string(data)) + } +} + +func TestConfigSetTOML(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.toml") + initialConfig := "domain = \"https://example.com\"\nuser = \"alice\"\npassword = \"pass\"\n" + if err := os.WriteFile(configPath, []byte(initialConfig), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"set", "default-project", "tomlproject"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + if !bytes.Contains(data, []byte("tomlproject")) { + t.Errorf("Config should contain 'tomlproject', got: %s", string(data)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 25814ea..c0ba746 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,9 +27,10 @@ func NormalizeURL(url string) string { // 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"` + Domain string `json:"domain" yaml:"domain" toml:"domain"` + User string `json:"user" yaml:"user" toml:"user"` + Password string `json:"password" yaml:"password" toml:"password"` + DefaultProject string `json:"default_project,omitempty" yaml:"default_project,omitempty" toml:"default_project,omitempty"` } // configExtensions lists supported config file extensions in order of preference @@ -205,11 +206,39 @@ func SaveToPath(cfg *Config, path string) (string, error) { return path, nil } +// LoadRaw reads configuration without validating required fields. +// Useful for reading optional settings like DefaultProject. +func LoadRaw() *Config { + var cfg Config + + if configPath := GetConfigPath(); configPath != "" { + fileCfg, err := LoadFromFile(configPath) + if err == nil { + cfg = *fileCfg + } + } + + 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 + } + + return &cfg +} + // 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) + if cfg.DefaultProject != "" { + content += fmt.Sprintf("default_project = %q\n", cfg.DefaultProject) + } return []byte(content), nil } diff --git a/main.go b/main.go index 512fc8b..23faf55 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/chenasraf/cospend-cli/cmd" + "github.com/chenasraf/cospend-cli/internal/config" "github.com/spf13/cobra" ) @@ -19,6 +20,14 @@ func main() { Long: `cospend is a command-line interface for adding expenses to Nextcloud Cospend projects.`, Version: strings.TrimSpace(version), TraverseChildren: true, + PersistentPreRun: func(c *cobra.Command, args []string) { + // Apply default project from config if -p not explicitly set + if cmd.ProjectID == "" { + if raw := config.LoadRaw(); raw.DefaultProject != "" { + cmd.ProjectID = raw.DefaultProject + } + } + }, } rootCmd.AddCommand(cmd.NewAddCommand()) @@ -28,6 +37,7 @@ func main() { rootCmd.AddCommand(cmd.NewEditCommand()) rootCmd.AddCommand(cmd.NewProjectsCommand()) rootCmd.AddCommand(cmd.NewInfoCommand()) + rootCmd.AddCommand(cmd.NewConfigCommand()) rootCmd.PersistentFlags().BoolVarP(&cmd.Debug, "debug", "D", false, "Enable debug output") rootCmd.PersistentFlags().StringVarP(&cmd.ProjectID, "project", "p", "", "Project ID")