esync

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

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 }