5 files changed,
699 insertions(+),
38 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-04 23:34:07 +0200
Authored at:
2026-02-03 20:57:24 +0200
Change ID:
llzpqltrnktzpynqmvzxpkkvzrtysopo
Parent:
7711375
M
go.sum
路路路 109 109 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 110 110 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 111 111 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 112 +olexsmir.xyz/x v0.1.2 h1:etNRQijfG4Oduip6XClBDIaP/gqugAd9Viu5MNyLcoA= 113 +olexsmir.xyz/x v0.1.2/go.mod h1:mScl7AVsDCUUxvhzh6X2sLGwjEBg2K79AlkE9dgHXUA=
M
internal/config/config.go
路路路 5 5 "fmt" 6 6 "os" 7 7 "path/filepath" 8 + "time" 8 9 9 10 "gopkg.in/yaml.v2" 10 11 ) 11 12 12 13 var ErrConfigNotFound = errors.New("no config file found") 13 14 15 +type ServerConfig struct { 16 + Host string `yaml:"host"` 17 + Port int `yaml:"port"` 18 +} 19 + 20 +type MetaConfig struct { 21 + Title string `yaml:"title"` 22 + Description string `yaml:"description"` 23 + Host string `yaml:"host"` 24 +} 25 + 26 +type RepoConfig struct { 27 + Dir string `yaml:"dir"` 28 + Readmes []string `yaml:"readmes"` 29 + Masters []string `yaml:"masters"` 30 +} 31 + 32 +type SSHConfig struct { 33 + Enable bool `yaml:"enable"` 34 + Port int `yaml:"port"` 35 + HostKey string `yaml:"host_key"` 36 + Keys []string `yaml:"keys"` 37 +} 38 + 39 +type MirrorConfig struct { 40 + Enable bool `yaml:"enable"` 41 + Interval string `yaml:"interval"` 42 + GithubToken string `yaml:"github_token"` 43 +} 44 + 14 45 type Config struct { 15 - Server struct { 16 - Host string `yaml:"host"` 17 - Port int `yaml:"port"` 18 - } `yaml:"server"` 19 - Meta struct { 20 - Title string `yaml:"title"` 21 - Description string `yaml:"description"` 22 - Host string `yaml:"host"` 23 - } `yaml:"meta"` 24 - Repo struct { 25 - Dir string `yaml:"dir"` 26 - Readmes []string `yaml:"readmes"` 27 - Masters []string `yaml:"masters"` 28 - } `yaml:"repo"` 29 - SSH struct { 30 - Enable bool `yaml:"enable"` 31 - Port int `yaml:"port"` 32 - HostKey string `yaml:"host_key"` 33 - Keys []string `yaml:"keys"` 34 - } `yaml:"ssh"` 35 - Mirror struct { 36 - Enable bool `yaml:"enable"` 37 - Interval string `yaml:"interval"` 38 - GithubToken string `yaml:"github_token"` 39 - } `yaml:"mirror"` 46 + Server ServerConfig `yaml:"server"` 47 + Meta MetaConfig `yaml:"meta"` 48 + Repo RepoConfig `yaml:"repo"` 49 + SSH SSHConfig `yaml:"ssh"` 50 + Mirror MirrorConfig `yaml:"mirror"` 40 51 } 41 52 42 53 // Load loads configuration with the following priority: 43 54 // 1. User provided fpath (if provided and exists) 44 -// 2. $XDG_CONFIG_HOME/mugit/config.yaml 45 -// 3. $HOME/.config/mugit/config.yaml (fallback if XDG_CONFIG_HOME not set) 46 -// 4. /etc/mugit/config.yaml 55 +// 2. /var/lib/mugit/config.yaml 56 +// 3. $XDG_CONFIG_HOME/mugit/config.yaml or $HOME/.config/mugit/config.yaml 47 57 func Load(fpath string) (*Config, error) { 58 + // 4. /etc/mugit/config.yaml 48 59 configPath, err := findConfigFile(fpath) 49 60 if err != nil { 50 61 return nil, err 路路路 72 83 } 73 84 74 85 func (c Config) validate() error { 75 - // var errs []error 76 - // return errors.Join(errs...) 77 - return nil 86 + var errs []error 87 + 88 + // server 89 + if err := validatePort(c.Server.Port, "server.port"); err != nil { 90 + errs = append(errs, err) 91 + } 92 + 93 + // meta 94 + if c.Meta.Host == "" { 95 + errs = append(errs, errors.New("meta.host is required")) 96 + } 97 + 98 + // repo 99 + if err := validateDirExists(c.Repo.Dir, "repo.dir"); err != nil { 100 + errs = append(errs, err) 101 + } 102 + if len(c.Repo.Readmes) == 0 { 103 + errs = append(errs, errors.New("repo.readmes must have at least one value")) 104 + } 105 + if len(c.Repo.Masters) == 0 { 106 + errs = append(errs, errors.New("repo.masters must have at least one value")) 107 + } 108 + 109 + // ssh 110 + if c.SSH.Enable { 111 + if err := validatePort(c.SSH.Port, "ssh.port"); err != nil { 112 + errs = append(errs, err) 113 + } 114 + if c.SSH.Port == c.Server.Port { 115 + errs = append(errs, fmt.Errorf("ssh.port must differ from server.port (both are %d)", c.Server.Port)) 116 + } 117 + if err := validateFileExists(c.SSH.HostKey, "ssh.host_key"); err != nil { 118 + errs = append(errs, err) 119 + } 120 + if len(c.SSH.Keys) == 0 { 121 + errs = append(errs, errors.New("ssh.keys must have at least one value when ssh is enabled")) 122 + } 123 + } 124 + 125 + // mirror 126 + if c.Mirror.Enable { 127 + if c.Mirror.Interval == "" { 128 + errs = append(errs, errors.New("mirror.interval is required when mirror is enabled")) 129 + } else if _, err := time.ParseDuration(c.Mirror.Interval); err != nil { 130 + errs = append(errs, fmt.Errorf("mirror.interval: invalid duration format: %w", err)) 131 + } 132 + } 133 + 134 + return errors.Join(errs...) 78 135 } 79 136 80 137 func findConfigFile(userPath string) (string, error) { 路路路 84 141 } 85 142 } 86 143 87 - paths := []string{} 88 - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { 89 - paths = append(paths, filepath.Join(xdg, "mugit", "config.yaml")) 90 - } else if home := os.Getenv("HOME"); home != "" { 91 - paths = append(paths, filepath.Join(home, ".config", "mugit", "config.yaml")) 144 + path := "/var/lib/mugit/config.yaml" 145 + if _, err := os.Stat(path); err == nil { 146 + return path, nil 92 147 } 93 148 94 - paths = append(paths, "/etc/mugit/config.yaml") 95 - for _, p := range paths { 149 + if configDir, err := os.UserConfigDir(); err == nil { 150 + p := filepath.Join(configDir, "mugit", "config.yaml") 96 151 if _, err := os.Stat(p); err == nil { 97 152 return p, nil 98 153 } 99 154 } 100 155 156 + path = "/etc/mugit/config.yaml" 157 + if _, err := os.Stat(path); err == nil { 158 + return path, nil 159 + } 160 + 101 161 return "", ErrConfigNotFound 102 162 } 163 + 164 +func validatePort(port int, fieldName string) error { 165 + if port < 1 || port > 65535 { 166 + return fmt.Errorf("%s must be between 1 and 65535, got %d", fieldName, port) 167 + } 168 + return nil 169 +} 170 + 171 +func validateDirExists(path string, fieldName string) error { 172 + if path == "" { 173 + return fmt.Errorf("%s is required", fieldName) 174 + } 175 + info, err := os.Stat(path) 176 + if err != nil { 177 + if os.IsNotExist(err) { 178 + return fmt.Errorf("%s: directory does not exist: %s", fieldName, path) 179 + } 180 + return fmt.Errorf("%s: cannot access directory: %w", fieldName, err) 181 + } 182 + if !info.IsDir() { 183 + return fmt.Errorf("%s: path exists but is not a directory: %s", fieldName, path) 184 + } 185 + return nil 186 +} 187 + 188 +func validateFileExists(path string, fieldName string) error { 189 + if path == "" { 190 + return fmt.Errorf("%s is required", fieldName) 191 + } 192 + info, err := os.Stat(path) 193 + if err != nil { 194 + if os.IsNotExist(err) { 195 + return fmt.Errorf("%s: file does not exist: %s", fieldName, path) 196 + } 197 + return fmt.Errorf("%s: cannot access file: %w", fieldName, err) 198 + } 199 + if info.IsDir() { 200 + return fmt.Errorf("%s: path is a directory, not a file: %s", fieldName, path) 201 + } 202 + return nil 203 +}
A
internal/config/config_test.go
路路路 1 +package config 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "olexsmir.xyz/x/is" 9 +) 10 + 11 +func TestFindConfigFile(t *testing.T) { 12 + t.Run("returns user provided path when it exists", func(t *testing.T) { 13 + path, err := findConfigFile("testdata/hostkey") 14 + is.Err(t, err, nil) 15 + is.Equal(t, path, "testdata/hostkey") 16 + }) 17 + 18 + t.Run("falls back when user path doesn't exist", func(t *testing.T) { 19 + path, err := findConfigFile("/nonexistent/user/config.yaml") 20 + if err != nil { 21 + is.Err(t, err, ErrConfigNotFound) 22 + } else { 23 + _, statErr := os.Stat(path) 24 + is.Err(t, statErr, nil) 25 + } 26 + }) 27 + 28 + t.Run("finds config in user config directory", func(t *testing.T) { 29 + tmpDir := t.TempDir() 30 + configDir := filepath.Join(tmpDir, "mugit") 31 + if err := os.MkdirAll(configDir, 0o755); err != nil { 32 + t.Fatal(err) 33 + } 34 + configFile := filepath.Join(configDir, "config.yaml") 35 + if err := os.WriteFile(configFile, []byte("test"), 0o644); err != nil { 36 + t.Fatal(err) 37 + } 38 + 39 + t.Setenv("XDG_CONFIG_HOME", tmpDir) 40 + 41 + path, err := findConfigFile("") 42 + is.Err(t, err, nil) 43 + is.Equal(t, path, configFile) 44 + }) 45 + 46 + t.Run("returns error when no config found anywhere", func(t *testing.T) { 47 + t.Setenv("XDG_CONFIG_HOME", "/nonexistent") 48 + t.Setenv("HOME", "/nonexistent") 49 + 50 + path, err := findConfigFile("/nonexistent/config.yaml") 51 + is.Err(t, err, ErrConfigNotFound) 52 + is.Equal(t, path, "") 53 + }) 54 + 55 + t.Run("prefers data directory over user config", func(t *testing.T) { 56 + tmpDir := t.TempDir() 57 + configDir := filepath.Join(tmpDir, "mugit") 58 + if err := os.MkdirAll(configDir, 0o755); err != nil { 59 + t.Fatal(err) 60 + } 61 + userConfigFile := filepath.Join(configDir, "config.yaml") 62 + if err := os.WriteFile(userConfigFile, []byte("user config"), 0o644); err != nil { 63 + t.Fatal(err) 64 + } 65 + 66 + t.Setenv("XDG_CONFIG_HOME", tmpDir) 67 + 68 + path, err := findConfigFile("") 69 + is.Err(t, err, nil) 70 + 71 + if path == "/var/lib/mugit/config.yaml" { 72 + _, statErr := os.Stat(path) 73 + is.Err(t, statErr, nil) 74 + } else { 75 + is.Equal(t, path, userConfigFile) 76 + } 77 + }) 78 +} 79 + 80 +func TestValidatePort(t *testing.T) { 81 + t.Run("accepts standard port numbers", func(t *testing.T) { 82 + is.Err(t, validatePort(1, "test"), nil) 83 + is.Err(t, validatePort(80, "test"), nil) 84 + is.Err(t, validatePort(8080, "test"), nil) 85 + is.Err(t, validatePort(65535, "test"), nil) 86 + }) 87 + 88 + t.Run("rejects out of range ports", func(t *testing.T) { 89 + is.Err(t, validatePort(0, "test"), "must be between") 90 + is.Err(t, validatePort(-1, "test"), "must be between") 91 + is.Err(t, validatePort(65536, "test"), "must be between") 92 + is.Err(t, validatePort(100000, "test"), "must be between") 93 + }) 94 +} 95 + 96 +func TestValidateDirExists(t *testing.T) { 97 + t.Run("accepts existing directories", func(t *testing.T) { 98 + tmpDir := t.TempDir() 99 + is.Err(t, validateDirExists(tmpDir, "test"), nil) 100 + }) 101 + 102 + t.Run("rejects nonexistent paths", func(t *testing.T) { 103 + is.Err(t, validateDirExists("/nonexistent/path/to/dir", "test"), "does not exist") 104 + }) 105 + 106 + t.Run("rejects empty paths", func(t *testing.T) { 107 + is.Err(t, validateDirExists("", "test"), "is required") 108 + }) 109 + 110 + t.Run("rejects files when directory expected", func(t *testing.T) { 111 + tmpDir := t.TempDir() 112 + tmpFile := filepath.Join(tmpDir, "file.txt") 113 + if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil { 114 + t.Fatal(err) 115 + } 116 + is.Err(t, validateDirExists(tmpFile, "test"), "not a directory") 117 + }) 118 +} 119 + 120 +func TestValidateFileExists(t *testing.T) { 121 + t.Run("accepts existing files", func(t *testing.T) { 122 + tmpDir := t.TempDir() 123 + tmpFile := filepath.Join(tmpDir, "file.txt") 124 + if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil { 125 + t.Fatal(err) 126 + } 127 + is.Err(t, validateFileExists(tmpFile, "test"), nil) 128 + }) 129 + 130 + t.Run("rejects nonexistent files", func(t *testing.T) { 131 + is.Err(t, validateFileExists("/nonexistent/file.txt", "test"), "does not exist") 132 + }) 133 + 134 + t.Run("rejects empty paths", func(t *testing.T) { 135 + is.Err(t, validateFileExists("", "test"), "is required") 136 + }) 137 + 138 + t.Run("rejects directories when file expected", func(t *testing.T) { 139 + tmpDir := t.TempDir() 140 + is.Err(t, validateFileExists(tmpDir, "test"), "is a directory") 141 + }) 142 +} 143 + 144 +func TestConfigValidate(t *testing.T) { 145 + tmpDir := t.TempDir() 146 + hostKeyPath := "testdata/hostkey" 147 + 148 + t.Run("accepts minimal valid configuration", func(t *testing.T) { 149 + cfg := Config{ 150 + Server: ServerConfig{ 151 + Host: "localhost", 152 + Port: 8080, 153 + }, 154 + Meta: MetaConfig{ 155 + Title: "Test", 156 + Description: "Test description", 157 + Host: "example.com", 158 + }, 159 + Repo: RepoConfig{ 160 + Dir: tmpDir, 161 + Readmes: []string{"README.md"}, 162 + Masters: []string{"main"}, 163 + }, 164 + SSH: SSHConfig{ 165 + Enable: false, 166 + }, 167 + Mirror: MirrorConfig{ 168 + Enable: false, 169 + }, 170 + } 171 + is.Err(t, cfg.validate(), nil) 172 + }) 173 + 174 + t.Run("accepts configuration with SSH enabled", func(t *testing.T) { 175 + cfg := Config{ 176 + Server: ServerConfig{ 177 + Host: "localhost", 178 + Port: 8080, 179 + }, 180 + Meta: MetaConfig{ 181 + Title: "Test", 182 + Description: "Test description", 183 + Host: "example.com", 184 + }, 185 + Repo: RepoConfig{ 186 + Dir: tmpDir, 187 + Readmes: []string{"README.md"}, 188 + Masters: []string{"main"}, 189 + }, 190 + SSH: SSHConfig{ 191 + Enable: true, 192 + Port: 2222, 193 + HostKey: hostKeyPath, 194 + Keys: []string{"ssh-rsa AAAAB3..."}, 195 + }, 196 + Mirror: MirrorConfig{ 197 + Enable: false, 198 + }, 199 + } 200 + is.Err(t, cfg.validate(), nil) 201 + }) 202 + 203 + t.Run("accepts configuration with mirroring enabled", func(t *testing.T) { 204 + cfg := Config{ 205 + Server: ServerConfig{ 206 + Host: "localhost", 207 + Port: 8080, 208 + }, 209 + Meta: MetaConfig{ 210 + Title: "Test", 211 + Description: "Test description", 212 + Host: "example.com", 213 + }, 214 + Repo: RepoConfig{ 215 + Dir: tmpDir, 216 + Readmes: []string{"README.md"}, 217 + Masters: []string{"main"}, 218 + }, 219 + SSH: SSHConfig{ 220 + Enable: false, 221 + }, 222 + Mirror: MirrorConfig{ 223 + Enable: true, 224 + Interval: "1h", 225 + GithubToken: "ghp_token", 226 + }, 227 + } 228 + is.Err(t, cfg.validate(), nil) 229 + }) 230 + 231 + t.Run("rejects invalid server port", func(t *testing.T) { 232 + cfg := Config{ 233 + Server: ServerConfig{ 234 + Port: 0, 235 + }, 236 + Meta: MetaConfig{ 237 + Host: "example.com", 238 + }, 239 + Repo: RepoConfig{ 240 + Dir: tmpDir, 241 + Readmes: []string{"README.md"}, 242 + Masters: []string{"main"}, 243 + }, 244 + } 245 + is.Err(t, cfg.validate(), "server.port") 246 + }) 247 + 248 + t.Run("rejects missing meta host", func(t *testing.T) { 249 + cfg := Config{ 250 + Server: ServerConfig{ 251 + Port: 8080, 252 + }, 253 + Meta: MetaConfig{ 254 + Host: "", 255 + }, 256 + Repo: RepoConfig{ 257 + Dir: tmpDir, 258 + Readmes: []string{"README.md"}, 259 + Masters: []string{"main"}, 260 + }, 261 + } 262 + is.Err(t, cfg.validate(), "meta.host") 263 + }) 264 + 265 + t.Run("rejects nonexistent repository directory", func(t *testing.T) { 266 + cfg := Config{ 267 + Server: ServerConfig{ 268 + Port: 8080, 269 + }, 270 + Meta: MetaConfig{ 271 + Host: "example.com", 272 + }, 273 + Repo: RepoConfig{ 274 + Dir: "/nonexistent/path", 275 + Readmes: []string{"README.md"}, 276 + Masters: []string{"main"}, 277 + }, 278 + } 279 + is.Err(t, cfg.validate(), "repo.dir") 280 + }) 281 + 282 + t.Run("rejects empty readme list", func(t *testing.T) { 283 + cfg := Config{ 284 + Server: ServerConfig{ 285 + Port: 8080, 286 + }, 287 + Meta: MetaConfig{ 288 + Host: "example.com", 289 + }, 290 + Repo: RepoConfig{ 291 + Dir: tmpDir, 292 + Readmes: []string{}, 293 + Masters: []string{"main"}, 294 + }, 295 + } 296 + is.Err(t, cfg.validate(), "repo.readmes") 297 + }) 298 + 299 + t.Run("rejects empty master branches list", func(t *testing.T) { 300 + cfg := Config{ 301 + Server: ServerConfig{ 302 + Port: 8080, 303 + }, 304 + Meta: MetaConfig{ 305 + Host: "example.com", 306 + }, 307 + Repo: RepoConfig{ 308 + Dir: tmpDir, 309 + Readmes: []string{"README.md"}, 310 + Masters: []string{}, 311 + }, 312 + } 313 + is.Err(t, cfg.validate(), "repo.masters") 314 + }) 315 + 316 + t.Run("rejects invalid SSH port when SSH enabled", func(t *testing.T) { 317 + cfg := Config{ 318 + Server: ServerConfig{ 319 + Port: 8080, 320 + }, 321 + Meta: MetaConfig{ 322 + Host: "example.com", 323 + }, 324 + Repo: RepoConfig{ 325 + Dir: tmpDir, 326 + Readmes: []string{"README.md"}, 327 + Masters: []string{"main"}, 328 + }, 329 + SSH: SSHConfig{ 330 + Enable: true, 331 + Port: 0, 332 + HostKey: hostKeyPath, 333 + Keys: []string{"ssh-rsa AAAAB3..."}, 334 + }, 335 + } 336 + is.Err(t, cfg.validate(), "ssh.port") 337 + }) 338 + 339 + t.Run("rejects SSH port same as HTTP port", func(t *testing.T) { 340 + cfg := Config{ 341 + Server: ServerConfig{ 342 + Port: 8080, 343 + }, 344 + Meta: MetaConfig{ 345 + Host: "example.com", 346 + }, 347 + Repo: RepoConfig{ 348 + Dir: tmpDir, 349 + Readmes: []string{"README.md"}, 350 + Masters: []string{"main"}, 351 + }, 352 + SSH: SSHConfig{ 353 + Enable: true, 354 + Port: 8080, 355 + HostKey: hostKeyPath, 356 + Keys: []string{"ssh-rsa AAAAB3..."}, 357 + }, 358 + } 359 + is.Err(t, cfg.validate(), "must differ") 360 + }) 361 + 362 + t.Run("rejects nonexistent SSH host key file", func(t *testing.T) { 363 + cfg := Config{ 364 + Server: ServerConfig{ 365 + Port: 8080, 366 + }, 367 + Meta: MetaConfig{ 368 + Host: "example.com", 369 + }, 370 + Repo: RepoConfig{ 371 + Dir: tmpDir, 372 + Readmes: []string{"README.md"}, 373 + Masters: []string{"main"}, 374 + }, 375 + SSH: SSHConfig{ 376 + Enable: true, 377 + Port: 2222, 378 + HostKey: "/nonexistent/key", 379 + Keys: []string{"ssh-rsa AAAAB3..."}, 380 + }, 381 + } 382 + is.Err(t, cfg.validate(), "ssh.host_key") 383 + }) 384 + 385 + t.Run("rejects empty SSH keys list when SSH enabled", func(t *testing.T) { 386 + cfg := Config{ 387 + Server: ServerConfig{ 388 + Port: 8080, 389 + }, 390 + Meta: MetaConfig{ 391 + Host: "example.com", 392 + }, 393 + Repo: RepoConfig{ 394 + Dir: tmpDir, 395 + Readmes: []string{"README.md"}, 396 + Masters: []string{"main"}, 397 + }, 398 + SSH: SSHConfig{ 399 + Enable: true, 400 + Port: 2222, 401 + HostKey: hostKeyPath, 402 + Keys: []string{}, 403 + }, 404 + } 405 + is.Err(t, cfg.validate(), "ssh.keys") 406 + }) 407 + 408 + t.Run("rejects empty mirror interval", func(t *testing.T) { 409 + cfg := Config{ 410 + Server: ServerConfig{ 411 + Port: 8080, 412 + }, 413 + Meta: MetaConfig{ 414 + Host: "example.com", 415 + }, 416 + Repo: RepoConfig{ 417 + Dir: tmpDir, 418 + Readmes: []string{"README.md"}, 419 + Masters: []string{"main"}, 420 + }, 421 + Mirror: MirrorConfig{ 422 + Enable: true, 423 + Interval: "", 424 + }, 425 + } 426 + is.Err(t, cfg.validate(), "mirror.interval") 427 + }) 428 + 429 + t.Run("rejects invalid mirror interval format", func(t *testing.T) { 430 + cfg := Config{ 431 + Server: ServerConfig{ 432 + Port: 8080, 433 + }, 434 + Meta: MetaConfig{ 435 + Host: "example.com", 436 + }, 437 + Repo: RepoConfig{ 438 + Dir: tmpDir, 439 + Readmes: []string{"README.md"}, 440 + Masters: []string{"main"}, 441 + }, 442 + Mirror: MirrorConfig{ 443 + Enable: true, 444 + Interval: "1hour", 445 + }, 446 + } 447 + is.Err(t, cfg.validate(), "invalid duration") 448 + }) 449 + 450 + t.Run("collects and reports multiple validation errors", func(t *testing.T) { 451 + cfg := Config{ 452 + Server: ServerConfig{ 453 + Port: 0, 454 + }, 455 + Meta: MetaConfig{ 456 + Host: "", 457 + }, 458 + Repo: RepoConfig{ 459 + Dir: "/nonexistent", 460 + Readmes: []string{}, 461 + Masters: []string{}, 462 + }, 463 + } 464 + err := cfg.validate() 465 + is.Err(t, err, "server.port") 466 + is.Err(t, err, "meta.host") 467 + is.Err(t, err, "repo.dir") 468 + is.Err(t, err, "repo.readmes") 469 + is.Err(t, err, "repo.masters") 470 + }) 471 + 472 + t.Run("accepts multiple readme and master branch names", func(t *testing.T) { 473 + cfg := Config{ 474 + Server: ServerConfig{ 475 + Port: 8080, 476 + }, 477 + Meta: MetaConfig{ 478 + Host: "example.com", 479 + }, 480 + Repo: RepoConfig{ 481 + Dir: tmpDir, 482 + Readmes: []string{"README.md", "readme.txt", "README"}, 483 + Masters: []string{"main", "master", "trunk"}, 484 + }, 485 + } 486 + is.Err(t, cfg.validate(), nil) 487 + }) 488 + 489 + t.Run("ignores invalid SSH fields when SSH disabled", func(t *testing.T) { 490 + cfg := Config{ 491 + Server: ServerConfig{ 492 + Port: 8080, 493 + }, 494 + Meta: MetaConfig{ 495 + Host: "example.com", 496 + }, 497 + Repo: RepoConfig{ 498 + Dir: tmpDir, 499 + Readmes: []string{"README.md"}, 500 + Masters: []string{"main"}, 501 + }, 502 + SSH: SSHConfig{ 503 + Enable: false, 504 + Port: 0, 505 + HostKey: "/nonexistent", 506 + Keys: []string{}, 507 + }, 508 + } 509 + is.Err(t, cfg.validate(), nil) 510 + }) 511 + 512 + t.Run("ignores invalid mirror fields when mirroring disabled", func(t *testing.T) { 513 + cfg := Config{ 514 + Server: ServerConfig{ 515 + Port: 8080, 516 + }, 517 + Meta: MetaConfig{ 518 + Host: "example.com", 519 + }, 520 + Repo: RepoConfig{ 521 + Dir: tmpDir, 522 + Readmes: []string{"README.md"}, 523 + Masters: []string{"main"}, 524 + }, 525 + Mirror: MirrorConfig{ 526 + Enable: false, 527 + Interval: "invalid", 528 + }, 529 + } 530 + is.Err(t, cfg.validate(), nil) 531 + }) 532 + 533 + t.Run("accepts various time duration formats", func(t *testing.T) { 534 + durations := []string{"1h", "30m", "1h30m", "1h30m45s", "24h"} 535 + for _, duration := range durations { 536 + cfg := Config{ 537 + Server: ServerConfig{ 538 + Port: 8080, 539 + }, 540 + Meta: MetaConfig{ 541 + Host: "example.com", 542 + }, 543 + Repo: RepoConfig{ 544 + Dir: tmpDir, 545 + Readmes: []string{"README.md"}, 546 + Masters: []string{"main"}, 547 + }, 548 + Mirror: MirrorConfig{ 549 + Enable: true, 550 + Interval: duration, 551 + }, 552 + } 553 + is.Err(t, cfg.validate(), nil) 554 + } 555 + }) 556 +}