package config import ( "fmt" "os" "path/filepath" "strings" "github.com/spf13/viper" "gopkg.in/yaml.v3" ) type Config struct { Hosts map[string]HostConfig `yaml:"hosts"` } type HostConfig struct { Hostname string `yaml:"hostname"` Token string `yaml:"token"` User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` MatchDirs []string `yaml:"match_dirs,omitempty"` } func GetConfigDir() (string, error) { if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { return filepath.Join(xdgConfigHome, "fgj"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".config", "fgj"), nil } func GetConfigPath() (string, error) { dir, err := GetConfigDir() if err != nil { return "", err } return filepath.Join(dir, "config.yaml"), nil } func Load() (*Config, error) { path, err := GetConfigPath() if err != nil { return nil, err } return LoadFromPath(path) } func LoadFromPath(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return &Config{Hosts: make(map[string]HostConfig)}, nil } return nil, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } if cfg.Hosts == nil { cfg.Hosts = make(map[string]HostConfig) } return &cfg, nil } func (c *Config) Save() error { path, err := GetConfigPath() if err != nil { return err } return c.SaveToPath(path) } func (c *Config) SaveToPath(path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(path, data, 0600) } // GetHost resolves the hostname to use for API client creation. // Priority order: // 1. Explicitly provided hostname parameter // 2. CLI flag (--hostname) // 3. Environment variable (FGJ_HOST) // 4. Auto-detected hostname from git remote // 5. match_dirs lookup (longest prefix match) // 6. Default to codeberg.org func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) { if hostname == "" { hostname = viper.GetString("hostname") } if hostname == "" { hostname = os.Getenv("FGJ_HOST") } if hostname == "" { hostname = detectedHost } if hostname == "" { hostname = c.ResolveHostByPath(cwd) } if hostname == "" { hostname = "codeberg.org" } host, ok := c.Hosts[hostname] if !ok { return HostConfig{}, fmt.Errorf("no configuration found for host %s", hostname) } return host, nil } // ResolveHostByPath finds the host whose match_dirs entry is the longest // prefix of cwd. Returns "" if no match is found. // Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks // to handle symlinks (e.g. macOS /tmp → /private/tmp). func (c *Config) ResolveHostByPath(cwd string) string { if cwd == "" { return "" } // Resolve symlinks in cwd so /tmp becomes /private/tmp on macOS, etc. if resolved, err := filepath.EvalSymlinks(cwd); err == nil { cwd = resolved } bestHost := "" bestLen := 0 for hostname, host := range c.Hosts { for _, dir := range host.MatchDirs { if dir == "" { continue } // Expand ~ to home directory dir = expandHome(dir) // Resolve symlinks in the configured dir as well if resolved, err := filepath.EvalSymlinks(dir); err == nil { dir = resolved } // Normalize: ensure trailing slash for prefix matching prefix := dir if !strings.HasSuffix(prefix, "/") { prefix += "/" } // Match if cwd equals dir exactly or is under it if cwd == dir || strings.HasPrefix(cwd, prefix) { if len(dir) > bestLen { bestLen = len(dir) bestHost = hostname } } } } return bestHost } // expandHome replaces a leading ~ with the user's home directory. func expandHome(path string) string { if path == "~" || strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[1:]) } return path } func (c *Config) SetHost(hostname string, host HostConfig) { if c.Hosts == nil { c.Hosts = make(map[string]HostConfig) } c.Hosts[hostname] = host }