config_test.go (13834B)
1 package config 2 3 import ( 4 "os" 5 "path/filepath" 6 "strings" 7 "testing" 8 9 "github.com/spf13/viper" 10 ) 11 12 // --- Helper: write a TOML string to a temp file and return its path --- 13 func writeTempTOML(t *testing.T, content string) string { 14 t.Helper() 15 dir := t.TempDir() 16 path := filepath.Join(dir, "esync.toml") 17 if err := os.WriteFile(path, []byte(content), 0644); err != nil { 18 t.Fatalf("failed to write temp TOML: %v", err) 19 } 20 return path 21 } 22 23 // ----------------------------------------------------------------------- 24 // 1. TestLoadConfig — full TOML with all fields 25 // ----------------------------------------------------------------------- 26 func TestLoadConfig(t *testing.T) { 27 toml := ` 28 [sync] 29 local = "/home/user/project" 30 remote = "server:/data/project" 31 interval = 5 32 33 [settings] 34 watcher_debounce = 300 35 initial_sync = true 36 ignore = [".git", "node_modules"] 37 38 [settings.rsync] 39 archive = true 40 compress = false 41 backup = true 42 backup_dir = ".my_backup" 43 progress = false 44 extra_args = ["--delete", "--verbose"] 45 ignore = ["*.tmp", "*.log"] 46 47 [settings.log] 48 file = "/var/log/esync.log" 49 format = "json" 50 ` 51 path := writeTempTOML(t, toml) 52 cfg, err := Load(path) 53 if err != nil { 54 t.Fatalf("Load returned error: %v", err) 55 } 56 57 // sync section 58 if cfg.Sync.Local != "/home/user/project" { 59 t.Errorf("Sync.Local = %q, want %q", cfg.Sync.Local, "/home/user/project") 60 } 61 if cfg.Sync.Remote != "server:/data/project" { 62 t.Errorf("Sync.Remote = %q, want %q", cfg.Sync.Remote, "server:/data/project") 63 } 64 if cfg.Sync.Interval != 5 { 65 t.Errorf("Sync.Interval = %d, want 5", cfg.Sync.Interval) 66 } 67 68 // settings 69 if cfg.Settings.WatcherDebounce != 300 { 70 t.Errorf("Settings.WatcherDebounce = %d, want 300", cfg.Settings.WatcherDebounce) 71 } 72 if cfg.Settings.InitialSync != true { 73 t.Errorf("Settings.InitialSync = %v, want true", cfg.Settings.InitialSync) 74 } 75 if len(cfg.Settings.Ignore) != 2 || cfg.Settings.Ignore[0] != ".git" || cfg.Settings.Ignore[1] != "node_modules" { 76 t.Errorf("Settings.Ignore = %v, want [.git node_modules]", cfg.Settings.Ignore) 77 } 78 79 // rsync 80 if cfg.Settings.Rsync.Archive != true { 81 t.Errorf("Rsync.Archive = %v, want true", cfg.Settings.Rsync.Archive) 82 } 83 if cfg.Settings.Rsync.Compress != false { 84 t.Errorf("Rsync.Compress = %v, want false", cfg.Settings.Rsync.Compress) 85 } 86 if cfg.Settings.Rsync.Backup != true { 87 t.Errorf("Rsync.Backup = %v, want true", cfg.Settings.Rsync.Backup) 88 } 89 if cfg.Settings.Rsync.BackupDir != ".my_backup" { 90 t.Errorf("Rsync.BackupDir = %q, want %q", cfg.Settings.Rsync.BackupDir, ".my_backup") 91 } 92 if cfg.Settings.Rsync.Progress != false { 93 t.Errorf("Rsync.Progress = %v, want false", cfg.Settings.Rsync.Progress) 94 } 95 if len(cfg.Settings.Rsync.ExtraArgs) != 2 || cfg.Settings.Rsync.ExtraArgs[0] != "--delete" { 96 t.Errorf("Rsync.ExtraArgs = %v, want [--delete --verbose]", cfg.Settings.Rsync.ExtraArgs) 97 } 98 if len(cfg.Settings.Rsync.Ignore) != 2 || cfg.Settings.Rsync.Ignore[0] != "*.tmp" { 99 t.Errorf("Rsync.Ignore = %v, want [*.tmp *.log]", cfg.Settings.Rsync.Ignore) 100 } 101 102 // log 103 if cfg.Settings.Log.File != "/var/log/esync.log" { 104 t.Errorf("Log.File = %q, want %q", cfg.Settings.Log.File, "/var/log/esync.log") 105 } 106 if cfg.Settings.Log.Format != "json" { 107 t.Errorf("Log.Format = %q, want %q", cfg.Settings.Log.Format, "json") 108 } 109 } 110 111 // ----------------------------------------------------------------------- 112 // 2. TestLoadConfigWithSSH — TOML with [sync.ssh] section 113 // ----------------------------------------------------------------------- 114 func TestLoadConfigWithSSH(t *testing.T) { 115 toml := ` 116 [sync] 117 local = "/home/user/src" 118 remote = "/data/dest" 119 120 [sync.ssh] 121 host = "myserver.com" 122 user = "deploy" 123 port = 2222 124 identity_file = "~/.ssh/id_ed25519" 125 interactive_auth = true 126 ` 127 path := writeTempTOML(t, toml) 128 cfg, err := Load(path) 129 if err != nil { 130 t.Fatalf("Load returned error: %v", err) 131 } 132 133 if cfg.Sync.SSH == nil { 134 t.Fatal("Sync.SSH is nil, expected SSH config") 135 } 136 if cfg.Sync.SSH.Host != "myserver.com" { 137 t.Errorf("SSH.Host = %q, want %q", cfg.Sync.SSH.Host, "myserver.com") 138 } 139 if cfg.Sync.SSH.User != "deploy" { 140 t.Errorf("SSH.User = %q, want %q", cfg.Sync.SSH.User, "deploy") 141 } 142 if cfg.Sync.SSH.Port != 2222 { 143 t.Errorf("SSH.Port = %d, want 2222", cfg.Sync.SSH.Port) 144 } 145 if cfg.Sync.SSH.IdentityFile != "~/.ssh/id_ed25519" { 146 t.Errorf("SSH.IdentityFile = %q, want %q", cfg.Sync.SSH.IdentityFile, "~/.ssh/id_ed25519") 147 } 148 if cfg.Sync.SSH.InteractiveAuth != true { 149 t.Errorf("SSH.InteractiveAuth = %v, want true", cfg.Sync.SSH.InteractiveAuth) 150 } 151 152 // IsRemote should return true when SSH is configured 153 if !cfg.IsRemote() { 154 t.Error("IsRemote() = false, want true (SSH config present)") 155 } 156 } 157 158 // ----------------------------------------------------------------------- 159 // 3. TestLoadConfigDefaults — minimal TOML, verify defaults applied 160 // ----------------------------------------------------------------------- 161 func TestLoadConfigDefaults(t *testing.T) { 162 toml := ` 163 [sync] 164 local = "/src" 165 remote = "/dst" 166 ` 167 path := writeTempTOML(t, toml) 168 cfg, err := Load(path) 169 if err != nil { 170 t.Fatalf("Load returned error: %v", err) 171 } 172 173 if cfg.Sync.Interval != 1 { 174 t.Errorf("default Sync.Interval = %d, want 1", cfg.Sync.Interval) 175 } 176 if cfg.Settings.WatcherDebounce != 500 { 177 t.Errorf("default WatcherDebounce = %d, want 500", cfg.Settings.WatcherDebounce) 178 } 179 if cfg.Settings.InitialSync != false { 180 t.Errorf("default InitialSync = %v, want false", cfg.Settings.InitialSync) 181 } 182 if cfg.Settings.Rsync.Archive != true { 183 t.Errorf("default Rsync.Archive = %v, want true", cfg.Settings.Rsync.Archive) 184 } 185 if cfg.Settings.Rsync.Compress != true { 186 t.Errorf("default Rsync.Compress = %v, want true", cfg.Settings.Rsync.Compress) 187 } 188 if cfg.Settings.Rsync.Backup != false { 189 t.Errorf("default Rsync.Backup = %v, want false", cfg.Settings.Rsync.Backup) 190 } 191 if cfg.Settings.Rsync.BackupDir != ".rsync_backup" { 192 t.Errorf("default Rsync.BackupDir = %q, want %q", cfg.Settings.Rsync.BackupDir, ".rsync_backup") 193 } 194 if cfg.Settings.Rsync.Progress != true { 195 t.Errorf("default Rsync.Progress = %v, want true", cfg.Settings.Rsync.Progress) 196 } 197 if cfg.Settings.Log.Format != "text" { 198 t.Errorf("default Log.Format = %q, want %q", cfg.Settings.Log.Format, "text") 199 } 200 201 // SSH should be nil when not specified 202 if cfg.Sync.SSH != nil { 203 t.Errorf("Sync.SSH = %v, want nil", cfg.Sync.SSH) 204 } 205 } 206 207 // ----------------------------------------------------------------------- 208 // 4. TestLoadConfigValidation — missing required fields 209 // ----------------------------------------------------------------------- 210 func TestLoadConfigValidation(t *testing.T) { 211 t.Run("missing local", func(t *testing.T) { 212 toml := ` 213 [sync] 214 remote = "/dst" 215 ` 216 path := writeTempTOML(t, toml) 217 _, err := Load(path) 218 if err == nil { 219 t.Error("expected error for missing local, got nil") 220 } 221 }) 222 223 t.Run("missing remote", func(t *testing.T) { 224 toml := ` 225 [sync] 226 local = "/src" 227 ` 228 path := writeTempTOML(t, toml) 229 _, err := Load(path) 230 if err == nil { 231 t.Error("expected error for missing remote, got nil") 232 } 233 }) 234 235 t.Run("missing both", func(t *testing.T) { 236 toml := ` 237 [settings] 238 watcher_debounce = 100 239 ` 240 path := writeTempTOML(t, toml) 241 _, err := Load(path) 242 if err == nil { 243 t.Error("expected error for missing local and remote, got nil") 244 } 245 }) 246 } 247 248 // ----------------------------------------------------------------------- 249 // 5. TestIsRemote — various remote patterns 250 // ----------------------------------------------------------------------- 251 func TestIsRemote(t *testing.T) { 252 tests := []struct { 253 name string 254 remote string 255 ssh *SSHConfig 256 want bool 257 }{ 258 {"user@host:/path", "user@host:/path", nil, true}, 259 {"host:/path", "host:/path", nil, true}, 260 {"local relative", "./local", nil, false}, 261 {"local absolute", "/absolute", nil, false}, 262 {"windows path", "C:/windows", nil, false}, 263 {"ssh config present", "/data/dest", &SSHConfig{Host: "myserver"}, true}, 264 } 265 266 for _, tt := range tests { 267 t.Run(tt.name, func(t *testing.T) { 268 cfg := &Config{ 269 Sync: SyncSection{ 270 Remote: tt.remote, 271 SSH: tt.ssh, 272 }, 273 } 274 got := cfg.IsRemote() 275 if got != tt.want { 276 t.Errorf("IsRemote() = %v, want %v (remote=%q, ssh=%v)", got, tt.want, tt.remote, tt.ssh) 277 } 278 }) 279 } 280 } 281 282 // ----------------------------------------------------------------------- 283 // 6. TestFindConfigFile / TestFindConfigFileNotFound 284 // ----------------------------------------------------------------------- 285 func TestFindConfigFile(t *testing.T) { 286 dir := t.TempDir() 287 configPath := filepath.Join(dir, "esync.toml") 288 if err := os.WriteFile(configPath, []byte("[sync]\n"), 0644); err != nil { 289 t.Fatal(err) 290 } 291 292 found := FindConfigIn([]string{ 293 filepath.Join(dir, "nonexistent.toml"), 294 configPath, 295 "/also/nonexistent.toml", 296 }) 297 if found != configPath { 298 t.Errorf("FindConfigIn = %q, want %q", found, configPath) 299 } 300 } 301 302 func TestFindConfigFileNotFound(t *testing.T) { 303 found := FindConfigIn([]string{ 304 "/does/not/exist/esync.toml", 305 "/also/nonexistent/config.toml", 306 }) 307 if found != "" { 308 t.Errorf("FindConfigIn = %q, want empty string", found) 309 } 310 } 311 312 func TestFindConfigInPrefersDotFile(t *testing.T) { 313 dir := t.TempDir() 314 dotFile := filepath.Join(dir, ".esync.toml") 315 os.WriteFile(dotFile, []byte("[sync]\n"), 0644) 316 317 got := FindConfigIn([]string{ 318 filepath.Join(dir, ".esync.toml"), 319 filepath.Join(dir, "esync.toml"), 320 }) 321 if got != dotFile { 322 t.Fatalf("expected %s, got %s", dotFile, got) 323 } 324 } 325 326 func TestFindConfigInReturnsEmpty(t *testing.T) { 327 got := FindConfigIn([]string{"/nonexistent/path"}) 328 if got != "" { 329 t.Fatalf("expected empty, got %s", got) 330 } 331 } 332 333 // ----------------------------------------------------------------------- 334 // 7. TestAllIgnorePatterns — combines both ignore lists 335 // ----------------------------------------------------------------------- 336 func TestAllIgnorePatterns(t *testing.T) { 337 cfg := &Config{ 338 Settings: Settings{ 339 Ignore: []string{".git", "node_modules"}, 340 Rsync: RsyncSettings{ 341 Ignore: []string{"*.tmp", "*.log"}, 342 }, 343 }, 344 } 345 346 patterns := cfg.AllIgnorePatterns() 347 expected := []string{".git", "node_modules", "*.tmp", "*.log"} 348 349 if len(patterns) != len(expected) { 350 t.Fatalf("AllIgnorePatterns length = %d, want %d", len(patterns), len(expected)) 351 } 352 for i, p := range patterns { 353 if p != expected[i] { 354 t.Errorf("AllIgnorePatterns[%d] = %q, want %q", i, p, expected[i]) 355 } 356 } 357 } 358 359 func TestAllIgnorePatternsEmpty(t *testing.T) { 360 cfg := &Config{} 361 patterns := cfg.AllIgnorePatterns() 362 if len(patterns) != 0 { 363 t.Errorf("AllIgnorePatterns = %v, want empty", patterns) 364 } 365 } 366 367 // ----------------------------------------------------------------------- 368 // 8. TestLoadConfigWithInclude — include field parsed correctly 369 // ----------------------------------------------------------------------- 370 func TestLoadConfigWithInclude(t *testing.T) { 371 toml := ` 372 [sync] 373 local = "/src" 374 remote = "/dst" 375 376 [settings] 377 include = ["src", "docs/api"] 378 ignore = [".git"] 379 ` 380 path := writeTempTOML(t, toml) 381 cfg, err := Load(path) 382 if err != nil { 383 t.Fatalf("Load returned error: %v", err) 384 } 385 386 if len(cfg.Settings.Include) != 2 { 387 t.Fatalf("Settings.Include length = %d, want 2", len(cfg.Settings.Include)) 388 } 389 if cfg.Settings.Include[0] != "src" || cfg.Settings.Include[1] != "docs/api" { 390 t.Errorf("Settings.Include = %v, want [src docs/api]", cfg.Settings.Include) 391 } 392 } 393 394 // ----------------------------------------------------------------------- 395 // 9. TestLoadConfigIncludeDefaultsToEmpty — omitted include is nil/empty 396 // ----------------------------------------------------------------------- 397 func TestLoadConfigIncludeDefaultsToEmpty(t *testing.T) { 398 toml := ` 399 [sync] 400 local = "/src" 401 remote = "/dst" 402 ` 403 path := writeTempTOML(t, toml) 404 cfg, err := Load(path) 405 if err != nil { 406 t.Fatalf("Load returned error: %v", err) 407 } 408 409 if cfg.Settings.Include == nil { 410 // nil is fine — treated as "include everything" 411 } else if len(cfg.Settings.Include) != 0 { 412 t.Errorf("Settings.Include = %v, want empty", cfg.Settings.Include) 413 } 414 } 415 416 // ----------------------------------------------------------------------- 417 // 10. TestDefaultTOML — returns a non-empty template 418 // ----------------------------------------------------------------------- 419 func TestDefaultTOML(t *testing.T) { 420 toml := DefaultTOML() 421 if toml == "" { 422 t.Error("DefaultTOML() returned empty string") 423 } 424 // Should contain key sections 425 for _, section := range []string{"[sync]", "[settings]", "[settings.rsync]", "[settings.log]"} { 426 if !containsString(toml, section) { 427 t.Errorf("DefaultTOML() missing section %q", section) 428 } 429 } 430 // Should document include field 431 if !containsString(toml, "include") { 432 t.Error("DefaultTOML() missing include field") 433 } 434 } 435 436 // ----------------------------------------------------------------------- 437 // 11. TestEditTemplateTOMLIsValidTOML — returns valid TOML with required fields 438 // ----------------------------------------------------------------------- 439 func TestEditTemplateTOMLIsValidTOML(t *testing.T) { 440 content := EditTemplateTOML() 441 if content == "" { 442 t.Fatal("EditTemplateTOML returned empty string") 443 } 444 if !strings.Contains(content, `local = "."`) { 445 t.Fatal("missing local field") 446 } 447 if !strings.Contains(content, `remote = "user@host:/path/to/dest"`) { 448 t.Fatal("missing remote field") 449 } 450 v := viper.New() 451 v.SetConfigType("toml") 452 if err := v.ReadConfig(strings.NewReader(content)); err != nil { 453 t.Fatalf("EditTemplateTOML is not valid TOML: %v", err) 454 } 455 } 456 457 func containsString(s, substr string) bool { 458 return len(s) >= len(substr) && searchString(s, substr) 459 } 460 461 func searchString(s, substr string) bool { 462 for i := 0; i <= len(s)-len(substr); i++ { 463 if s[i:i+len(substr)] == substr { 464 return true 465 } 466 } 467 return false 468 }