diff --git a/cmd/pho/main.go b/cmd/pho/main.go index ab7085c..a7a142a 100644 --- a/cmd/pho/main.go +++ b/cmd/pho/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "pho/internal/app" + "pho/internal/config" "syscall" "time" ) @@ -14,18 +15,17 @@ func main() { os.Exit(run()) } -// getTimeout returns the configured timeout from environment variable or default. +// getTimeout returns the configured timeout from config or environment variable. func getTimeout() time.Duration { - const defaultTimeout = 60 * time.Second - - if timeoutStr := os.Getenv("PHO_TIMEOUT"); timeoutStr != "" { - if timeout, err := time.ParseDuration(timeoutStr); err == nil { - return timeout - } - // If parsing fails, fall back to default + // Load config to get timeout + cfg, err := config.Load() + if err != nil { + // Fallback to default if config loading fails + const defaultTimeoutSeconds = 60 + return defaultTimeoutSeconds * time.Second } - return defaultTimeout + return cfg.GetTimeoutDuration() } func run() int { diff --git a/go.mod b/go.mod index 41733f3..258c0c4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 toolchain go1.24.3 require ( + github.com/BurntSushi/toml v1.5.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.3.8 go.mongodb.org/mongo-driver v1.17.4 // latest diff --git a/go.sum b/go.sum index 84606c7..ffa68fd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= diff --git a/internal/app/app.go b/internal/app/app.go index d26c387..e652f83 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "pho/internal/config" "pho/internal/logging" "pho/internal/pho" "pho/internal/render" @@ -15,10 +16,6 @@ import ( "github.com/urfave/cli/v3" ) -const ( - defaultDocumentLimit = 10000 // Default limit for document retrieval -) - var ( // Version is injected via ldflags during build. Version = "dev" @@ -26,12 +23,21 @@ var ( // App represents the CLI application. type App struct { - cmd *cli.Command + cmd *cli.Command + config *config.Config } // New creates a new CLI application. func New() *App { + // Load configuration + cfg, err := config.Load() + if err != nil { + // Use default config if loading fails + cfg = config.NewDefault() + } + return &App{ + config: cfg, cmd: &cli.Command{ Name: "pho", Usage: "MongoDB document editor - query, edit, and apply changes interactively", @@ -102,6 +108,51 @@ This will execute the actual database operations.`, Action: applyAction, Flags: getConnectionFlags(), }, + { + Name: "config", + Aliases: []string{"cfg"}, + Usage: "Manage pho configuration", + Description: `Manage pho configuration settings. +Configuration is stored in ~/.config/pho/config.toml and can be overridden by environment variables.`, + Commands: []*cli.Command{ + { + Name: "get", + Aliases: []string{"g"}, + Usage: "Get configuration value", + Description: `Get a configuration value by key. +Examples: + pho config get mongo.uri + pho config get app.editor + pho config get output.format`, + Action: configGetAction, + }, + { + Name: "set", + Aliases: []string{"s"}, + Usage: "Set configuration value", + Description: `Set a configuration value by key. +Examples: + pho config set mongo.uri mongodb://localhost:27017 + pho config set app.editor nano + pho config set output.format json`, + Action: configSetAction, + }, + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "List configuration values [section]", + Description: `List all current configuration values, or values for a specific section. + +Examples: + pho config list # List all configuration values + pho config list mongo # List only MongoDB configuration + pho config list app # List only Application configuration + +Available sections: mongo, database, query, app, output, directories`, + Action: configListAction, + }, + }, + }, }, Flags: getCommonFlags(), Action: defaultAction, // Default action when no subcommand is specified @@ -118,74 +169,104 @@ func (a *App) Run(ctx context.Context, args []string) error { // getRenderFlags returns common flags used for output rendering. func getRenderFlags() []cli.Flag { + // Load config to get defaults + cfg, _ := config.Load() + if cfg == nil { + cfg = config.NewDefault() + } + return []cli.Flag{ &cli.StringFlag{ Name: "extjson-mode", Aliases: []string{"m"}, - Value: "canonical", + Value: cfg.Mongo.ExtJSONMode, Usage: "ExtJSON output mode: canonical, relaxed, or shell", + Sources: cli.EnvVars("PHO_EXTJSON_MODE"), }, &cli.BoolFlag{ Name: "compact", Aliases: []string{"C"}, + Value: cfg.Output.Compact, Usage: "Use compact JSON output (no indentation)", + Sources: cli.EnvVars("PHO_OUTPUT_COMPACT"), }, &cli.BoolFlag{ Name: "line-numbers", Aliases: []string{"n"}, - Value: true, + Value: cfg.Output.LineNumbers, Usage: "Show line numbers in output", + Sources: cli.EnvVars("PHO_OUTPUT_LINE_NUMBERS"), }, } } // getVerbosityFlags returns common flags used for verbosity control. func getVerbosityFlags() []cli.Flag { + // Load config to get defaults + cfg, _ := config.Load() + if cfg == nil { + cfg = config.NewDefault() + } + return []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, + Value: cfg.Output.Verbose, Usage: "Enable verbose output with detailed progress information", + Sources: cli.EnvVars("PHO_OUTPUT_VERBOSE"), }, &cli.BoolFlag{ Name: "quiet", Aliases: []string{"Q"}, + Value: cfg.Output.Quiet, Usage: "Suppress all non-essential output (quiet mode)", + Sources: cli.EnvVars("PHO_OUTPUT_QUIET"), }, } } // getConnectionFlags returns flags for MongoDB connection. func getConnectionFlags() []cli.Flag { + // Load config to get defaults + cfg, _ := config.Load() + if cfg == nil { + cfg = config.NewDefault() + } + return []cli.Flag{ &cli.StringFlag{ Name: "uri", Aliases: []string{"u"}, - Value: "mongodb://localhost:27017", + Value: cfg.Mongo.URI, Usage: "MongoDB URI Connection String", Sources: cli.EnvVars("MONGODB_URI"), }, &cli.StringFlag{ Name: "host", Aliases: []string{"H"}, + Value: cfg.Mongo.Host, Usage: "MongoDB hostname (alternative to --uri)", Sources: cli.EnvVars("MONGODB_HOST"), }, &cli.StringFlag{ Name: "port", Aliases: []string{"P"}, + Value: cfg.Mongo.Port, Usage: "MongoDB port (used with --host)", Sources: cli.EnvVars("MONGODB_PORT"), }, &cli.StringFlag{ Name: "db", Aliases: []string{"d"}, + Value: cfg.Mongo.Database, Usage: "MongoDB database name", Sources: cli.EnvVars("MONGODB_DB"), }, &cli.StringFlag{ Name: "collection", Aliases: []string{"c"}, + Value: cfg.Mongo.Collection, Usage: "MongoDB collection name", Sources: cli.EnvVars("MONGODB_COLLECTION"), }, @@ -194,12 +275,19 @@ func getConnectionFlags() []cli.Flag { // getEditFlags returns flags for the edit command. func getEditFlags() []cli.Flag { + // Load config to get defaults + cfg, _ := config.Load() + if cfg == nil { + cfg = config.NewDefault() + } + editorFlags := []cli.Flag{ &cli.StringFlag{ Name: "editor", Aliases: []string{"e"}, - Value: "vim", + Value: cfg.App.Editor, Usage: "Editor command to use for editing documents", + Sources: cli.EnvVars("PHO_EDITOR"), }, } @@ -217,33 +305,46 @@ func getReviewFlags() []cli.Flag { // getCommonFlags returns all flags including connection and query flags. func getCommonFlags() []cli.Flag { + // Load config to get defaults + cfg, _ := config.Load() + if cfg == nil { + cfg = config.NewDefault() + } + connectionFlags := getConnectionFlags() queryFlags := []cli.Flag{ &cli.StringFlag{ Name: "query", Aliases: []string{"q"}, - Value: "{}", + Value: cfg.Query.Query, Usage: "MongoDB query as a JSON document", + Sources: cli.EnvVars("PHO_QUERY"), }, &cli.Int64Flag{ Name: "limit", Aliases: []string{"l"}, - Value: defaultDocumentLimit, + Value: cfg.Query.Limit, Usage: "Maximum number of documents to retrieve", + Sources: cli.EnvVars("PHO_LIMIT"), }, &cli.StringFlag{ - Name: "sort", - Usage: "Sort order for documents (JSON format, e.g. '{\"_id\": 1}')", + Name: "sort", + Value: cfg.Query.Sort, + Usage: "Sort order for documents (JSON format, e.g. '{\"_id\": 1}')", + Sources: cli.EnvVars("PHO_SORT"), }, &cli.StringFlag{ - Name: "projection", - Usage: "Projection for documents (JSON format, e.g. '{\"field\": 1}')", + Name: "projection", + Value: cfg.Query.Projection, + Usage: "Projection for documents (JSON format, e.g. '{\"field\": 1}')", + Sources: cli.EnvVars("PHO_PROJECTION"), }, &cli.StringFlag{ Name: "editor", Aliases: []string{"e"}, - Value: "vim", + Value: cfg.App.Editor, Usage: "Editor command to use for editing documents", + Sources: cli.EnvVars("PHO_EDITOR"), }, &cli.BoolFlag{ Name: "edit", @@ -763,6 +864,7 @@ func defaultAction(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(os.Stderr, " edit Edit documents from previous query\n") fmt.Fprintf(os.Stderr, " review Review changes made to documents\n") fmt.Fprintf(os.Stderr, " apply Apply changes to MongoDB\n") + fmt.Fprintf(os.Stderr, " config Manage pho configuration\n") fmt.Fprintf(os.Stderr, " version Show version information\n\n") fmt.Fprintf(os.Stderr, "Run 'pho --help' for detailed usage information.\n") return errors.New("database name is required") @@ -771,3 +873,150 @@ func defaultAction(ctx context.Context, cmd *cli.Command) error { // If database is specified, run the query action (default behavior) return queryAction(ctx, cmd) } + +// configGetAction handles the config get command. +func configGetAction(ctx context.Context, cmd *cli.Command) error { + _, _ = ctx, cmd + + args := cmd.Args() + if args.Len() == 0 { + fmt.Fprintf(os.Stderr, "Error: config key is required\n") + fmt.Fprintf(os.Stderr, "Usage: pho config get \n") + fmt.Fprintf(os.Stderr, "Example: pho config get mongo.uri\n") + return errors.New("config key is required") + } + + key := args.First() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + return err + } + + value, err := cfg.Get(key) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting config value: %v\n", err) + return err + } + + fmt.Fprintf(os.Stdout, "%v\n", value) + return nil +} + +// configSetAction handles the config set command. +func configSetAction(ctx context.Context, cmd *cli.Command) error { + _, _ = ctx, cmd + + args := cmd.Args() + if args.Len() < 2 { + fmt.Fprintf(os.Stderr, "Error: config key and value are required\n") + fmt.Fprintf(os.Stderr, "Usage: pho config set \n") + fmt.Fprintf(os.Stderr, "Example: pho config set mongo.uri mongodb://localhost:27017\n") + return errors.New("config key and value are required") + } + + key := args.Get(0) + value := args.Get(1) + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + return err + } + + if err := cfg.Set(key, value); err != nil { + fmt.Fprintf(os.Stderr, "Error setting config value: %v\n", err) + return err + } + + if err := cfg.Save(); err != nil { + fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) + return err + } + + fmt.Fprintf(os.Stdout, "Set %s = %s\n", key, value) + return nil +} + +// configListAction handles the config list command. +func configListAction(ctx context.Context, cmd *cli.Command) error { + _, _ = ctx, cmd + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + return err + } + + categories := map[string][]string{ + "MongoDB": { + "mongo.uri", "mongo.host", "mongo.port", "mongo.database", "mongo.collection", "mongo.extjson_mode", + }, + "Database": { + "database.type", + }, + "Query": { + "query.query", "query.limit", "query.sort", "query.projection", + }, + "Application": { + "app.editor", "app.timeout", + }, + "Output": { + "output.format", "output.line_numbers", "output.compact", "output.verbose", "output.quiet", + }, + "Directories": { + "directories.data_dir", "directories.config_dir", + }, + } + + // Map section shortcuts to full category names + sectionMap := map[string]string{ + "mongo": "MongoDB", + "database": "Database", + "query": "Query", + "app": "Application", + "output": "Output", + "directories": "Directories", + } + + // Check if specific section is requested + args := cmd.Args() + if args.Len() > 0 { + sectionName := args.Get(0) + if categoryName, exists := sectionMap[sectionName]; exists { + // List only the specific section + fmt.Fprintf(os.Stdout, "Configuration for %s:\n\n", categoryName) + printConfigSection(cfg, categoryName, categories[categoryName]) + return nil + } + fmt.Fprintf(os.Stderr, "Error: Unknown section '%s'\n", sectionName) + fmt.Fprintf(os.Stderr, "Available sections: mongo, database, query, app, output, directories\n") + return fmt.Errorf("unknown section: %s", sectionName) + } + + // List all sections + fmt.Fprintf(os.Stdout, "Current configuration:\n\n") + for category, categoryKeys := range categories { + printConfigSection(cfg, category, categoryKeys) + } + + return nil +} + +// printConfigSection prints a single configuration section. +func printConfigSection(cfg *config.Config, category string, categoryKeys []string) { + fmt.Fprintf(os.Stdout, "[%s]\n", category) + + for _, key := range categoryKeys { + if value, err := cfg.Get(key); err == nil { + // Show empty values as + displayValue := fmt.Sprintf("%v", value) + if displayValue == "" { + displayValue = "" + } + fmt.Fprintf(os.Stdout, " %-20s = %s\n", key, displayValue) + } + } + fmt.Fprintf(os.Stdout, "\n") +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1d043c6..fcb4bac 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -18,7 +18,7 @@ func TestNew(t *testing.T) { require.NotNil(t, cmd) assert.Equal(t, "pho", cmd.Name) assert.Equal(t, "MongoDB document editor - query, edit, and apply changes interactively", cmd.Usage) - assert.Len(t, cmd.Commands, 5) // version, query, edit, review, apply + assert.Len(t, cmd.Commands, 6) // version, query, edit, review, apply, config } func TestParseExtJSONMode(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d634a90 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,429 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/BurntSushi/toml" +) + +const ( + defaultLimit = 10000 // Default limit for document retrieval + defaultTimeoutSeconds = 60 // Default timeout in seconds +) + +// Config represents the application configuration. +type Config struct { + // Database-specific settings + Mongo MongoConfig `toml:"mongo"` + + // Database selection and generic query settings + Database DatabaseConfig `toml:"database"` + Query QueryConfig `toml:"query"` + + // Application settings + App AppConfig `toml:"app"` + + // Output/Display settings + Output OutputConfig `toml:"output"` + + // Directory settings + Directories DirectoriesConfig `toml:"directories"` +} + +// MongoConfig contains MongoDB-specific connection settings. +type MongoConfig struct { + URI string `toml:"uri"` + Host string `toml:"host"` + Port string `toml:"port"` + Database string `toml:"database"` + Collection string `toml:"collection"` + ExtJSONMode string `toml:"extjson_mode"` +} + +// DatabaseConfig contains database type selection. +type DatabaseConfig struct { + Type string `toml:"type"` // "mongodb", etc. +} + +// QueryConfig contains database-agnostic query settings. +type QueryConfig struct { + Query string `toml:"query"` + Limit int64 `toml:"limit"` + Sort string `toml:"sort"` + Projection string `toml:"projection"` +} + +// AppConfig contains application behavior settings. +type AppConfig struct { + Editor string `toml:"editor"` + Timeout string `toml:"timeout"` +} + +// OutputConfig contains output formatting settings. +type OutputConfig struct { + Format string `toml:"format"` // "json", "yaml", "csv", etc. + LineNumbers bool `toml:"line_numbers"` + Compact bool `toml:"compact"` + Verbose bool `toml:"verbose"` + Quiet bool `toml:"quiet"` +} + +// DirectoriesConfig contains directory path settings. +type DirectoriesConfig struct { + DataDir string `toml:"data_dir"` + ConfigDir string `toml:"config_dir"` +} + +// NewDefault returns a new Config with default values. +func NewDefault() *Config { + return &Config{ + Mongo: MongoConfig{ + URI: "mongodb://localhost:27017", + Host: "", + Port: "", + Database: "", + Collection: "", + ExtJSONMode: "canonical", + }, + Database: DatabaseConfig{ + Type: "mongodb", // Default to MongoDB + }, + Query: QueryConfig{ + Query: "{}", + Limit: defaultLimit, + Sort: "", + Projection: "", + }, + App: AppConfig{ + Editor: "vim", + Timeout: "60s", + }, + Output: OutputConfig{ + Format: "json", + LineNumbers: true, + Compact: false, + Verbose: false, + Quiet: false, + }, + Directories: DirectoriesConfig{ + DataDir: "", // Will be computed dynamically if empty + ConfigDir: "", // Will be computed dynamically if empty + }, + } +} + +// Load loads configuration from file and applies environment variable overrides. +func Load() (*Config, error) { + config := NewDefault() + + // Load from config file if it exists + configPath, err := getConfigFilePath() + if err != nil { + return nil, fmt.Errorf("could not get config file path: %w", err) + } + + if data, err := os.ReadFile(configPath); err == nil { + if err := toml.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("could not parse config file: %w", err) + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("could not read config file: %w", err) + } + + // Apply environment variable overrides + config.applyEnvironmentOverrides() + + return config, nil +} + +// Save saves the configuration to file. +func (c *Config) Save() error { + configPath, err := getConfigFilePath() + if err != nil { + return fmt.Errorf("could not get config file path: %w", err) + } + + // Ensure config directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0750); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + + data, err := toml.Marshal(c) + if err != nil { + return fmt.Errorf("could not marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0600); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + + return nil +} + +// applyEnvironmentOverrides applies environment variable overrides to the config. +func (c *Config) applyEnvironmentOverrides() { + // MongoDB settings + if val := os.Getenv("MONGODB_URI"); val != "" { + c.Mongo.URI = val + } + if val := os.Getenv("MONGODB_HOST"); val != "" { + c.Mongo.Host = val + } + if val := os.Getenv("MONGODB_PORT"); val != "" { + c.Mongo.Port = val + } + if val := os.Getenv("MONGODB_DB"); val != "" { + c.Mongo.Database = val + } + if val := os.Getenv("MONGODB_COLLECTION"); val != "" { + c.Mongo.Collection = val + } + + // Database type + if val := os.Getenv("PHO_DATABASE_TYPE"); val != "" { + c.Database.Type = val + } + + // Query settings + if val := os.Getenv("PHO_QUERY"); val != "" { + c.Query.Query = val + } + if val := os.Getenv("PHO_LIMIT"); val != "" { + if limit, err := strconv.ParseInt(val, 10, 64); err == nil { + c.Query.Limit = limit + } + } + if val := os.Getenv("PHO_SORT"); val != "" { + c.Query.Sort = val + } + if val := os.Getenv("PHO_PROJECTION"); val != "" { + c.Query.Projection = val + } + + // App settings + if val := os.Getenv("PHO_EDITOR"); val != "" { + c.App.Editor = val + } + if val := os.Getenv("PHO_TIMEOUT"); val != "" { + c.App.Timeout = val + } + + // MongoDB specific + if val := os.Getenv("PHO_EXTJSON_MODE"); val != "" { + c.Mongo.ExtJSONMode = val + } + + // Directory settings + if val := os.Getenv("PHO_DATA_DIR"); val != "" { + c.Directories.DataDir = val + } + if val := os.Getenv("PHO_CONFIG_DIR"); val != "" { + c.Directories.ConfigDir = val + } + + // Output settings + if val := os.Getenv("PHO_OUTPUT_COMPACT"); val != "" { + if compact, err := strconv.ParseBool(val); err == nil { + c.Output.Compact = compact + } + } + if val := os.Getenv("PHO_OUTPUT_LINE_NUMBERS"); val != "" { + if lineNumbers, err := strconv.ParseBool(val); err == nil { + c.Output.LineNumbers = lineNumbers + } + } + if val := os.Getenv("PHO_OUTPUT_VERBOSE"); val != "" { + if verbose, err := strconv.ParseBool(val); err == nil { + c.Output.Verbose = verbose + } + } + if val := os.Getenv("PHO_OUTPUT_QUIET"); val != "" { + if quiet, err := strconv.ParseBool(val); err == nil { + c.Output.Quiet = quiet + } + } +} + +// Set sets a configuration value by key path (e.g., "mongo.uri", "app.editor"). +func (c *Config) Set(key, value string) error { + switch key { + // MongoDB settings + case "mongo.uri": + c.Mongo.URI = value + case "mongo.host": + c.Mongo.Host = value + case "mongo.port": + c.Mongo.Port = value + case "mongo.database", "mongo.db": + c.Mongo.Database = value + case "mongo.collection": + c.Mongo.Collection = value + case "mongo.extjson_mode", "mongo.extjson-mode": + if value != "canonical" && value != "relaxed" && value != "shell" { + return fmt.Errorf("invalid extjson mode: %s (valid: canonical, relaxed, shell)", value) + } + c.Mongo.ExtJSONMode = value + + // Database selection + case "database.type": + if value != "mongodb" { + return fmt.Errorf("invalid database type: %s (valid: mongodb)", value) + } + c.Database.Type = value + + // Query settings + case "query.query": + c.Query.Query = value + case "query.limit": + limit, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid limit value: %w", err) + } + c.Query.Limit = limit + case "query.sort": + c.Query.Sort = value + case "query.projection": + c.Query.Projection = value + + // App settings + case "app.editor": + c.App.Editor = value + case "app.timeout": + // Validate the duration format + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("invalid timeout duration: %w", err) + } + c.App.Timeout = value + + // Output settings + case "output.format": + // Accept common format types + if value != "json" && value != "yaml" && value != "csv" { + return fmt.Errorf("invalid format: %s (valid: json, yaml, csv)", value) + } + c.Output.Format = value + case "output.line_numbers", "output.line-numbers": + val, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %w", err) + } + c.Output.LineNumbers = val + case "output.compact": + val, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %w", err) + } + c.Output.Compact = val + case "output.verbose": + val, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %w", err) + } + c.Output.Verbose = val + case "output.quiet": + val, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %w", err) + } + c.Output.Quiet = val + + // Directory settings + case "directories.data_dir", "directories.data-dir": + c.Directories.DataDir = value + case "directories.config_dir", "directories.config-dir": + c.Directories.ConfigDir = value + + default: + return fmt.Errorf("unknown config key: %s", key) + } + + return nil +} + +// Get gets a configuration value by key path. +func (c *Config) Get(key string) (interface{}, error) { + switch key { + // MongoDB settings + case "mongo.uri": + return c.Mongo.URI, nil + case "mongo.host": + return c.Mongo.Host, nil + case "mongo.port": + return c.Mongo.Port, nil + case "mongo.database", "mongo.db": + return c.Mongo.Database, nil + case "mongo.collection": + return c.Mongo.Collection, nil + case "mongo.extjson_mode", "mongo.extjson-mode": + return c.Mongo.ExtJSONMode, nil + + // Database selection + case "database.type": + return c.Database.Type, nil + + // Query settings + case "query.query": + return c.Query.Query, nil + case "query.limit": + return c.Query.Limit, nil + case "query.sort": + return c.Query.Sort, nil + case "query.projection": + return c.Query.Projection, nil + + // App settings + case "app.editor": + return c.App.Editor, nil + case "app.timeout": + return c.App.Timeout, nil + + // Output settings + case "output.format": + return c.Output.Format, nil + case "output.line_numbers", "output.line-numbers": + return c.Output.LineNumbers, nil + case "output.compact": + return c.Output.Compact, nil + case "output.verbose": + return c.Output.Verbose, nil + case "output.quiet": + return c.Output.Quiet, nil + + // Directory settings + case "directories.data_dir", "directories.data-dir": + return c.Directories.DataDir, nil + case "directories.config_dir", "directories.config-dir": + return c.Directories.ConfigDir, nil + + default: + return nil, fmt.Errorf("unknown config key: %s", key) + } +} + +// getConfigFilePath returns the path to the configuration file. +func getConfigFilePath() (string, error) { + configDir := os.Getenv("PHO_CONFIG_DIR") + if configDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not get user home directory: %w", err) + } + configDir = filepath.Join(homeDir, ".config", "pho") + } + + return filepath.Join(configDir, "config.toml"), nil +} + +// GetTimeoutDuration returns the timeout as a time.Duration. +func (c *Config) GetTimeoutDuration() time.Duration { + if timeout, err := time.ParseDuration(c.App.Timeout); err == nil { + return timeout + } + // Fallback to default if parsing fails + return defaultTimeoutSeconds * time.Second +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5a5e943 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,192 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "pho/internal/config" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDefault(t *testing.T) { + cfg := config.NewDefault() + + // MongoDB config + assert.Equal(t, "mongodb://localhost:27017", cfg.Mongo.URI) + assert.Equal(t, "canonical", cfg.Mongo.ExtJSONMode) + + // Database selection + assert.Equal(t, "mongodb", cfg.Database.Type) + + // Query config + assert.Equal(t, "{}", cfg.Query.Query) + assert.Equal(t, int64(10000), cfg.Query.Limit) + + // App config + assert.Equal(t, "vim", cfg.App.Editor) + assert.Equal(t, "60s", cfg.App.Timeout) + + // Output config + assert.Equal(t, "json", cfg.Output.Format) + assert.True(t, cfg.Output.LineNumbers) + assert.False(t, cfg.Output.Compact) + assert.False(t, cfg.Output.Verbose) + assert.False(t, cfg.Output.Quiet) +} + +func TestConfig_SetAndGet(t *testing.T) { + cfg := config.NewDefault() + + tests := []struct { + key string + setValue string + getValue interface{} + }{ + {"mongo.uri", "mongodb://example.com:27017", "mongodb://example.com:27017"}, + {"mongo.database", "testdb", "testdb"}, + {"mongo.extjson_mode", "relaxed", "relaxed"}, + {"database.type", "mongodb", "mongodb"}, + {"query.query", "{\"test\": 1}", "{\"test\": 1}"}, + {"query.limit", "5000", int64(5000)}, + {"app.editor", "nano", "nano"}, + {"app.timeout", "30s", "30s"}, + {"output.format", "yaml", "yaml"}, + {"output.line_numbers", "false", false}, + {"output.compact", "true", true}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + err := cfg.Set(tt.key, tt.setValue) + require.NoError(t, err) + + value, err := cfg.Get(tt.key) + require.NoError(t, err) + assert.Equal(t, tt.getValue, value) + }) + } +} + +func TestConfig_SetInvalidValues(t *testing.T) { + cfg := config.NewDefault() + + tests := []struct { + key string + value string + }{ + {"app.timeout", "invalid"}, + {"query.limit", "not-a-number"}, + {"mongo.extjson_mode", "invalid"}, + {"output.format", "invalid"}, + {"database.type", "invalid"}, + {"output.line_numbers", "not-bool"}, + {"unknown.key", "value"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + err := cfg.Set(tt.key, tt.value) + assert.Error(t, err) + }) + } +} + +func TestConfig_GetInvalidKey(t *testing.T) { + cfg := config.NewDefault() + + _, err := cfg.Get("unknown.key") + assert.Error(t, err) +} + +func TestConfig_SaveAndLoad(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + + // Set PHO_CONFIG_DIR to temp directory + t.Setenv("PHO_CONFIG_DIR", tempDir) + + // Create and save config + cfg := config.NewDefault() + cfg.Mongo.URI = "mongodb://test:27017" + cfg.App.Editor = "emacs" + cfg.Output.Format = "yaml" + + err := cfg.Save() + require.NoError(t, err) + + // Verify file was created + configPath := filepath.Join(tempDir, "config.toml") + assert.FileExists(t, configPath) + + // Load config + loadedCfg, err := config.Load() + require.NoError(t, err) + + assert.Equal(t, "mongodb://test:27017", loadedCfg.Mongo.URI) + assert.Equal(t, "emacs", loadedCfg.App.Editor) + assert.Equal(t, "yaml", loadedCfg.Output.Format) +} + +func TestConfig_EnvironmentOverrides(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + + // Set test environment variables + envVars := map[string]string{ + "PHO_CONFIG_DIR": tempDir, + "MONGODB_URI": "mongodb://env:27017", + "MONGODB_DB": "envdb", + "PHO_TIMEOUT": "120s", + } + + // Set environment variables + for key, value := range envVars { + t.Setenv(key, value) + } + + // Create config file with different values + configPath := filepath.Join(tempDir, "config.toml") + configData := map[string]interface{}{ + "mongo": map[string]interface{}{ + "uri": "mongodb://file:27017", + "database": "filedb", + }, + "app": map[string]interface{}{ + "timeout": "60s", + }, + } + + data, err := toml.Marshal(configData) + require.NoError(t, err) + + err = os.WriteFile(configPath, data, 0600) + require.NoError(t, err) + + // Load config - environment should override file + cfg, err := config.Load() + require.NoError(t, err) + + assert.Equal(t, "mongodb://env:27017", cfg.Mongo.URI) // from env + assert.Equal(t, "envdb", cfg.Mongo.Database) // from env + assert.Equal(t, "120s", cfg.App.Timeout) // from env +} + +func TestConfig_LoadNonExistentFile(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + + // Set PHO_CONFIG_DIR to temp directory (no config file) + t.Setenv("PHO_CONFIG_DIR", tempDir) + + // Load should succeed with defaults + cfg, err := config.Load() + require.NoError(t, err) + + // Should have default values + assert.Equal(t, "mongodb://localhost:27017", cfg.Mongo.URI) + assert.Equal(t, "vim", cfg.App.Editor) +}