config.go (7554B)
1 // Package config handles loading, validating, and providing defaults for 2 // esync TOML configuration files. 3 package config 4 5 import ( 6 "fmt" 7 "os" 8 "strings" 9 10 "github.com/spf13/viper" 11 ) 12 13 // --------------------------------------------------------------------------- 14 // Structs 15 // --------------------------------------------------------------------------- 16 17 // SSHConfig holds SSH connection parameters for remote syncing. 18 type SSHConfig struct { 19 Host string `mapstructure:"host"` 20 User string `mapstructure:"user"` 21 Port int `mapstructure:"port"` 22 IdentityFile string `mapstructure:"identity_file"` 23 InteractiveAuth bool `mapstructure:"interactive_auth"` 24 } 25 26 // SyncSection defines the local/remote pair and optional SSH tunnel. 27 type SyncSection struct { 28 Local string `mapstructure:"local"` 29 Remote string `mapstructure:"remote"` 30 Interval int `mapstructure:"interval"` 31 SSH *SSHConfig `mapstructure:"ssh"` 32 } 33 34 // RsyncSettings controls rsync behaviour. 35 type RsyncSettings struct { 36 Archive bool `mapstructure:"archive"` 37 Compress bool `mapstructure:"compress"` 38 Delete bool `mapstructure:"delete"` 39 CopyLinks bool `mapstructure:"copy_links"` 40 Backup bool `mapstructure:"backup"` 41 BackupDir string `mapstructure:"backup_dir"` 42 Progress bool `mapstructure:"progress"` 43 ExtraArgs []string `mapstructure:"extra_args"` 44 Ignore []string `mapstructure:"ignore"` 45 } 46 47 // LogSettings controls logging output. 48 type LogSettings struct { 49 File string `mapstructure:"file"` 50 Format string `mapstructure:"format"` 51 } 52 53 // Settings groups watcher, rsync, and log tunables. 54 type Settings struct { 55 WatcherDebounce int `mapstructure:"watcher_debounce"` 56 InitialSync bool `mapstructure:"initial_sync"` 57 Include []string `mapstructure:"include"` 58 Ignore []string `mapstructure:"ignore"` 59 Rsync RsyncSettings `mapstructure:"rsync"` 60 Log LogSettings `mapstructure:"log"` 61 } 62 63 // Config is the top-level configuration. 64 type Config struct { 65 Sync SyncSection `mapstructure:"sync"` 66 Settings Settings `mapstructure:"settings"` 67 } 68 69 // --------------------------------------------------------------------------- 70 // Load 71 // --------------------------------------------------------------------------- 72 73 // Load reads a TOML configuration file at path, applies defaults, validates 74 // required fields, and returns the populated Config. 75 func Load(path string) (*Config, error) { 76 v := viper.New() 77 v.SetConfigFile(path) 78 v.SetConfigType("toml") 79 80 // Defaults 81 v.SetDefault("sync.interval", 1) 82 v.SetDefault("settings.watcher_debounce", 500) 83 v.SetDefault("settings.initial_sync", false) 84 v.SetDefault("settings.rsync.archive", true) 85 v.SetDefault("settings.rsync.compress", true) 86 v.SetDefault("settings.rsync.delete", false) 87 v.SetDefault("settings.rsync.copy_links", false) 88 v.SetDefault("settings.rsync.backup", false) 89 v.SetDefault("settings.rsync.backup_dir", ".rsync_backup") 90 v.SetDefault("settings.rsync.progress", true) 91 v.SetDefault("settings.log.format", "text") 92 93 if err := v.ReadInConfig(); err != nil { 94 return nil, fmt.Errorf("reading config: %w", err) 95 } 96 97 var cfg Config 98 if err := v.Unmarshal(&cfg); err != nil { 99 return nil, fmt.Errorf("unmarshalling config: %w", err) 100 } 101 102 // Validation: local and remote are required. 103 if strings.TrimSpace(cfg.Sync.Local) == "" { 104 return nil, fmt.Errorf("sync.local is required") 105 } 106 if strings.TrimSpace(cfg.Sync.Remote) == "" { 107 return nil, fmt.Errorf("sync.remote is required") 108 } 109 110 return &cfg, nil 111 } 112 113 // --------------------------------------------------------------------------- 114 // Config file search 115 // --------------------------------------------------------------------------- 116 117 // FindConfigFile searches the standard locations for an esync config file 118 // and returns the first one found, or an empty string. 119 func FindConfigFile() string { 120 home, _ := os.UserHomeDir() 121 candidates := []string{ 122 "./.esync.toml", 123 home + "/.config/esync/config.toml", 124 "/etc/esync/config.toml", 125 } 126 return FindConfigIn(candidates) 127 } 128 129 // FindConfigIn returns the first path in the list that exists on disk, 130 // or an empty string if none exist. 131 func FindConfigIn(paths []string) string { 132 for _, p := range paths { 133 if _, err := os.Stat(p); err == nil { 134 return p 135 } 136 } 137 return "" 138 } 139 140 // --------------------------------------------------------------------------- 141 // Helpers 142 // --------------------------------------------------------------------------- 143 144 // IsRemote returns true if the configuration targets a remote destination, 145 // either via an explicit SSH section or a remote string that looks like 146 // "user@host:/path" or "host:/path". 147 func (c *Config) IsRemote() bool { 148 if c.Sync.SSH != nil && c.Sync.SSH.Host != "" { 149 return true 150 } 151 return looksRemote(c.Sync.Remote) 152 } 153 154 // looksRemote returns true if remote resembles an scp-style address 155 // (e.g. "user@host:/path" or "host:/path") but not a Windows drive 156 // letter like "C:/". 157 func looksRemote(remote string) bool { 158 idx := strings.Index(remote, ":") 159 if idx < 0 { 160 return false 161 } 162 // Single letter before colon is a Windows drive letter (e.g. "C:/") 163 if idx == 1 { 164 return false 165 } 166 return true 167 } 168 169 // AllIgnorePatterns returns the combined ignore list from both 170 // settings.ignore and settings.rsync.ignore, in that order. 171 func (c *Config) AllIgnorePatterns() []string { 172 combined := make([]string, 0, len(c.Settings.Ignore)+len(c.Settings.Rsync.Ignore)) 173 combined = append(combined, c.Settings.Ignore...) 174 combined = append(combined, c.Settings.Rsync.Ignore...) 175 return combined 176 } 177 178 // --------------------------------------------------------------------------- 179 // DefaultTOML 180 // --------------------------------------------------------------------------- 181 182 // EditTemplateTOML returns a minimal commented TOML template used by the 183 // TUI "e" key when no .esync.toml exists. Unlike DefaultTOML (used by 184 // esync init), most fields are commented out. 185 func EditTemplateTOML() string { 186 return `# esync configuration 187 # Docs: https://github.com/LouLouLibs/esync 188 189 [sync] 190 local = "." 191 remote = "user@host:/path/to/dest" 192 # interval = 1 # seconds between syncs 193 194 # [sync.ssh] 195 # key = "~/.ssh/id_ed25519" 196 # port = 22 197 198 [settings] 199 # watcher_debounce = 500 # ms 200 # initial_sync = false 201 # include = ["src/", "cmd/"] 202 # ignore = [".git", "*.tmp"] 203 204 # [settings.rsync] 205 # archive = true 206 # compress = true 207 # delete = false 208 # copy_links = false 209 # extra_args = ["--exclude=.DS_Store"] 210 211 # [settings.log] 212 # file = "esync.log" 213 # format = "text" 214 ` 215 } 216 217 // DefaultTOML returns a commented TOML template suitable for writing to a 218 // new configuration file. 219 func DefaultTOML() string { 220 return `# esync configuration file 221 222 [sync] 223 local = "." 224 remote = "user@host:/path/to/dest" 225 interval = 1 226 227 # [sync.ssh] 228 # host = "myserver.com" 229 # user = "deploy" 230 # port = 22 231 # identity_file = "~/.ssh/id_ed25519" 232 # interactive_auth = false 233 234 [settings] 235 watcher_debounce = 500 236 initial_sync = false 237 # include: path prefixes to sync (relative to local). Empty means everything. 238 # Keep include simple and explicit; use ignore for fine-grained filtering. 239 include = [] 240 ignore = [".git", "node_modules", ".DS_Store"] 241 242 [settings.rsync] 243 archive = true 244 compress = true 245 delete = false 246 copy_links = false 247 backup = false 248 backup_dir = ".rsync_backup" 249 progress = true 250 extra_args = [] 251 ignore = [] 252 253 [settings.log] 254 # file = "/var/log/esync.log" 255 format = "text" 256 ` 257 }