2026-03-01-go-rewrite-plan.md (62831B)
1 # esync Go Rewrite — Implementation Plan 2 3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5 **Goal:** Rewrite esync from Python to Go with a Bubbletea TUI, Cobra CLI, and Viper-based TOML configuration. 6 7 **Architecture:** Cobra CLI dispatches to subcommands. `esync sync` launches either a Bubbletea TUI (default) or daemon mode. fsnotify watches files, debouncer batches events, syncer executes rsync. Viper loads TOML config with a search path. 8 9 **Tech Stack:** Go 1.22+, Cobra, Viper, Bubbletea, Lipgloss, fsnotify, rsync (external) 10 11 --- 12 13 ### Task 1: Project Scaffolding 14 15 **Files:** 16 - Create: `main.go` 17 - Create: `go.mod` 18 - Create: `cmd/root.go` 19 - Create: `internal/config/config.go` 20 - Create: `internal/syncer/syncer.go` 21 - Create: `internal/watcher/watcher.go` 22 - Create: `internal/tui/app.go` 23 - Create: `internal/logger/logger.go` 24 25 **Step 1: Remove Python source files** 26 27 Delete the Python package and build files (we're on a feature branch): 28 ```bash 29 rm -rf esync/ pyproject.toml uv.lock .python-version 30 ``` 31 32 **Step 2: Initialize Go module** 33 34 ```bash 35 go mod init github.com/eloualiche/esync 36 ``` 37 38 **Step 3: Create directory structure** 39 40 ```bash 41 mkdir -p cmd internal/config internal/syncer internal/watcher internal/tui internal/logger 42 ``` 43 44 **Step 4: Create minimal main.go** 45 46 ```go 47 package main 48 49 import "github.com/eloualiche/esync/cmd" 50 51 func main() { 52 cmd.Execute() 53 } 54 ``` 55 56 **Step 5: Create root command stub** 57 58 ```go 59 // cmd/root.go 60 package cmd 61 62 import ( 63 "fmt" 64 "os" 65 66 "github.com/spf13/cobra" 67 ) 68 69 var cfgFile string 70 71 var rootCmd = &cobra.Command{ 72 Use: "esync", 73 Short: "File synchronization tool using rsync", 74 Long: "A file sync tool that watches for changes and automatically syncs them to a remote destination using rsync.", 75 } 76 77 func Execute() { 78 if err := rootCmd.Execute(); err != nil { 79 fmt.Fprintln(os.Stderr, err) 80 os.Exit(1) 81 } 82 } 83 84 func init() { 85 rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path") 86 } 87 ``` 88 89 **Step 6: Install dependencies and verify build** 90 91 ```bash 92 go get github.com/spf13/cobra 93 go get github.com/spf13/viper 94 go get github.com/fsnotify/fsnotify 95 go get github.com/charmbracelet/bubbletea 96 go get github.com/charmbracelet/lipgloss 97 go mod tidy 98 go build ./... 99 ``` 100 101 **Step 7: Commit** 102 103 ```bash 104 git add -A 105 git commit -m "feat: scaffold Go project with Cobra root command" 106 ``` 107 108 --- 109 110 ### Task 2: Configuration Package 111 112 **Files:** 113 - Create: `internal/config/config.go` 114 - Create: `internal/config/config_test.go` 115 116 **Step 1: Write failing tests for config structs and loading** 117 118 ```go 119 // internal/config/config_test.go 120 package config 121 122 import ( 123 "os" 124 "path/filepath" 125 "testing" 126 ) 127 128 func TestLoadConfig(t *testing.T) { 129 dir := t.TempDir() 130 tomlPath := filepath.Join(dir, "esync.toml") 131 132 content := []byte(` 133 [sync] 134 local = "./src" 135 remote = "user@host:/deploy" 136 interval = 1 137 138 [settings] 139 watcher_debounce = 500 140 initial_sync = true 141 ignore = ["*.log", "*.tmp"] 142 143 [settings.rsync] 144 archive = true 145 compress = true 146 backup = true 147 backup_dir = ".rsync_backup" 148 progress = true 149 ignore = [".git/", "node_modules/"] 150 151 [settings.log] 152 file = "/tmp/esync.log" 153 format = "json" 154 `) 155 os.WriteFile(tomlPath, content, 0644) 156 157 cfg, err := Load(tomlPath) 158 if err != nil { 159 t.Fatalf("unexpected error: %v", err) 160 } 161 if cfg.Sync.Local != "./src" { 162 t.Errorf("expected local=./src, got %s", cfg.Sync.Local) 163 } 164 if cfg.Sync.Remote != "user@host:/deploy" { 165 t.Errorf("expected remote=user@host:/deploy, got %s", cfg.Sync.Remote) 166 } 167 if cfg.Settings.WatcherDebounce != 500 { 168 t.Errorf("expected debounce=500, got %d", cfg.Settings.WatcherDebounce) 169 } 170 if !cfg.Settings.InitialSync { 171 t.Error("expected initial_sync=true") 172 } 173 if len(cfg.Settings.Ignore) != 2 { 174 t.Errorf("expected 2 ignore patterns, got %d", len(cfg.Settings.Ignore)) 175 } 176 if !cfg.Settings.Rsync.Archive { 177 t.Error("expected rsync archive=true") 178 } 179 if cfg.Settings.Log.Format != "json" { 180 t.Errorf("expected log format=json, got %s", cfg.Settings.Log.Format) 181 } 182 } 183 184 func TestLoadConfigWithSSH(t *testing.T) { 185 dir := t.TempDir() 186 tomlPath := filepath.Join(dir, "esync.toml") 187 188 content := []byte(` 189 [sync] 190 local = "./src" 191 remote = "/deploy" 192 193 [sync.ssh] 194 host = "example.com" 195 user = "deploy" 196 port = 22 197 identity_file = "~/.ssh/id_ed25519" 198 interactive_auth = true 199 `) 200 os.WriteFile(tomlPath, content, 0644) 201 202 cfg, err := Load(tomlPath) 203 if err != nil { 204 t.Fatalf("unexpected error: %v", err) 205 } 206 if cfg.Sync.SSH == nil { 207 t.Fatal("expected SSH config to be set") 208 } 209 if cfg.Sync.SSH.Host != "example.com" { 210 t.Errorf("expected host=example.com, got %s", cfg.Sync.SSH.Host) 211 } 212 if cfg.Sync.SSH.User != "deploy" { 213 t.Errorf("expected user=deploy, got %s", cfg.Sync.SSH.User) 214 } 215 if cfg.Sync.SSH.IdentityFile != "~/.ssh/id_ed25519" { 216 t.Errorf("expected identity_file, got %s", cfg.Sync.SSH.IdentityFile) 217 } 218 } 219 220 func TestLoadConfigDefaults(t *testing.T) { 221 dir := t.TempDir() 222 tomlPath := filepath.Join(dir, "esync.toml") 223 224 content := []byte(` 225 [sync] 226 local = "./src" 227 remote = "./dst" 228 `) 229 os.WriteFile(tomlPath, content, 0644) 230 231 cfg, err := Load(tomlPath) 232 if err != nil { 233 t.Fatalf("unexpected error: %v", err) 234 } 235 if cfg.Settings.WatcherDebounce != 500 { 236 t.Errorf("expected default debounce=500, got %d", cfg.Settings.WatcherDebounce) 237 } 238 if cfg.Settings.Rsync.Archive != true { 239 t.Error("expected default archive=true") 240 } 241 } 242 243 func TestIsRemote(t *testing.T) { 244 tests := []struct { 245 remote string 246 want bool 247 }{ 248 {"user@host:/path", true}, 249 {"host:/path", true}, 250 {"./local/path", false}, 251 {"/absolute/path", false}, 252 {"C:/windows/path", false}, 253 } 254 for _, tt := range tests { 255 cfg := &Config{Sync: SyncSection{Remote: tt.remote}} 256 if got := cfg.IsRemote(); got != tt.want { 257 t.Errorf("IsRemote(%q) = %v, want %v", tt.remote, got, tt.want) 258 } 259 } 260 } 261 262 func TestFindConfigFile(t *testing.T) { 263 dir := t.TempDir() 264 tomlPath := filepath.Join(dir, "esync.toml") 265 os.WriteFile(tomlPath, []byte("[sync]\nlocal = \".\"\nremote = \".\"\n"), 0644) 266 267 found := FindConfigIn([]string{tomlPath}) 268 if found != tomlPath { 269 t.Errorf("expected %s, got %s", tomlPath, found) 270 } 271 } 272 273 func TestFindConfigFileNotFound(t *testing.T) { 274 found := FindConfigIn([]string{"/nonexistent/esync.toml"}) 275 if found != "" { 276 t.Errorf("expected empty string, got %s", found) 277 } 278 } 279 ``` 280 281 **Step 2: Run tests to verify they fail** 282 283 ```bash 284 cd internal/config && go test -v 285 ``` 286 Expected: compilation errors (types don't exist yet) 287 288 **Step 3: Implement config package** 289 290 ```go 291 // internal/config/config.go 292 package config 293 294 import ( 295 "fmt" 296 "os" 297 "path/filepath" 298 "regexp" 299 "strings" 300 301 "github.com/spf13/viper" 302 ) 303 304 // SSHConfig holds SSH connection settings. 305 type SSHConfig struct { 306 Host string `mapstructure:"host"` 307 User string `mapstructure:"user"` 308 Port int `mapstructure:"port"` 309 IdentityFile string `mapstructure:"identity_file"` 310 InteractiveAuth bool `mapstructure:"interactive_auth"` 311 } 312 313 // SyncSection holds source and destination paths. 314 type SyncSection struct { 315 Local string `mapstructure:"local"` 316 Remote string `mapstructure:"remote"` 317 Interval int `mapstructure:"interval"` 318 SSH *SSHConfig `mapstructure:"ssh"` 319 } 320 321 // RsyncSettings holds rsync-specific options. 322 type RsyncSettings struct { 323 Archive bool `mapstructure:"archive"` 324 Compress bool `mapstructure:"compress"` 325 Backup bool `mapstructure:"backup"` 326 BackupDir string `mapstructure:"backup_dir"` 327 Progress bool `mapstructure:"progress"` 328 ExtraArgs []string `mapstructure:"extra_args"` 329 Ignore []string `mapstructure:"ignore"` 330 } 331 332 // LogSettings holds logging configuration. 333 type LogSettings struct { 334 File string `mapstructure:"file"` 335 Format string `mapstructure:"format"` 336 } 337 338 // Settings holds all application settings. 339 type Settings struct { 340 WatcherDebounce int `mapstructure:"watcher_debounce"` 341 InitialSync bool `mapstructure:"initial_sync"` 342 Ignore []string `mapstructure:"ignore"` 343 Rsync RsyncSettings `mapstructure:"rsync"` 344 Log LogSettings `mapstructure:"log"` 345 } 346 347 // Config is the top-level configuration. 348 type Config struct { 349 Sync SyncSection `mapstructure:"sync"` 350 Settings Settings `mapstructure:"settings"` 351 } 352 353 // IsRemote returns true if the remote target is an SSH destination. 354 func (c *Config) IsRemote() bool { 355 if c.Sync.SSH != nil && c.Sync.SSH.Host != "" { 356 return true 357 } 358 return isRemotePath(c.Sync.Remote) 359 } 360 361 // isRemotePath checks if a path string looks like user@host:/path or host:/path. 362 func isRemotePath(path string) bool { 363 if len(path) >= 2 && path[1] == ':' && (path[0] >= 'A' && path[0] <= 'Z' || path[0] >= 'a' && path[0] <= 'z') { 364 return false // Windows drive letter 365 } 366 re := regexp.MustCompile(`^(?:[^@]+@)?[^/:]+:.+$`) 367 return re.MatchString(path) 368 } 369 370 // AllIgnorePatterns returns combined ignore patterns from settings and rsync. 371 func (c *Config) AllIgnorePatterns() []string { 372 combined := make([]string, 0, len(c.Settings.Ignore)+len(c.Settings.Rsync.Ignore)) 373 combined = append(combined, c.Settings.Ignore...) 374 combined = append(combined, c.Settings.Rsync.Ignore...) 375 return combined 376 } 377 378 // Load reads and parses a TOML config file. 379 func Load(path string) (*Config, error) { 380 v := viper.New() 381 v.SetConfigFile(path) 382 v.SetConfigType("toml") 383 384 // Defaults 385 v.SetDefault("sync.interval", 1) 386 v.SetDefault("settings.watcher_debounce", 500) 387 v.SetDefault("settings.initial_sync", false) 388 v.SetDefault("settings.rsync.archive", true) 389 v.SetDefault("settings.rsync.compress", true) 390 v.SetDefault("settings.rsync.backup", false) 391 v.SetDefault("settings.rsync.backup_dir", ".rsync_backup") 392 v.SetDefault("settings.rsync.progress", true) 393 v.SetDefault("settings.log.format", "text") 394 395 if err := v.ReadInConfig(); err != nil { 396 return nil, fmt.Errorf("reading config: %w", err) 397 } 398 399 var cfg Config 400 if err := v.Unmarshal(&cfg); err != nil { 401 return nil, fmt.Errorf("parsing config: %w", err) 402 } 403 404 if cfg.Sync.Local == "" { 405 return nil, fmt.Errorf("sync.local is required") 406 } 407 if cfg.Sync.Remote == "" { 408 return nil, fmt.Errorf("sync.remote is required") 409 } 410 411 return &cfg, nil 412 } 413 414 // FindConfigFile searches default locations for a config file. 415 func FindConfigFile() string { 416 home, _ := os.UserHomeDir() 417 paths := []string{ 418 filepath.Join(".", "esync.toml"), 419 filepath.Join(home, ".config", "esync", "config.toml"), 420 "/etc/esync/config.toml", 421 } 422 return FindConfigIn(paths) 423 } 424 425 // FindConfigIn searches the given paths for the first existing file. 426 func FindConfigIn(paths []string) string { 427 for _, p := range paths { 428 if _, err := os.Stat(p); err == nil { 429 return p 430 } 431 } 432 return "" 433 } 434 435 // DefaultTOML returns a default config as a TOML string. 436 func DefaultTOML() string { 437 return strings.TrimSpace(` 438 [sync] 439 local = "." 440 remote = "./remote" 441 interval = 1 442 443 # [sync.ssh] 444 # host = "example.com" 445 # user = "username" 446 # port = 22 447 # identity_file = "~/.ssh/id_ed25519" 448 # interactive_auth = true 449 450 [settings] 451 watcher_debounce = 500 452 initial_sync = false 453 ignore = ["*.log", "*.tmp", ".env"] 454 455 [settings.rsync] 456 archive = true 457 compress = true 458 backup = false 459 backup_dir = ".rsync_backup" 460 progress = true 461 extra_args = [] 462 ignore = [".git/", "node_modules/", "**/__pycache__/"] 463 464 [settings.log] 465 # file = "~/.local/share/esync/esync.log" 466 format = "text" 467 `) + "\n" 468 } 469 ``` 470 471 **Step 4: Run tests to verify they pass** 472 473 ```bash 474 cd internal/config && go test -v 475 ``` 476 Expected: all PASS 477 478 **Step 5: Commit** 479 480 ```bash 481 git add internal/config/ 482 git commit -m "feat: add config package with TOML loading, defaults, and search path" 483 ``` 484 485 --- 486 487 ### Task 3: Syncer Package 488 489 **Files:** 490 - Create: `internal/syncer/syncer.go` 491 - Create: `internal/syncer/syncer_test.go` 492 493 **Step 1: Write failing tests for rsync command building** 494 495 ```go 496 // internal/syncer/syncer_test.go 497 package syncer 498 499 import ( 500 "testing" 501 502 "github.com/eloualiche/esync/internal/config" 503 ) 504 505 func TestBuildCommand_Local(t *testing.T) { 506 cfg := &config.Config{ 507 Sync: config.SyncSection{ 508 Local: "/tmp/src", 509 Remote: "/tmp/dst", 510 }, 511 Settings: config.Settings{ 512 Rsync: config.RsyncSettings{ 513 Archive: true, 514 Compress: true, 515 Progress: true, 516 Ignore: []string{".git/", "node_modules/"}, 517 }, 518 }, 519 } 520 521 s := New(cfg) 522 cmd := s.BuildCommand() 523 524 if cmd[0] != "rsync" { 525 t.Errorf("expected rsync, got %s", cmd[0]) 526 } 527 if !contains(cmd, "--archive") { 528 t.Error("expected --archive flag") 529 } 530 if !contains(cmd, "--compress") { 531 t.Error("expected --compress flag") 532 } 533 // Source should end with / 534 source := cmd[len(cmd)-2] 535 if source[len(source)-1] != '/' { 536 t.Errorf("source should end with /, got %s", source) 537 } 538 } 539 540 func TestBuildCommand_Remote(t *testing.T) { 541 cfg := &config.Config{ 542 Sync: config.SyncSection{ 543 Local: "/tmp/src", 544 Remote: "user@host:/deploy", 545 }, 546 } 547 548 s := New(cfg) 549 cmd := s.BuildCommand() 550 551 dest := cmd[len(cmd)-1] 552 if dest != "user@host:/deploy" { 553 t.Errorf("expected user@host:/deploy, got %s", dest) 554 } 555 } 556 557 func TestBuildCommand_SSHConfig(t *testing.T) { 558 cfg := &config.Config{ 559 Sync: config.SyncSection{ 560 Local: "/tmp/src", 561 Remote: "/deploy", 562 SSH: &config.SSHConfig{ 563 Host: "example.com", 564 User: "deploy", 565 Port: 2222, 566 IdentityFile: "~/.ssh/id_ed25519", 567 }, 568 }, 569 } 570 571 s := New(cfg) 572 cmd := s.BuildCommand() 573 574 dest := cmd[len(cmd)-1] 575 if dest != "[email protected]:/deploy" { 576 t.Errorf("expected [email protected]:/deploy, got %s", dest) 577 } 578 if !containsPrefix(cmd, "-e") { 579 t.Error("expected -e flag for SSH") 580 } 581 } 582 583 func TestBuildCommand_ExcludePatterns(t *testing.T) { 584 cfg := &config.Config{ 585 Sync: config.SyncSection{ 586 Local: "/tmp/src", 587 Remote: "/tmp/dst", 588 }, 589 Settings: config.Settings{ 590 Ignore: []string{"*.log"}, 591 Rsync: config.RsyncSettings{ 592 Ignore: []string{".git/"}, 593 }, 594 }, 595 } 596 597 s := New(cfg) 598 cmd := s.BuildCommand() 599 600 excludeCount := 0 601 for _, arg := range cmd { 602 if arg == "--exclude" { 603 excludeCount++ 604 } 605 } 606 if excludeCount != 2 { 607 t.Errorf("expected 2 exclude flags, got %d", excludeCount) 608 } 609 } 610 611 func TestBuildCommand_ExtraArgs(t *testing.T) { 612 cfg := &config.Config{ 613 Sync: config.SyncSection{ 614 Local: "/tmp/src", 615 Remote: "/tmp/dst", 616 }, 617 Settings: config.Settings{ 618 Rsync: config.RsyncSettings{ 619 ExtraArgs: []string{"--delete", "--checksum"}, 620 }, 621 }, 622 } 623 624 s := New(cfg) 625 cmd := s.BuildCommand() 626 627 if !contains(cmd, "--delete") { 628 t.Error("expected --delete from extra_args") 629 } 630 if !contains(cmd, "--checksum") { 631 t.Error("expected --checksum from extra_args") 632 } 633 } 634 635 func TestBuildCommand_DryRun(t *testing.T) { 636 cfg := &config.Config{ 637 Sync: config.SyncSection{ 638 Local: "/tmp/src", 639 Remote: "/tmp/dst", 640 }, 641 } 642 643 s := New(cfg) 644 s.DryRun = true 645 cmd := s.BuildCommand() 646 647 if !contains(cmd, "--dry-run") { 648 t.Error("expected --dry-run flag") 649 } 650 } 651 652 func TestBuildCommand_Backup(t *testing.T) { 653 cfg := &config.Config{ 654 Sync: config.SyncSection{ 655 Local: "/tmp/src", 656 Remote: "/tmp/dst", 657 }, 658 Settings: config.Settings{ 659 Rsync: config.RsyncSettings{ 660 Backup: true, 661 BackupDir: ".backup", 662 }, 663 }, 664 } 665 666 s := New(cfg) 667 cmd := s.BuildCommand() 668 669 if !contains(cmd, "--backup") { 670 t.Error("expected --backup flag") 671 } 672 if !contains(cmd, "--backup-dir=.backup") { 673 t.Error("expected --backup-dir flag") 674 } 675 } 676 677 func contains(args []string, target string) bool { 678 for _, a := range args { 679 if a == target { 680 return true 681 } 682 } 683 return false 684 } 685 686 func containsPrefix(args []string, prefix string) bool { 687 for _, a := range args { 688 if len(a) >= len(prefix) && a[:len(prefix)] == prefix { 689 return true 690 } 691 } 692 return false 693 } 694 ``` 695 696 **Step 2: Run tests to verify they fail** 697 698 ```bash 699 go test ./internal/syncer/ -v 700 ``` 701 702 **Step 3: Implement syncer package** 703 704 ```go 705 // internal/syncer/syncer.go 706 package syncer 707 708 import ( 709 "fmt" 710 "os/exec" 711 "regexp" 712 "strconv" 713 "strings" 714 "time" 715 716 "github.com/eloualiche/esync/internal/config" 717 ) 718 719 // Result holds the outcome of a sync operation. 720 type Result struct { 721 Success bool 722 FilesCount int 723 BytesTotal int64 724 Duration time.Duration 725 Files []string 726 ErrorMessage string 727 } 728 729 // Syncer builds and executes rsync commands. 730 type Syncer struct { 731 cfg *config.Config 732 DryRun bool 733 } 734 735 // New creates a new Syncer. 736 func New(cfg *config.Config) *Syncer { 737 return &Syncer{cfg: cfg} 738 } 739 740 // BuildCommand constructs the rsync argument list. 741 func (s *Syncer) BuildCommand() []string { 742 cmd := []string{"rsync", "--recursive", "--times", "--progress", "--copy-unsafe-links"} 743 744 rs := s.cfg.Settings.Rsync 745 if rs.Archive { 746 cmd = append(cmd, "--archive") 747 } 748 if rs.Compress { 749 cmd = append(cmd, "--compress") 750 } 751 if rs.Backup { 752 cmd = append(cmd, "--backup") 753 cmd = append(cmd, fmt.Sprintf("--backup-dir=%s", rs.BackupDir)) 754 } 755 if s.DryRun { 756 cmd = append(cmd, "--dry-run") 757 } 758 759 // Exclude patterns 760 for _, pattern := range s.cfg.AllIgnorePatterns() { 761 clean := strings.Trim(pattern, "\"[]'") 762 if strings.HasPrefix(clean, "**/") { 763 clean = clean[3:] 764 } 765 cmd = append(cmd, "--exclude", clean) 766 } 767 768 // Extra args passthrough 769 cmd = append(cmd, rs.ExtraArgs...) 770 771 // SSH options 772 sshCmd := s.buildSSHCommand() 773 if sshCmd != "" { 774 cmd = append(cmd, "-e", sshCmd) 775 } 776 777 // Source (always ends with /) 778 source := s.cfg.Sync.Local 779 if !strings.HasSuffix(source, "/") { 780 source += "/" 781 } 782 cmd = append(cmd, source) 783 784 // Destination 785 cmd = append(cmd, s.buildDestination()) 786 787 return cmd 788 } 789 790 // Run executes the rsync command and returns the result. 791 func (s *Syncer) Run() (*Result, error) { 792 args := s.BuildCommand() 793 start := time.Now() 794 795 c := exec.Command(args[0], args[1:]...) 796 output, err := c.CombinedOutput() 797 duration := time.Since(start) 798 799 result := &Result{ 800 Duration: duration, 801 Files: extractFiles(string(output)), 802 } 803 804 if err != nil { 805 result.Success = false 806 result.ErrorMessage = strings.TrimSpace(string(output)) 807 if result.ErrorMessage == "" { 808 result.ErrorMessage = err.Error() 809 } 810 return result, err 811 } 812 813 result.Success = true 814 result.FilesCount, result.BytesTotal = extractStats(string(output)) 815 return result, nil 816 } 817 818 func (s *Syncer) buildSSHCommand() string { 819 ssh := s.cfg.Sync.SSH 820 if ssh == nil { 821 return "" 822 } 823 parts := []string{"ssh"} 824 if ssh.Port != 0 && ssh.Port != 22 { 825 parts = append(parts, fmt.Sprintf("-p %d", ssh.Port)) 826 } 827 if ssh.IdentityFile != "" { 828 parts = append(parts, fmt.Sprintf("-i %s", ssh.IdentityFile)) 829 } 830 // ControlMaster for SSH keepalive 831 parts = append(parts, "-o", "ControlMaster=auto") 832 parts = append(parts, "-o", "ControlPath=/tmp/esync-ssh-%r@%h:%p") 833 parts = append(parts, "-o", "ControlPersist=600") 834 if len(parts) == 1 { 835 return "" 836 } 837 return strings.Join(parts, " ") 838 } 839 840 func (s *Syncer) buildDestination() string { 841 ssh := s.cfg.Sync.SSH 842 if ssh != nil && ssh.Host != "" { 843 if ssh.User != "" { 844 return fmt.Sprintf("%s@%s:%s", ssh.User, ssh.Host, s.cfg.Sync.Remote) 845 } 846 return fmt.Sprintf("%s:%s", ssh.Host, s.cfg.Sync.Remote) 847 } 848 return s.cfg.Sync.Remote 849 } 850 851 func extractFiles(output string) []string { 852 var files []string 853 skip := regexp.MustCompile(`^(building|sending|sent|total|bytes|\s*$)`) 854 for _, line := range strings.Split(output, "\n") { 855 trimmed := strings.TrimSpace(line) 856 if trimmed == "" || skip.MatchString(trimmed) { 857 continue 858 } 859 parts := strings.Fields(trimmed) 860 if len(parts) > 0 && !strings.Contains(parts[0], "%") { 861 files = append(files, parts[0]) 862 } 863 } 864 return files 865 } 866 867 func extractStats(output string) (int, int64) { 868 fileRe := regexp.MustCompile(`(\d+) files? to consider`) 869 bytesRe := regexp.MustCompile(`sent ([\d,]+) bytes\s+received ([\d,]+) bytes`) 870 871 var count int 872 var total int64 873 874 if m := fileRe.FindStringSubmatch(output); len(m) > 1 { 875 count, _ = strconv.Atoi(m[1]) 876 } 877 if m := bytesRe.FindStringSubmatch(output); len(m) > 2 { 878 sent, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) 879 recv, _ := strconv.ParseInt(strings.ReplaceAll(m[2], ",", ""), 10, 64) 880 total = sent + recv 881 } 882 return count, total 883 } 884 ``` 885 886 **Step 4: Run tests** 887 888 ```bash 889 go test ./internal/syncer/ -v 890 ``` 891 Expected: all PASS 892 893 **Step 5: Commit** 894 895 ```bash 896 git add internal/syncer/ 897 git commit -m "feat: add syncer package with rsync command builder and SSH support" 898 ``` 899 900 --- 901 902 ### Task 4: Watcher Package 903 904 **Files:** 905 - Create: `internal/watcher/watcher.go` 906 - Create: `internal/watcher/watcher_test.go` 907 908 **Step 1: Write failing tests for debouncer** 909 910 ```go 911 // internal/watcher/watcher_test.go 912 package watcher 913 914 import ( 915 "sync/atomic" 916 "testing" 917 "time" 918 ) 919 920 func TestDebouncerBatchesEvents(t *testing.T) { 921 var callCount atomic.Int32 922 callback := func() { callCount.Add(1) } 923 924 d := NewDebouncer(100*time.Millisecond, callback) 925 defer d.Stop() 926 927 // Fire 5 events rapidly 928 for i := 0; i < 5; i++ { 929 d.Trigger() 930 time.Sleep(10 * time.Millisecond) 931 } 932 933 // Wait for debounce window to expire 934 time.Sleep(200 * time.Millisecond) 935 936 if count := callCount.Load(); count != 1 { 937 t.Errorf("expected 1 callback, got %d", count) 938 } 939 } 940 941 func TestDebouncerSeparateEvents(t *testing.T) { 942 var callCount atomic.Int32 943 callback := func() { callCount.Add(1) } 944 945 d := NewDebouncer(50*time.Millisecond, callback) 946 defer d.Stop() 947 948 d.Trigger() 949 time.Sleep(100 * time.Millisecond) // Wait for first debounce 950 951 d.Trigger() 952 time.Sleep(100 * time.Millisecond) // Wait for second debounce 953 954 if count := callCount.Load(); count != 2 { 955 t.Errorf("expected 2 callbacks, got %d", count) 956 } 957 } 958 ``` 959 960 **Step 2: Run tests to verify they fail** 961 962 ```bash 963 go test ./internal/watcher/ -v 964 ``` 965 966 **Step 3: Implement watcher package** 967 968 ```go 969 // internal/watcher/watcher.go 970 package watcher 971 972 import ( 973 "log" 974 "path/filepath" 975 "sync" 976 "time" 977 978 "github.com/fsnotify/fsnotify" 979 ) 980 981 // Debouncer batches rapid events into a single callback. 982 type Debouncer struct { 983 interval time.Duration 984 callback func() 985 timer *time.Timer 986 mu sync.Mutex 987 stopped bool 988 } 989 990 // NewDebouncer creates a debouncer with the given interval. 991 func NewDebouncer(interval time.Duration, callback func()) *Debouncer { 992 return &Debouncer{ 993 interval: interval, 994 callback: callback, 995 } 996 } 997 998 // Trigger resets the debounce timer. 999 func (d *Debouncer) Trigger() { 1000 d.mu.Lock() 1001 defer d.mu.Unlock() 1002 if d.stopped { 1003 return 1004 } 1005 if d.timer != nil { 1006 d.timer.Stop() 1007 } 1008 d.timer = time.AfterFunc(d.interval, d.callback) 1009 } 1010 1011 // Stop cancels any pending callback. 1012 func (d *Debouncer) Stop() { 1013 d.mu.Lock() 1014 defer d.mu.Unlock() 1015 d.stopped = true 1016 if d.timer != nil { 1017 d.timer.Stop() 1018 } 1019 } 1020 1021 // EventHandler is called when files change. 1022 type EventHandler func() 1023 1024 // Watcher monitors a directory for changes using fsnotify. 1025 type Watcher struct { 1026 fsw *fsnotify.Watcher 1027 debouncer *Debouncer 1028 path string 1029 ignores []string 1030 done chan struct{} 1031 } 1032 1033 // New creates a file watcher for the given path. 1034 func New(path string, debounceMs int, ignores []string, handler EventHandler) (*Watcher, error) { 1035 fsw, err := fsnotify.NewWatcher() 1036 if err != nil { 1037 return nil, err 1038 } 1039 1040 interval := time.Duration(debounceMs) * time.Millisecond 1041 if interval == 0 { 1042 interval = 500 * time.Millisecond 1043 } 1044 1045 w := &Watcher{ 1046 fsw: fsw, 1047 debouncer: NewDebouncer(interval, handler), 1048 path: path, 1049 ignores: ignores, 1050 done: make(chan struct{}), 1051 } 1052 1053 return w, nil 1054 } 1055 1056 // Start begins watching for file changes. 1057 func (w *Watcher) Start() error { 1058 if err := w.addRecursive(w.path); err != nil { 1059 return err 1060 } 1061 1062 go w.loop() 1063 return nil 1064 } 1065 1066 // Stop ends the watcher. 1067 func (w *Watcher) Stop() { 1068 w.debouncer.Stop() 1069 w.fsw.Close() 1070 <-w.done 1071 } 1072 1073 // Paused tracks whether watching is paused. 1074 var Paused bool 1075 1076 func (w *Watcher) loop() { 1077 defer close(w.done) 1078 for { 1079 select { 1080 case event, ok := <-w.fsw.Events: 1081 if !ok { 1082 return 1083 } 1084 if Paused { 1085 continue 1086 } 1087 if w.shouldIgnore(event.Name) { 1088 continue 1089 } 1090 if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { 1091 // If a directory was created, watch it too 1092 if event.Op&fsnotify.Create != 0 { 1093 w.addRecursive(event.Name) 1094 } 1095 w.debouncer.Trigger() 1096 } 1097 case err, ok := <-w.fsw.Errors: 1098 if !ok { 1099 return 1100 } 1101 log.Printf("watcher error: %v", err) 1102 } 1103 } 1104 } 1105 1106 func (w *Watcher) shouldIgnore(path string) bool { 1107 base := filepath.Base(path) 1108 for _, pattern := range w.ignores { 1109 if matched, _ := filepath.Match(pattern, base); matched { 1110 return true 1111 } 1112 if matched, _ := filepath.Match(pattern, path); matched { 1113 return true 1114 } 1115 } 1116 return false 1117 } 1118 1119 func (w *Watcher) addRecursive(path string) error { 1120 return filepath.Walk(path, func(p string, info interface{}, err error) error { 1121 if err != nil { 1122 return nil // skip errors 1123 } 1124 // Only add directories 1125 return w.fsw.Add(p) 1126 }) 1127 } 1128 ``` 1129 1130 Note: `addRecursive` uses `filepath.Walk` which needs `os.FileInfo`, not `interface{}`. The actual implementation should use the correct signature. The executing agent will fix this during implementation. 1131 1132 **Step 4: Run tests** 1133 1134 ```bash 1135 go test ./internal/watcher/ -v 1136 ``` 1137 Expected: all PASS 1138 1139 **Step 5: Commit** 1140 1141 ```bash 1142 git add internal/watcher/ 1143 git commit -m "feat: add watcher package with fsnotify and debouncing" 1144 ``` 1145 1146 --- 1147 1148 ### Task 5: Logger Package 1149 1150 **Files:** 1151 - Create: `internal/logger/logger.go` 1152 - Create: `internal/logger/logger_test.go` 1153 1154 **Step 1: Write failing tests** 1155 1156 ```go 1157 // internal/logger/logger_test.go 1158 package logger 1159 1160 import ( 1161 "encoding/json" 1162 "os" 1163 "path/filepath" 1164 "strings" 1165 "testing" 1166 ) 1167 1168 func TestJSONLogger(t *testing.T) { 1169 dir := t.TempDir() 1170 logPath := filepath.Join(dir, "test.log") 1171 1172 l, err := New(logPath, "json") 1173 if err != nil { 1174 t.Fatalf("unexpected error: %v", err) 1175 } 1176 defer l.Close() 1177 1178 l.Info("synced", map[string]interface{}{ 1179 "file": "main.go", 1180 "size": 2150, 1181 }) 1182 1183 data, _ := os.ReadFile(logPath) 1184 lines := strings.TrimSpace(string(data)) 1185 1186 var entry map[string]interface{} 1187 if err := json.Unmarshal([]byte(lines), &entry); err != nil { 1188 t.Fatalf("invalid JSON: %v\nline: %s", err, lines) 1189 } 1190 if entry["level"] != "info" { 1191 t.Errorf("expected level=info, got %v", entry["level"]) 1192 } 1193 if entry["event"] != "synced" { 1194 t.Errorf("expected event=synced, got %v", entry["event"]) 1195 } 1196 } 1197 1198 func TestTextLogger(t *testing.T) { 1199 dir := t.TempDir() 1200 logPath := filepath.Join(dir, "test.log") 1201 1202 l, err := New(logPath, "text") 1203 if err != nil { 1204 t.Fatalf("unexpected error: %v", err) 1205 } 1206 defer l.Close() 1207 1208 l.Info("synced", map[string]interface{}{"file": "main.go"}) 1209 1210 data, _ := os.ReadFile(logPath) 1211 line := string(data) 1212 if !strings.Contains(line, "INF") { 1213 t.Errorf("expected INF in text log, got: %s", line) 1214 } 1215 if !strings.Contains(line, "synced") { 1216 t.Errorf("expected 'synced' in text log, got: %s", line) 1217 } 1218 } 1219 ``` 1220 1221 **Step 2: Run tests to verify they fail** 1222 1223 ```bash 1224 go test ./internal/logger/ -v 1225 ``` 1226 1227 **Step 3: Implement logger** 1228 1229 ```go 1230 // internal/logger/logger.go 1231 package logger 1232 1233 import ( 1234 "encoding/json" 1235 "fmt" 1236 "os" 1237 "strings" 1238 "sync" 1239 "time" 1240 ) 1241 1242 // Logger writes structured log entries to a file. 1243 type Logger struct { 1244 file *os.File 1245 format string // "json" or "text" 1246 mu sync.Mutex 1247 } 1248 1249 // New creates a logger writing to the given path. 1250 func New(path string, format string) (*Logger, error) { 1251 if format == "" { 1252 format = "text" 1253 } 1254 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 1255 if err != nil { 1256 return nil, err 1257 } 1258 return &Logger{file: f, format: format}, nil 1259 } 1260 1261 // Close closes the log file. 1262 func (l *Logger) Close() { 1263 if l.file != nil { 1264 l.file.Close() 1265 } 1266 } 1267 1268 // Info logs an info-level entry. 1269 func (l *Logger) Info(event string, fields map[string]interface{}) { 1270 l.log("info", event, fields) 1271 } 1272 1273 // Warn logs a warning-level entry. 1274 func (l *Logger) Warn(event string, fields map[string]interface{}) { 1275 l.log("warn", event, fields) 1276 } 1277 1278 // Error logs an error-level entry. 1279 func (l *Logger) Error(event string, fields map[string]interface{}) { 1280 l.log("error", event, fields) 1281 } 1282 1283 // Debug logs a debug-level entry. 1284 func (l *Logger) Debug(event string, fields map[string]interface{}) { 1285 l.log("debug", event, fields) 1286 } 1287 1288 func (l *Logger) log(level, event string, fields map[string]interface{}) { 1289 l.mu.Lock() 1290 defer l.mu.Unlock() 1291 1292 now := time.Now().Format("15:04:05") 1293 1294 if l.format == "json" { 1295 entry := map[string]interface{}{ 1296 "time": now, 1297 "level": level, 1298 "event": event, 1299 } 1300 for k, v := range fields { 1301 entry[k] = v 1302 } 1303 data, _ := json.Marshal(entry) 1304 fmt.Fprintln(l.file, string(data)) 1305 } else { 1306 tag := strings.ToUpper(level[:3]) 1307 parts := []string{fmt.Sprintf("%s %s %s", now, tag, event)} 1308 for k, v := range fields { 1309 parts = append(parts, fmt.Sprintf("%s=%v", k, v)) 1310 } 1311 fmt.Fprintln(l.file, strings.Join(parts, " ")) 1312 } 1313 } 1314 ``` 1315 1316 **Step 4: Run tests** 1317 1318 ```bash 1319 go test ./internal/logger/ -v 1320 ``` 1321 Expected: all PASS 1322 1323 **Step 5: Commit** 1324 1325 ```bash 1326 git add internal/logger/ 1327 git commit -m "feat: add logger package with JSON and text output" 1328 ``` 1329 1330 --- 1331 1332 ### Task 6: TUI — Styles and Dashboard 1333 1334 **Files:** 1335 - Create: `internal/tui/styles.go` 1336 - Create: `internal/tui/dashboard.go` 1337 - Create: `internal/tui/app.go` 1338 1339 **Step 1: Create Lipgloss styles** 1340 1341 ```go 1342 // internal/tui/styles.go 1343 package tui 1344 1345 import "github.com/charmbracelet/lipgloss" 1346 1347 var ( 1348 titleStyle = lipgloss.NewStyle(). 1349 Bold(true). 1350 Foreground(lipgloss.Color("12")) // blue 1351 1352 statusSynced = lipgloss.NewStyle(). 1353 Foreground(lipgloss.Color("10")) // green 1354 1355 statusSyncing = lipgloss.NewStyle(). 1356 Foreground(lipgloss.Color("11")) // yellow 1357 1358 statusError = lipgloss.NewStyle(). 1359 Foreground(lipgloss.Color("9")) // red 1360 1361 dimStyle = lipgloss.NewStyle(). 1362 Foreground(lipgloss.Color("8")) // dim gray 1363 1364 sectionStyle = lipgloss.NewStyle(). 1365 BorderStyle(lipgloss.NormalBorder()). 1366 BorderBottom(true). 1367 BorderForeground(lipgloss.Color("8")) 1368 1369 helpStyle = lipgloss.NewStyle(). 1370 Foreground(lipgloss.Color("8")) 1371 ) 1372 ``` 1373 1374 **Step 2: Create dashboard Bubbletea model** 1375 1376 ```go 1377 // internal/tui/dashboard.go 1378 package tui 1379 1380 import ( 1381 "fmt" 1382 "strings" 1383 "time" 1384 1385 tea "github.com/charmbracelet/bubbletea" 1386 ) 1387 1388 // SyncEvent represents a file sync event for display. 1389 type SyncEvent struct { 1390 File string 1391 Size string 1392 Duration time.Duration 1393 Status string // "synced", "syncing", "error" 1394 Time time.Time 1395 } 1396 1397 // DashboardModel is the main TUI view. 1398 type DashboardModel struct { 1399 local string 1400 remote string 1401 status string // "watching", "syncing", "paused", "error" 1402 lastSync time.Time 1403 events []SyncEvent 1404 totalSynced int 1405 totalBytes string 1406 totalErrors int 1407 width int 1408 height int 1409 filter string 1410 filtering bool 1411 } 1412 1413 // NewDashboard creates the dashboard model. 1414 func NewDashboard(local, remote string) DashboardModel { 1415 return DashboardModel{ 1416 local: local, 1417 remote: remote, 1418 status: "watching", 1419 events: []SyncEvent{}, 1420 } 1421 } 1422 1423 func (m DashboardModel) Init() tea.Cmd { 1424 return tickCmd() 1425 } 1426 1427 // tickMsg triggers periodic UI refresh. 1428 type tickMsg time.Time 1429 1430 func tickCmd() tea.Cmd { 1431 return tea.Tick(time.Second, func(t time.Time) tea.Msg { 1432 return tickMsg(t) 1433 }) 1434 } 1435 1436 // SyncEventMsg delivers a sync event to the TUI. 1437 type SyncEventMsg SyncEvent 1438 1439 // SyncStatsMsg updates aggregate stats. 1440 type SyncStatsMsg struct { 1441 TotalSynced int 1442 TotalBytes string 1443 TotalErrors int 1444 } 1445 1446 func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 1447 switch msg := msg.(type) { 1448 case tea.KeyMsg: 1449 if m.filtering { 1450 switch msg.String() { 1451 case "enter", "esc": 1452 m.filtering = false 1453 if msg.String() == "esc" { 1454 m.filter = "" 1455 } 1456 return m, nil 1457 case "backspace": 1458 if len(m.filter) > 0 { 1459 m.filter = m.filter[:len(m.filter)-1] 1460 } 1461 return m, nil 1462 default: 1463 if len(msg.String()) == 1 { 1464 m.filter += msg.String() 1465 } 1466 return m, nil 1467 } 1468 } 1469 1470 switch msg.String() { 1471 case "q", "ctrl+c": 1472 return m, tea.Quit 1473 case "p": 1474 if m.status == "paused" { 1475 m.status = "watching" 1476 } else if m.status == "watching" { 1477 m.status = "paused" 1478 } 1479 return m, nil 1480 case "/": 1481 m.filtering = true 1482 m.filter = "" 1483 return m, nil 1484 } 1485 1486 case tea.WindowSizeMsg: 1487 m.width = msg.Width 1488 m.height = msg.Height 1489 1490 case tickMsg: 1491 return m, tickCmd() 1492 1493 case SyncEventMsg: 1494 e := SyncEvent(msg) 1495 m.events = append([]SyncEvent{e}, m.events...) 1496 if len(m.events) > 100 { 1497 m.events = m.events[:100] 1498 } 1499 if e.Status == "synced" { 1500 m.lastSync = e.Time 1501 } 1502 1503 case SyncStatsMsg: 1504 m.totalSynced = msg.TotalSynced 1505 m.totalBytes = msg.TotalBytes 1506 m.totalErrors = msg.TotalErrors 1507 } 1508 1509 return m, nil 1510 } 1511 1512 func (m DashboardModel) View() string { 1513 var b strings.Builder 1514 1515 // Header 1516 title := titleStyle.Render(" esync ") 1517 separator := dimStyle.Render(strings.Repeat("─", max(0, m.width-8))) 1518 b.WriteString(title + separator + "\n") 1519 b.WriteString(fmt.Sprintf(" %s → %s\n", m.local, m.remote)) 1520 1521 // Status 1522 var statusStr string 1523 switch m.status { 1524 case "watching": 1525 ago := "" 1526 if !m.lastSync.IsZero() { 1527 ago = fmt.Sprintf(" (synced %s ago)", time.Since(m.lastSync).Round(time.Second)) 1528 } 1529 statusStr = statusSynced.Render("●") + " Watching" + dimStyle.Render(ago) 1530 case "syncing": 1531 statusStr = statusSyncing.Render("⟳") + " Syncing..." 1532 case "paused": 1533 statusStr = dimStyle.Render("⏸") + " Paused" 1534 case "error": 1535 statusStr = statusError.Render("✗") + " Error" 1536 } 1537 b.WriteString(" " + statusStr + "\n\n") 1538 1539 // Recent events 1540 b.WriteString(" " + dimStyle.Render("Recent "+strings.Repeat("─", max(0, m.width-12))) + "\n") 1541 filtered := m.filteredEvents() 1542 shown := min(10, len(filtered)) 1543 for i := 0; i < shown; i++ { 1544 e := filtered[i] 1545 var icon string 1546 switch e.Status { 1547 case "synced": 1548 icon = statusSynced.Render("✓") 1549 case "syncing": 1550 icon = statusSyncing.Render("⟳") 1551 case "error": 1552 icon = statusError.Render("✗") 1553 } 1554 dur := "" 1555 if e.Duration > 0 { 1556 dur = dimStyle.Render(fmt.Sprintf("%.1fs", e.Duration.Seconds())) 1557 } 1558 b.WriteString(fmt.Sprintf(" %s %-30s %8s %s\n", icon, e.File, e.Size, dur)) 1559 } 1560 b.WriteString("\n") 1561 1562 // Stats 1563 b.WriteString(" " + dimStyle.Render("Stats "+strings.Repeat("─", max(0, m.width-10))) + "\n") 1564 stats := fmt.Sprintf(" %d synced │ %s total │ %d errors", 1565 m.totalSynced, m.totalBytes, m.totalErrors) 1566 b.WriteString(dimStyle.Render(stats) + "\n\n") 1567 1568 // Help bar 1569 help := " q quit p pause r full resync l logs d dry-run / filter" 1570 if m.filtering { 1571 help = fmt.Sprintf(" filter: %s█ (enter to apply, esc to cancel)", m.filter) 1572 } 1573 b.WriteString(helpStyle.Render(help) + "\n") 1574 1575 return b.String() 1576 } 1577 1578 func (m DashboardModel) filteredEvents() []SyncEvent { 1579 if m.filter == "" { 1580 return m.events 1581 } 1582 var filtered []SyncEvent 1583 for _, e := range m.events { 1584 if strings.Contains(strings.ToLower(e.File), strings.ToLower(m.filter)) { 1585 filtered = append(filtered, e) 1586 } 1587 } 1588 return filtered 1589 } 1590 1591 func max(a, b int) int { 1592 if a > b { 1593 return a 1594 } 1595 return b 1596 } 1597 1598 func min(a, b int) int { 1599 if a < b { 1600 return a 1601 } 1602 return b 1603 } 1604 ``` 1605 1606 **Step 3: Create log view model** 1607 1608 ```go 1609 // internal/tui/logview.go 1610 package tui 1611 1612 import ( 1613 "fmt" 1614 "strings" 1615 "time" 1616 1617 tea "github.com/charmbracelet/bubbletea" 1618 ) 1619 1620 // LogEntry is a single log line. 1621 type LogEntry struct { 1622 Time time.Time 1623 Level string // "INF", "WRN", "ERR" 1624 Message string 1625 } 1626 1627 // LogViewModel shows scrollable logs. 1628 type LogViewModel struct { 1629 entries []LogEntry 1630 offset int 1631 width int 1632 height int 1633 filter string 1634 filtering bool 1635 } 1636 1637 // NewLogView creates an empty log view. 1638 func NewLogView() LogViewModel { 1639 return LogViewModel{} 1640 } 1641 1642 func (m LogViewModel) Init() tea.Cmd { return nil } 1643 1644 func (m LogViewModel) Update(msg tea.Msg) (LogViewModel, tea.Cmd) { 1645 switch msg := msg.(type) { 1646 case tea.KeyMsg: 1647 if m.filtering { 1648 switch msg.String() { 1649 case "enter", "esc": 1650 m.filtering = false 1651 if msg.String() == "esc" { 1652 m.filter = "" 1653 } 1654 case "backspace": 1655 if len(m.filter) > 0 { 1656 m.filter = m.filter[:len(m.filter)-1] 1657 } 1658 default: 1659 if len(msg.String()) == 1 { 1660 m.filter += msg.String() 1661 } 1662 } 1663 return m, nil 1664 } 1665 1666 switch msg.String() { 1667 case "up", "k": 1668 if m.offset > 0 { 1669 m.offset-- 1670 } 1671 case "down", "j": 1672 m.offset++ 1673 case "/": 1674 m.filtering = true 1675 m.filter = "" 1676 } 1677 1678 case tea.WindowSizeMsg: 1679 m.width = msg.Width 1680 m.height = msg.Height 1681 } 1682 1683 return m, nil 1684 } 1685 1686 func (m LogViewModel) View() string { 1687 var b strings.Builder 1688 1689 title := titleStyle.Render(" esync ─ logs ") 1690 separator := dimStyle.Render(strings.Repeat("─", max(0, m.width-16))) 1691 b.WriteString(title + separator + "\n") 1692 1693 filtered := m.filteredEntries() 1694 visible := m.height - 4 // header + help 1695 if visible < 1 { 1696 visible = 10 1697 } 1698 1699 start := m.offset 1700 if start > len(filtered)-visible { 1701 start = max(0, len(filtered)-visible) 1702 } 1703 end := min(start+visible, len(filtered)) 1704 1705 for i := start; i < end; i++ { 1706 e := filtered[i] 1707 ts := dimStyle.Render(e.Time.Format("15:04:05")) 1708 var lvl string 1709 switch e.Level { 1710 case "INF": 1711 lvl = statusSynced.Render("INF") 1712 case "WRN": 1713 lvl = statusSyncing.Render("WRN") 1714 case "ERR": 1715 lvl = statusError.Render("ERR") 1716 default: 1717 lvl = dimStyle.Render(e.Level) 1718 } 1719 b.WriteString(fmt.Sprintf(" %s %s %s\n", ts, lvl, e.Message)) 1720 } 1721 1722 b.WriteString("\n") 1723 help := " ↑↓ scroll / filter l back q quit" 1724 if m.filtering { 1725 help = fmt.Sprintf(" filter: %s█ (enter to apply, esc to cancel)", m.filter) 1726 } 1727 b.WriteString(helpStyle.Render(help) + "\n") 1728 1729 return b.String() 1730 } 1731 1732 func (m LogViewModel) filteredEntries() []LogEntry { 1733 if m.filter == "" { 1734 return m.entries 1735 } 1736 var out []LogEntry 1737 for _, e := range m.entries { 1738 if strings.Contains(strings.ToLower(e.Message), strings.ToLower(m.filter)) { 1739 out = append(out, e) 1740 } 1741 } 1742 return out 1743 } 1744 1745 // AddEntry adds a log entry (called from outside the TUI update loop via a Cmd). 1746 func (m *LogViewModel) AddEntry(entry LogEntry) { 1747 m.entries = append(m.entries, entry) 1748 } 1749 ``` 1750 1751 **Step 4: Create app model (root TUI that switches between dashboard and log view)** 1752 1753 ```go 1754 // internal/tui/app.go 1755 package tui 1756 1757 import ( 1758 tea "github.com/charmbracelet/bubbletea" 1759 ) 1760 1761 type view int 1762 1763 const ( 1764 viewDashboard view = iota 1765 viewLogs 1766 ) 1767 1768 // AppModel is the root Bubbletea model. 1769 type AppModel struct { 1770 dashboard DashboardModel 1771 logView LogViewModel 1772 current view 1773 // Channels for external events 1774 syncEvents chan SyncEvent 1775 logEntries chan LogEntry 1776 } 1777 1778 // NewApp creates the root TUI model. 1779 func NewApp(local, remote string) *AppModel { 1780 return &AppModel{ 1781 dashboard: NewDashboard(local, remote), 1782 logView: NewLogView(), 1783 current: viewDashboard, 1784 syncEvents: make(chan SyncEvent, 100), 1785 logEntries: make(chan LogEntry, 100), 1786 } 1787 } 1788 1789 // SyncEventChan returns the channel to send sync events to the TUI. 1790 func (m *AppModel) SyncEventChan() chan<- SyncEvent { 1791 return m.syncEvents 1792 } 1793 1794 // LogEntryChan returns the channel to send log entries to the TUI. 1795 func (m *AppModel) LogEntryChan() chan<- LogEntry { 1796 return m.logEntries 1797 } 1798 1799 func (m *AppModel) Init() tea.Cmd { 1800 return tea.Batch( 1801 m.dashboard.Init(), 1802 m.waitForSyncEvent(), 1803 m.waitForLogEntry(), 1804 ) 1805 } 1806 1807 func (m *AppModel) waitForSyncEvent() tea.Cmd { 1808 return func() tea.Msg { 1809 e := <-m.syncEvents 1810 return SyncEventMsg(e) 1811 } 1812 } 1813 1814 func (m *AppModel) waitForLogEntry() tea.Cmd { 1815 return func() tea.Msg { 1816 return <-m.logEntries 1817 } 1818 } 1819 1820 func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 1821 switch msg := msg.(type) { 1822 case tea.KeyMsg: 1823 switch msg.String() { 1824 case "l": 1825 if m.current == viewDashboard { 1826 m.current = viewLogs 1827 } else { 1828 m.current = viewDashboard 1829 } 1830 return m, nil 1831 case "q", "ctrl+c": 1832 return m, tea.Quit 1833 } 1834 1835 case SyncEventMsg: 1836 var cmd tea.Cmd 1837 var model tea.Model 1838 model, cmd = m.dashboard.Update(msg) 1839 m.dashboard = model.(DashboardModel) 1840 return m, tea.Batch(cmd, m.waitForSyncEvent()) 1841 1842 case LogEntry: 1843 m.logView.AddEntry(msg) 1844 return m, m.waitForLogEntry() 1845 } 1846 1847 // Delegate to current view 1848 switch m.current { 1849 case viewDashboard: 1850 var cmd tea.Cmd 1851 var model tea.Model 1852 model, cmd = m.dashboard.Update(msg) 1853 m.dashboard = model.(DashboardModel) 1854 return m, cmd 1855 case viewLogs: 1856 var cmd tea.Cmd 1857 m.logView, cmd = m.logView.Update(msg) 1858 return m, cmd 1859 } 1860 1861 return m, nil 1862 } 1863 1864 func (m *AppModel) View() string { 1865 switch m.current { 1866 case viewLogs: 1867 return m.logView.View() 1868 default: 1869 return m.dashboard.View() 1870 } 1871 } 1872 ``` 1873 1874 **Step 5: Verify build** 1875 1876 ```bash 1877 go build ./... 1878 ``` 1879 1880 **Step 6: Commit** 1881 1882 ```bash 1883 git add internal/tui/ 1884 git commit -m "feat: add TUI with dashboard, log view, and Lipgloss styles" 1885 ``` 1886 1887 --- 1888 1889 ### Task 7: CLI Commands — sync 1890 1891 **Files:** 1892 - Create: `cmd/sync.go` 1893 - Modify: `cmd/root.go` 1894 1895 **Step 1: Implement sync command** 1896 1897 ```go 1898 // cmd/sync.go 1899 package cmd 1900 1901 import ( 1902 "fmt" 1903 "os" 1904 "time" 1905 1906 tea "github.com/charmbracelet/bubbletea" 1907 "github.com/spf13/cobra" 1908 1909 "github.com/eloualiche/esync/internal/config" 1910 "github.com/eloualiche/esync/internal/logger" 1911 "github.com/eloualiche/esync/internal/syncer" 1912 "github.com/eloualiche/esync/internal/tui" 1913 "github.com/eloualiche/esync/internal/watcher" 1914 ) 1915 1916 var ( 1917 localPath string 1918 remotePath string 1919 daemon bool 1920 dryRun bool 1921 initialSync bool 1922 verbose bool 1923 ) 1924 1925 var syncCmd = &cobra.Command{ 1926 Use: "sync", 1927 Short: "Start watching and syncing files", 1928 Long: "Watch a local directory for changes and sync them to a remote destination using rsync.", 1929 RunE: runSync, 1930 } 1931 1932 func init() { 1933 syncCmd.Flags().StringVarP(&localPath, "local", "l", "", "local path to sync from") 1934 syncCmd.Flags().StringVarP(&remotePath, "remote", "r", "", "remote path to sync to") 1935 syncCmd.Flags().BoolVar(&daemon, "daemon", false, "run without TUI, log to file") 1936 syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would sync without executing") 1937 syncCmd.Flags().BoolVar(&initialSync, "initial-sync", false, "force full sync on startup") 1938 syncCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") 1939 1940 rootCmd.AddCommand(syncCmd) 1941 } 1942 1943 func runSync(cmd *cobra.Command, args []string) error { 1944 cfg, err := loadOrBuildConfig() 1945 if err != nil { 1946 return err 1947 } 1948 1949 // CLI overrides 1950 if localPath != "" { 1951 cfg.Sync.Local = localPath 1952 } 1953 if remotePath != "" { 1954 cfg.Sync.Remote = remotePath 1955 } 1956 if initialSync { 1957 cfg.Settings.InitialSync = true 1958 } 1959 1960 if cfg.Sync.Local == "" || cfg.Sync.Remote == "" { 1961 return fmt.Errorf("both local and remote paths are required (use -l and -r, or a config file)") 1962 } 1963 1964 s := syncer.New(cfg) 1965 s.DryRun = dryRun 1966 1967 // Optional initial sync 1968 if cfg.Settings.InitialSync { 1969 fmt.Println("Running initial sync...") 1970 if result, err := s.Run(); err != nil { 1971 fmt.Fprintf(os.Stderr, "initial sync failed: %s\n", result.ErrorMessage) 1972 } 1973 } 1974 1975 if daemon { 1976 return runDaemon(cfg, s) 1977 } 1978 return runTUI(cfg, s) 1979 } 1980 1981 func runTUI(cfg *config.Config, s *syncer.Syncer) error { 1982 app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote) 1983 1984 // Set up watcher 1985 handler := func() { 1986 result, err := s.Run() 1987 event := tui.SyncEvent{ 1988 Time: time.Now(), 1989 } 1990 if err != nil { 1991 event.Status = "error" 1992 event.File = result.ErrorMessage 1993 } else { 1994 event.Status = "synced" 1995 event.Duration = result.Duration 1996 if len(result.Files) > 0 { 1997 event.File = result.Files[0] 1998 } else { 1999 event.File = "(no changes)" 2000 } 2001 event.Size = formatSize(result.BytesTotal) 2002 } 2003 app.SyncEventChan() <- event 2004 } 2005 2006 w, err := watcher.New( 2007 cfg.Sync.Local, 2008 cfg.Settings.WatcherDebounce, 2009 cfg.AllIgnorePatterns(), 2010 handler, 2011 ) 2012 if err != nil { 2013 return fmt.Errorf("creating watcher: %w", err) 2014 } 2015 2016 if err := w.Start(); err != nil { 2017 return fmt.Errorf("starting watcher: %w", err) 2018 } 2019 defer w.Stop() 2020 2021 p := tea.NewProgram(app, tea.WithAltScreen()) 2022 if _, err := p.Run(); err != nil { 2023 return err 2024 } 2025 return nil 2026 } 2027 2028 func runDaemon(cfg *config.Config, s *syncer.Syncer) error { 2029 logPath := cfg.Settings.Log.File 2030 if logPath == "" { 2031 logPath = "esync.log" 2032 } 2033 logFormat := cfg.Settings.Log.Format 2034 2035 l, err := logger.New(logPath, logFormat) 2036 if err != nil { 2037 return fmt.Errorf("creating logger: %w", err) 2038 } 2039 defer l.Close() 2040 2041 fmt.Printf("esync daemon started (PID %d)\n", os.Getpid()) 2042 fmt.Printf("Watching: %s → %s\n", cfg.Sync.Local, cfg.Sync.Remote) 2043 fmt.Printf("Log: %s\n", logPath) 2044 2045 l.Info("started", map[string]interface{}{ 2046 "local": cfg.Sync.Local, 2047 "remote": cfg.Sync.Remote, 2048 "pid": os.Getpid(), 2049 }) 2050 2051 handler := func() { 2052 result, err := s.Run() 2053 if err != nil { 2054 l.Error("sync_failed", map[string]interface{}{ 2055 "error": result.ErrorMessage, 2056 }) 2057 fmt.Print("\a") // terminal bell on error 2058 } else { 2059 fields := map[string]interface{}{ 2060 "duration_ms": result.Duration.Milliseconds(), 2061 "bytes": result.BytesTotal, 2062 } 2063 if len(result.Files) > 0 { 2064 fields["file"] = result.Files[0] 2065 } 2066 l.Info("synced", fields) 2067 } 2068 } 2069 2070 w, err := watcher.New( 2071 cfg.Sync.Local, 2072 cfg.Settings.WatcherDebounce, 2073 cfg.AllIgnorePatterns(), 2074 handler, 2075 ) 2076 if err != nil { 2077 return fmt.Errorf("creating watcher: %w", err) 2078 } 2079 2080 if err := w.Start(); err != nil { 2081 return fmt.Errorf("starting watcher: %w", err) 2082 } 2083 defer w.Stop() 2084 2085 // Block until interrupted 2086 select {} 2087 } 2088 2089 func loadOrBuildConfig() (*config.Config, error) { 2090 if cfgFile != "" { 2091 return config.Load(cfgFile) 2092 } 2093 2094 // Quick mode: local + remote provided directly 2095 if localPath != "" && remotePath != "" { 2096 return &config.Config{ 2097 Sync: config.SyncSection{ 2098 Local: localPath, 2099 Remote: remotePath, 2100 }, 2101 Settings: config.Settings{ 2102 WatcherDebounce: 500, 2103 Rsync: config.RsyncSettings{ 2104 Archive: true, 2105 Compress: true, 2106 Progress: true, 2107 }, 2108 }, 2109 }, nil 2110 } 2111 2112 // Try to find config file 2113 path := config.FindConfigFile() 2114 if path == "" { 2115 return nil, fmt.Errorf("no config file found; use -c, create esync.toml, or pass -l and -r") 2116 } 2117 return config.Load(path) 2118 } 2119 2120 func formatSize(bytes int64) string { 2121 switch { 2122 case bytes < 1024: 2123 return fmt.Sprintf("%dB", bytes) 2124 case bytes < 1024*1024: 2125 return fmt.Sprintf("%.1fKB", float64(bytes)/1024) 2126 case bytes < 1024*1024*1024: 2127 return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024)) 2128 default: 2129 return fmt.Sprintf("%.2fGB", float64(bytes)/(1024*1024*1024)) 2130 } 2131 } 2132 ``` 2133 2134 **Step 2: Verify build** 2135 2136 ```bash 2137 go build ./... 2138 ``` 2139 2140 **Step 3: Manual test** 2141 2142 ```bash 2143 mkdir -p /tmp/esync-test-src /tmp/esync-test-dst 2144 echo "hello" > /tmp/esync-test-src/test.txt 2145 go run . sync -l /tmp/esync-test-src -r /tmp/esync-test-dst 2146 # TUI should appear. Modify test.txt in another terminal. Press q to quit. 2147 ``` 2148 2149 **Step 4: Commit** 2150 2151 ```bash 2152 git add cmd/sync.go 2153 git commit -m "feat: add sync command with TUI and daemon modes" 2154 ``` 2155 2156 --- 2157 2158 ### Task 8: CLI Commands — init (smart) 2159 2160 **Files:** 2161 - Create: `cmd/init.go` 2162 2163 **Step 1: Implement smart init** 2164 2165 ```go 2166 // cmd/init.go 2167 package cmd 2168 2169 import ( 2170 "bufio" 2171 "fmt" 2172 "os" 2173 "path/filepath" 2174 "strings" 2175 2176 "github.com/spf13/cobra" 2177 2178 "github.com/eloualiche/esync/internal/config" 2179 ) 2180 2181 var initRemote string 2182 2183 var initCmd = &cobra.Command{ 2184 Use: "init", 2185 Short: "Generate esync.toml from current directory", 2186 Long: "Create an esync.toml config file by inspecting the current directory, importing .gitignore patterns, and detecting common exclusions.", 2187 RunE: runInit, 2188 } 2189 2190 func init() { 2191 initCmd.Flags().StringVarP(&initRemote, "remote", "r", "", "pre-fill remote destination") 2192 rootCmd.AddCommand(initCmd) 2193 } 2194 2195 func runInit(cmd *cobra.Command, args []string) error { 2196 outPath := "esync.toml" 2197 if cfgFile != "" { 2198 outPath = cfgFile 2199 } 2200 2201 // Check if file exists 2202 if _, err := os.Stat(outPath); err == nil { 2203 fmt.Printf("Config file %s already exists. Overwrite? [y/N] ", outPath) 2204 reader := bufio.NewReader(os.Stdin) 2205 answer, _ := reader.ReadString('\n') 2206 answer = strings.TrimSpace(strings.ToLower(answer)) 2207 if answer != "y" && answer != "yes" { 2208 fmt.Println("Aborted.") 2209 return nil 2210 } 2211 } 2212 2213 // Start with default TOML 2214 content := config.DefaultTOML() 2215 2216 // Detect .gitignore 2217 gitignorePatterns := readGitignore() 2218 if len(gitignorePatterns) > 0 { 2219 fmt.Printf("Detected .gitignore — imported %d patterns\n", len(gitignorePatterns)) 2220 } 2221 2222 // Detect common directories to exclude 2223 autoExclude := detectCommonDirs() 2224 if len(autoExclude) > 0 { 2225 fmt.Printf("Auto-excluding: %s\n", strings.Join(autoExclude, ", ")) 2226 } 2227 2228 // Prompt for remote if not provided 2229 remote := initRemote 2230 if remote == "" { 2231 fmt.Print("Remote destination? (e.g. user@host:/path) ") 2232 reader := bufio.NewReader(os.Stdin) 2233 remote, _ = reader.ReadString('\n') 2234 remote = strings.TrimSpace(remote) 2235 } 2236 if remote != "" { 2237 content = strings.Replace(content, `remote = "./remote"`, fmt.Sprintf(`remote = "%s"`, remote), 1) 2238 } 2239 2240 // Merge extra ignore patterns into rsync ignore 2241 if len(gitignorePatterns) > 0 || len(autoExclude) > 0 { 2242 allExtra := append(gitignorePatterns, autoExclude...) 2243 // Build the ignore array string 2244 var quoted []string 2245 for _, p := range allExtra { 2246 quoted = append(quoted, fmt.Sprintf(`"%s"`, p)) 2247 } 2248 extraLine := strings.Join(quoted, ", ") 2249 // Append to existing ignore array 2250 content = strings.Replace(content, 2251 `ignore = [".git/", "node_modules/", "**/__pycache__/"]`, 2252 fmt.Sprintf(`ignore = [".git/", "node_modules/", "**/__pycache__/", %s]`, extraLine), 2253 1, 2254 ) 2255 } 2256 2257 if err := os.WriteFile(outPath, []byte(content), 0644); err != nil { 2258 return fmt.Errorf("writing config: %w", err) 2259 } 2260 2261 fmt.Printf("\nWritten: %s\n", outPath) 2262 fmt.Println("\nRun `esync check` for file preview, `esync edit` to adjust") 2263 2264 return nil 2265 } 2266 2267 func readGitignore() []string { 2268 f, err := os.Open(".gitignore") 2269 if err != nil { 2270 return nil 2271 } 2272 defer f.Close() 2273 2274 var patterns []string 2275 scanner := bufio.NewScanner(f) 2276 for scanner.Scan() { 2277 line := strings.TrimSpace(scanner.Text()) 2278 if line == "" || strings.HasPrefix(line, "#") { 2279 continue 2280 } 2281 // Skip patterns we already have as defaults 2282 if line == ".git" || line == ".git/" || line == "node_modules" || line == "node_modules/" || line == "__pycache__" || line == "__pycache__/" { 2283 continue 2284 } 2285 patterns = append(patterns, line) 2286 } 2287 return patterns 2288 } 2289 2290 func detectCommonDirs() []string { 2291 common := []string{".git/", "node_modules/", "__pycache__/", "build/", ".venv/", "dist/", ".tox/", ".mypy_cache/"} 2292 var found []string 2293 for _, dir := range common { 2294 clean := strings.TrimSuffix(dir, "/") 2295 if info, err := os.Stat(clean); err == nil && info.IsDir() { 2296 // Skip ones already in default config 2297 if dir == ".git/" || dir == "node_modules/" || dir == "__pycache__/" { 2298 continue 2299 } 2300 found = append(found, dir) 2301 } 2302 } 2303 return found 2304 } 2305 ``` 2306 2307 **Step 2: Verify build and test manually** 2308 2309 ```bash 2310 go build ./... 2311 cd /tmp && mkdir test-init && cd test-init 2312 echo "*.pyc" > .gitignore 2313 /path/to/esync init -r user@host:/deploy 2314 cat esync.toml 2315 ``` 2316 2317 **Step 3: Commit** 2318 2319 ```bash 2320 git add cmd/init.go 2321 git commit -m "feat: add smart init command with .gitignore import" 2322 ``` 2323 2324 --- 2325 2326 ### Task 9: CLI Commands — check and edit 2327 2328 **Files:** 2329 - Create: `cmd/check.go` 2330 - Create: `cmd/edit.go` 2331 2332 **Step 1: Implement check command** 2333 2334 ```go 2335 // cmd/check.go 2336 package cmd 2337 2338 import ( 2339 "fmt" 2340 "os" 2341 "path/filepath" 2342 "strings" 2343 2344 "github.com/charmbracelet/lipgloss" 2345 "github.com/spf13/cobra" 2346 2347 "github.com/eloualiche/esync/internal/config" 2348 ) 2349 2350 var checkCmd = &cobra.Command{ 2351 Use: "check", 2352 Short: "Validate config and show file include/exclude preview", 2353 RunE: runCheck, 2354 } 2355 2356 func init() { 2357 rootCmd.AddCommand(checkCmd) 2358 } 2359 2360 func runCheck(cmd *cobra.Command, args []string) error { 2361 cfg, err := loadConfig() 2362 if err != nil { 2363 return err 2364 } 2365 return printPreview(cfg) 2366 } 2367 2368 func loadConfig() (*config.Config, error) { 2369 path := cfgFile 2370 if path == "" { 2371 path = config.FindConfigFile() 2372 } 2373 if path == "" { 2374 return nil, fmt.Errorf("no config file found") 2375 } 2376 return config.Load(path) 2377 } 2378 2379 func printPreview(cfg *config.Config) error { 2380 green := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) 2381 yellow := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) 2382 dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 2383 2384 fmt.Println(green.Render(" esync ─ config preview")) 2385 fmt.Printf(" Local: %s\n", cfg.Sync.Local) 2386 fmt.Printf(" Remote: %s\n\n", cfg.Sync.Remote) 2387 2388 ignores := cfg.AllIgnorePatterns() 2389 2390 var included []string 2391 var excluded []excludedFile 2392 var totalSize int64 2393 2394 localPath := cfg.Sync.Local 2395 filepath.Walk(localPath, func(path string, info os.FileInfo, err error) error { 2396 if err != nil { 2397 return nil 2398 } 2399 rel, _ := filepath.Rel(localPath, path) 2400 if rel == "." { 2401 return nil 2402 } 2403 2404 for _, pattern := range ignores { 2405 clean := strings.Trim(pattern, "\"[]'") 2406 if strings.HasPrefix(clean, "**/") { 2407 clean = clean[3:] 2408 } 2409 base := filepath.Base(rel) 2410 if matched, _ := filepath.Match(clean, base); matched { 2411 excluded = append(excluded, excludedFile{path: rel, rule: pattern}) 2412 if info.IsDir() { 2413 return filepath.SkipDir 2414 } 2415 return nil 2416 } 2417 if matched, _ := filepath.Match(clean, rel); matched { 2418 excluded = append(excluded, excludedFile{path: rel, rule: pattern}) 2419 if info.IsDir() { 2420 return filepath.SkipDir 2421 } 2422 return nil 2423 } 2424 // Directory pattern matching 2425 if strings.HasSuffix(clean, "/") && info.IsDir() { 2426 dirName := strings.TrimSuffix(clean, "/") 2427 if base == dirName { 2428 excluded = append(excluded, excludedFile{path: rel + "/", rule: pattern}) 2429 return filepath.SkipDir 2430 } 2431 } 2432 } 2433 2434 if !info.IsDir() { 2435 included = append(included, rel) 2436 totalSize += info.Size() 2437 } 2438 return nil 2439 }) 2440 2441 // Show included 2442 fmt.Println(green.Render(" Included (sample):")) 2443 shown := min(10, len(included)) 2444 for i := 0; i < shown; i++ { 2445 fmt.Printf(" %s\n", included[i]) 2446 } 2447 if len(included) > shown { 2448 fmt.Printf(" %s\n", dim.Render(fmt.Sprintf("... %d more files", len(included)-shown))) 2449 } 2450 fmt.Println() 2451 2452 // Show excluded 2453 fmt.Println(yellow.Render(" Excluded by rules:")) 2454 shown = min(10, len(excluded)) 2455 for i := 0; i < shown; i++ { 2456 fmt.Printf(" %-30s %s\n", excluded[i].path, dim.Render("["+excluded[i].rule+"]")) 2457 } 2458 if len(excluded) > shown { 2459 fmt.Printf(" %s\n", dim.Render(fmt.Sprintf("... %d more", len(excluded)-shown))) 2460 } 2461 fmt.Println() 2462 2463 fmt.Printf(" %s\n", dim.Render(fmt.Sprintf("%d files included (%s) │ %d excluded", 2464 len(included), formatSize(totalSize), len(excluded)))) 2465 2466 return nil 2467 } 2468 2469 type excludedFile struct { 2470 path string 2471 rule string 2472 } 2473 ``` 2474 2475 **Step 2: Implement edit command** 2476 2477 ```go 2478 // cmd/edit.go 2479 package cmd 2480 2481 import ( 2482 "fmt" 2483 "os" 2484 "os/exec" 2485 2486 "github.com/spf13/cobra" 2487 2488 "github.com/eloualiche/esync/internal/config" 2489 ) 2490 2491 var editCmd = &cobra.Command{ 2492 Use: "edit", 2493 Short: "Open config in $EDITOR, then show preview", 2494 RunE: runEdit, 2495 } 2496 2497 func init() { 2498 rootCmd.AddCommand(editCmd) 2499 } 2500 2501 func runEdit(cmd *cobra.Command, args []string) error { 2502 path := cfgFile 2503 if path == "" { 2504 path = config.FindConfigFile() 2505 } 2506 if path == "" { 2507 return fmt.Errorf("no config file found; run `esync init` first") 2508 } 2509 2510 editor := os.Getenv("EDITOR") 2511 if editor == "" { 2512 editor = "vi" 2513 } 2514 2515 for { 2516 // Open editor 2517 c := exec.Command(editor, path) 2518 c.Stdin = os.Stdin 2519 c.Stdout = os.Stdout 2520 c.Stderr = os.Stderr 2521 if err := c.Run(); err != nil { 2522 return fmt.Errorf("editor failed: %w", err) 2523 } 2524 2525 // Validate and show preview 2526 cfg, err := config.Load(path) 2527 if err != nil { 2528 fmt.Printf("\nConfig error: %v\n", err) 2529 fmt.Print("Press enter to edit again, or q to cancel: ") 2530 var answer string 2531 fmt.Scanln(&answer) 2532 if answer == "q" { 2533 return nil 2534 } 2535 continue 2536 } 2537 2538 if err := printPreview(cfg); err != nil { 2539 return err 2540 } 2541 2542 fmt.Print("\nPress enter to accept, e to edit again, q to cancel: ") 2543 var answer string 2544 fmt.Scanln(&answer) 2545 switch answer { 2546 case "e": 2547 continue 2548 case "q": 2549 fmt.Println("Cancelled.") 2550 return nil 2551 default: 2552 fmt.Println("Config accepted.") 2553 return nil 2554 } 2555 } 2556 } 2557 ``` 2558 2559 **Step 3: Verify build** 2560 2561 ```bash 2562 go build ./... 2563 ``` 2564 2565 **Step 4: Commit** 2566 2567 ```bash 2568 git add cmd/check.go cmd/edit.go 2569 git commit -m "feat: add check and edit commands for config validation and preview" 2570 ``` 2571 2572 --- 2573 2574 ### Task 10: CLI Commands — status 2575 2576 **Files:** 2577 - Create: `cmd/status.go` 2578 - Modify: `cmd/sync.go` (write PID file in daemon mode) 2579 2580 **Step 1: Implement PID file in daemon mode** 2581 2582 Add to `runDaemon` in `cmd/sync.go`: 2583 ```go 2584 // Write PID file 2585 pidPath := filepath.Join(os.TempDir(), "esync.pid") 2586 os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) 2587 defer os.Remove(pidPath) 2588 ``` 2589 2590 **Step 2: Implement status command** 2591 2592 ```go 2593 // cmd/status.go 2594 package cmd 2595 2596 import ( 2597 "fmt" 2598 "os" 2599 "path/filepath" 2600 "strconv" 2601 "strings" 2602 "syscall" 2603 2604 "github.com/spf13/cobra" 2605 ) 2606 2607 var statusCmd = &cobra.Command{ 2608 Use: "status", 2609 Short: "Check if esync daemon is running", 2610 RunE: runStatus, 2611 } 2612 2613 func init() { 2614 rootCmd.AddCommand(statusCmd) 2615 } 2616 2617 func runStatus(cmd *cobra.Command, args []string) error { 2618 pidPath := filepath.Join(os.TempDir(), "esync.pid") 2619 data, err := os.ReadFile(pidPath) 2620 if err != nil { 2621 fmt.Println("No esync daemon running.") 2622 return nil 2623 } 2624 2625 pid, err := strconv.Atoi(strings.TrimSpace(string(data))) 2626 if err != nil { 2627 fmt.Println("No esync daemon running (invalid PID file).") 2628 os.Remove(pidPath) 2629 return nil 2630 } 2631 2632 // Check if process is alive 2633 process, err := os.FindProcess(pid) 2634 if err != nil { 2635 fmt.Println("No esync daemon running.") 2636 os.Remove(pidPath) 2637 return nil 2638 } 2639 2640 // On Unix, FindProcess always succeeds. Send signal 0 to check. 2641 if err := process.Signal(syscall.Signal(0)); err != nil { 2642 fmt.Println("No esync daemon running (stale PID file).") 2643 os.Remove(pidPath) 2644 return nil 2645 } 2646 2647 fmt.Printf("esync daemon running (PID %d)\n", pid) 2648 return nil 2649 } 2650 ``` 2651 2652 **Step 3: Verify build** 2653 2654 ```bash 2655 go build ./... 2656 ``` 2657 2658 **Step 4: Commit** 2659 2660 ```bash 2661 git add cmd/status.go cmd/sync.go 2662 git commit -m "feat: add status command and PID file for daemon mode" 2663 ``` 2664 2665 --- 2666 2667 ### Task 11: Signal Handling and Graceful Shutdown 2668 2669 **Files:** 2670 - Modify: `cmd/sync.go` 2671 2672 **Step 1: Add signal handling to daemon mode** 2673 2674 Replace the `select {}` block at the end of `runDaemon` with: 2675 2676 ```go 2677 sigCh := make(chan os.Signal, 1) 2678 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 2679 <-sigCh 2680 l.Info("stopping", nil) 2681 fmt.Println("\nesync daemon stopped.") 2682 ``` 2683 2684 And import `"os/signal"` and `"syscall"`. 2685 2686 **Step 2: Test daemon start/stop** 2687 2688 ```bash 2689 go run . sync --daemon -l /tmp/esync-test-src -r /tmp/esync-test-dst & 2690 go run . status 2691 kill %1 2692 ``` 2693 2694 **Step 3: Commit** 2695 2696 ```bash 2697 git add cmd/sync.go 2698 git commit -m "feat: add graceful shutdown with signal handling" 2699 ``` 2700 2701 --- 2702 2703 ### Task 12: README 2704 2705 **Files:** 2706 - Modify: `readme.md` 2707 2708 **Step 1: Write comprehensive README with TOML examples** 2709 2710 Replace the entire README with documentation covering: 2711 2712 - What esync does (1 paragraph) 2713 - Installation (go install + binary download) 2714 - Quick start (3 commands) 2715 - Commands reference (sync, init, check, edit, status) 2716 - Configuration reference with full annotated TOML example 2717 - Config file search order 2718 - SSH setup example 2719 - Daemon mode usage 2720 - TUI keyboard shortcuts 2721 - Examples section with 5-6 common use cases 2722 2723 Ensure thorough TOML examples per user request. 2724 2725 **Step 2: Commit** 2726 2727 ```bash 2728 git add readme.md 2729 git commit -m "docs: rewrite README for Go version with TOML examples" 2730 ``` 2731 2732 --- 2733 2734 ### Task 13: Integration Testing 2735 2736 **Files:** 2737 - Create: `integration_test.go` 2738 2739 **Step 1: Write integration test for local sync** 2740 2741 ```go 2742 // integration_test.go 2743 package main 2744 2745 import ( 2746 "os" 2747 "path/filepath" 2748 "testing" 2749 "time" 2750 2751 "github.com/eloualiche/esync/internal/config" 2752 "github.com/eloualiche/esync/internal/syncer" 2753 "github.com/eloualiche/esync/internal/watcher" 2754 ) 2755 2756 func TestLocalSyncIntegration(t *testing.T) { 2757 src := t.TempDir() 2758 dst := t.TempDir() 2759 2760 // Create a test file 2761 os.WriteFile(filepath.Join(src, "hello.txt"), []byte("hello"), 0644) 2762 2763 cfg := &config.Config{ 2764 Sync: config.SyncSection{ 2765 Local: src, 2766 Remote: dst, 2767 }, 2768 Settings: config.Settings{ 2769 WatcherDebounce: 100, 2770 Rsync: config.RsyncSettings{ 2771 Archive: true, 2772 Progress: true, 2773 }, 2774 }, 2775 } 2776 2777 s := syncer.New(cfg) 2778 result, err := s.Run() 2779 if err != nil { 2780 t.Fatalf("sync failed: %v", err) 2781 } 2782 if !result.Success { 2783 t.Fatalf("sync not successful: %s", result.ErrorMessage) 2784 } 2785 2786 // Verify file was synced 2787 data, err := os.ReadFile(filepath.Join(dst, "hello.txt")) 2788 if err != nil { 2789 t.Fatalf("synced file not found: %v", err) 2790 } 2791 if string(data) != "hello" { 2792 t.Errorf("expected 'hello', got %q", string(data)) 2793 } 2794 } 2795 2796 func TestWatcherTriggersSync(t *testing.T) { 2797 src := t.TempDir() 2798 dst := t.TempDir() 2799 2800 cfg := &config.Config{ 2801 Sync: config.SyncSection{ 2802 Local: src, 2803 Remote: dst, 2804 }, 2805 Settings: config.Settings{ 2806 WatcherDebounce: 100, 2807 Rsync: config.RsyncSettings{ 2808 Archive: true, 2809 Progress: true, 2810 }, 2811 }, 2812 } 2813 2814 s := syncer.New(cfg) 2815 synced := make(chan struct{}, 1) 2816 2817 handler := func() { 2818 s.Run() 2819 select { 2820 case synced <- struct{}{}: 2821 default: 2822 } 2823 } 2824 2825 w, err := watcher.New(src, 100, nil, handler) 2826 if err != nil { 2827 t.Fatalf("watcher creation failed: %v", err) 2828 } 2829 if err := w.Start(); err != nil { 2830 t.Fatalf("watcher start failed: %v", err) 2831 } 2832 defer w.Stop() 2833 2834 // Create a file to trigger sync 2835 time.Sleep(200 * time.Millisecond) // let watcher settle 2836 os.WriteFile(filepath.Join(src, "trigger.txt"), []byte("trigger"), 0644) 2837 2838 select { 2839 case <-synced: 2840 // Verify 2841 data, err := os.ReadFile(filepath.Join(dst, "trigger.txt")) 2842 if err != nil { 2843 t.Fatalf("file not synced: %v", err) 2844 } 2845 if string(data) != "trigger" { 2846 t.Errorf("expected 'trigger', got %q", string(data)) 2847 } 2848 case <-time.After(5 * time.Second): 2849 t.Fatal("timeout waiting for sync") 2850 } 2851 } 2852 ``` 2853 2854 **Step 2: Run all tests** 2855 2856 ```bash 2857 go test ./... -v 2858 ``` 2859 Expected: all PASS 2860 2861 **Step 3: Commit** 2862 2863 ```bash 2864 git add integration_test.go 2865 git commit -m "test: add integration tests for local sync and watcher" 2866 ``` 2867 2868 --- 2869 2870 ### Task 14: Example Config and Final Polish 2871 2872 **Files:** 2873 - Create: `esync.toml.example` 2874 - Verify: `go build ./...` and `go vet ./...` 2875 2876 **Step 1: Create example config** 2877 2878 Write `esync.toml.example` with the full annotated schema from the design doc. 2879 2880 **Step 2: Run linting and vet** 2881 2882 ```bash 2883 go vet ./... 2884 go build -o esync . 2885 ./esync --help 2886 ./esync sync --help 2887 ./esync init --help 2888 ``` 2889 2890 **Step 3: Clean up go.sum** 2891 2892 ```bash 2893 go mod tidy 2894 ``` 2895 2896 **Step 4: Final commit** 2897 2898 ```bash 2899 git add esync.toml.example go.mod go.sum 2900 git commit -m "chore: add example config and tidy module" 2901 ``` 2902 2903 --- 2904 2905 ## Execution Order Summary 2906 2907 | Task | Component | Depends On | 2908 |------|-----------|------------| 2909 | 1 | Project scaffolding | — | 2910 | 2 | Config package | 1 | 2911 | 3 | Syncer package | 2 | 2912 | 4 | Watcher package | 1 | 2913 | 5 | Logger package | 1 | 2914 | 6 | TUI (styles, dashboard, log view) | 1 | 2915 | 7 | CLI sync command | 2, 3, 4, 5, 6 | 2916 | 8 | CLI init command | 2 | 2917 | 9 | CLI check + edit commands | 2, 8 | 2918 | 10 | CLI status command | 7 | 2919 | 11 | Signal handling | 7 | 2920 | 12 | README | all above | 2921 | 13 | Integration tests | 3, 4 | 2922 | 14 | Example config + polish | all above | 2923 2924 **Parallelizable:** Tasks 2, 4, 5, 6 can run in parallel after Task 1. Tasks 8 and 13 can run in parallel with Task 7.