package config import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/karol-broda/snitch/internal/theme" "github.com/spf13/viper" ) // Config represents the application configuration type Config struct { Defaults DefaultConfig `mapstructure:"defaults"` } // DefaultConfig contains default values for CLI options type DefaultConfig struct { Interval string `mapstructure:"interval"` Numeric bool `mapstructure:"numeric"` Fields []string `mapstructure:"fields"` Theme string `mapstructure:"theme"` Units string `mapstructure:"units"` Color string `mapstructure:"color"` Resolve bool `mapstructure:"resolve"` IPv4 bool `mapstructure:"ipv4"` IPv6 bool `mapstructure:"ipv6"` NoHeaders bool `mapstructure:"no_headers"` OutputFormat string `mapstructure:"output_format"` SortBy string `mapstructure:"sort_by"` } var globalConfig *Config // Load loads configuration from file and environment variables func Load() (*Config, error) { if globalConfig != nil { return globalConfig, nil } v := viper.New() // set config name and file type (auto-detect based on extension) v.SetConfigName("snitch") // don't set config type - let viper auto-detect based on file extension // this allows both .toml and .yaml files to work v.AddConfigPath("$HOME/.config/snitch") v.AddConfigPath("$HOME/.snitch") v.AddConfigPath("/etc/snitch") // Environment variables v.SetEnvPrefix("SNITCH") v.AutomaticEnv() // environment variable bindings for readme-documented variables _ = v.BindEnv("config", "SNITCH_CONFIG") _ = v.BindEnv("defaults.resolve", "SNITCH_RESOLVE") _ = v.BindEnv("defaults.theme", "SNITCH_THEME") _ = v.BindEnv("defaults.color", "SNITCH_NO_COLOR") // Set defaults setDefaults(v) // Handle SNITCH_CONFIG environment variable for custom config path if configPath := os.Getenv("SNITCH_CONFIG"); configPath != "" { v.SetConfigFile(configPath) } // Try to read config file if err := v.ReadInConfig(); err != nil { // It's OK if config file doesn't exist if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("error reading config file: %w", err) } } // Handle special environment variables handleSpecialEnvVars(v) // Unmarshal into config struct config := &Config{} if err := v.Unmarshal(config); err != nil { return nil, fmt.Errorf("error unmarshaling config: %w", err) } globalConfig = config return config, nil } func setDefaults(v *viper.Viper) { // Set default values matching the README specification v.SetDefault("defaults.interval", "1s") v.SetDefault("defaults.numeric", false) v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}) v.SetDefault("defaults.theme", "ansi") v.SetDefault("defaults.units", "auto") v.SetDefault("defaults.color", "auto") v.SetDefault("defaults.resolve", true) v.SetDefault("defaults.ipv4", false) v.SetDefault("defaults.ipv6", false) v.SetDefault("defaults.no_headers", false) v.SetDefault("defaults.output_format", "table") v.SetDefault("defaults.sort_by", "") } func handleSpecialEnvVars(v *viper.Viper) { // Handle SNITCH_NO_COLOR - if set to "1", disable color if os.Getenv("SNITCH_NO_COLOR") == "1" { v.Set("defaults.color", "never") } // Handle SNITCH_RESOLVE - if set to "0", disable resolution if os.Getenv("SNITCH_RESOLVE") == "0" { v.Set("defaults.resolve", false) v.Set("defaults.numeric", true) } } // Get returns the global configuration, loading it if necessary func Get() *Config { if globalConfig == nil { config, err := Load() if err != nil { // Return default config on error return &Config{ Defaults: DefaultConfig{ Interval: "1s", Numeric: false, Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}, Theme: "ansi", Units: "auto", Color: "auto", Resolve: true, IPv4: false, IPv6: false, NoHeaders: false, OutputFormat: "table", SortBy: "", }, } } return config } return globalConfig } // GetInterval returns the configured interval as a duration func (c *Config) GetInterval() time.Duration { if duration, err := time.ParseDuration(c.Defaults.Interval); err == nil { return duration } return time.Second // default fallback } // CreateExampleConfig creates an example configuration file func CreateExampleConfig(path string) error { themeList := strings.Join(theme.ListThemes(), ", ") exampleConfig := fmt.Sprintf(`# snitch configuration file # See https://github.com/you/snitch for full documentation [defaults] # Default refresh interval for watch/stats/trace commands interval = "1s" # Disable name/service resolution by default numeric = false # Default fields to display (comma-separated list) fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"] # Default theme for TUI (ansi inherits terminal colors) # Available: %s theme = "%s" # Default units for byte display (auto, si, iec) units = "auto" # Default color mode (auto, always, never) color = "auto" # Enable name resolution by default resolve = true # Filter options ipv4 = false ipv6 = false # Output options no_headers = false output_format = "table" sort_by = "" `, themeList, theme.DefaultTheme) // Ensure directory exists if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } // Write config file if err := os.WriteFile(path, []byte(exampleConfig), 0644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil }