esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

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 }