package config import ( "errors" "fmt" "os" "path/filepath" "strings" "syscall" toml "github.com/pelletier/go-toml/v2" ) type Config struct { Startup StartupConfig `toml:"startup"` UI UIConfig `toml:"ui"` Browser BrowserConfig `toml:"browser"` Preview PreviewConfig `toml:"preview"` Behavior BehaviorConfig `toml:"behavior"` } type StartupConfig struct { LeftPath string `toml:"left_path"` RightPath string `toml:"right_path"` } type UIConfig struct { AppTitle string `toml:"app_title"` Theme string `toml:"theme"` IconMode string `toml:"icon_mode"` ShowTitleBar bool `toml:"show_title_bar"` ShowFooter bool `toml:"show_footer"` Border string `toml:"border"` PathDisplay string `toml:"path_display"` PaneGap int `toml:"pane_gap"` CenterWidthPercent int `toml:"center_width_percent"` } type BrowserConfig struct { ShowHidden bool `toml:"show_hidden"` DirsFirst bool `toml:"dirs_first"` HumanReadableSize bool `toml:"human_readable_size"` Sort SortConfig `toml:"sort"` Columns BrowserColumnsConfig `toml:"columns"` } type SortConfig struct { By string `toml:"by"` Reverse bool `toml:"reverse"` } type BrowserColumnsConfig struct { Name bool `toml:"name"` Size bool `toml:"size"` Modified bool `toml:"modified"` Created bool `toml:"created"` Permissions bool `toml:"permissions"` Extension bool `toml:"extension"` } type PreviewConfig struct { ShowMetadata bool `toml:"show_metadata"` WrapText bool `toml:"wrap_text"` MaxPreviewBytes int64 `toml:"max_preview_bytes"` DirectoryPreviewLimit int `toml:"directory_preview_limit"` } type BehaviorConfig struct { ConfirmDelete bool `toml:"confirm_delete"` ConfirmOverwrite bool `toml:"confirm_overwrite"` CalculateDirSizeOnSpace bool `toml:"calculate_dir_size_on_space"` FollowSymlinks bool `toml:"follow_symlinks"` } func Default() Config { return Config{ Startup: StartupConfig{}, UI: UIConfig{ AppTitle: "vcom", Theme: "catppuccin-mocha", IconMode: "auto", ShowTitleBar: true, ShowFooter: true, Border: "rounded", PathDisplay: "short", PaneGap: 1, CenterWidthPercent: 30, }, Browser: BrowserConfig{ ShowHidden: true, DirsFirst: true, HumanReadableSize: true, Sort: SortConfig{ By: "name", Reverse: false, }, Columns: BrowserColumnsConfig{ Name: true, Size: true, Modified: true, }, }, Preview: PreviewConfig{ ShowMetadata: true, WrapText: false, MaxPreviewBytes: 64 * 1024, DirectoryPreviewLimit: 80, }, Behavior: BehaviorConfig{ ConfirmDelete: true, ConfirmOverwrite: true, CalculateDirSizeOnSpace: true, FollowSymlinks: false, }, } } func Load(explicitPath string) (Config, string, error) { cfg := Default() path, found, err := resolvePath(explicitPath) if err != nil { return Config{}, "", err } if !found { return cfg, "", nil } data, err := os.ReadFile(path) if err != nil { return Config{}, "", fmt.Errorf("read %s: %w", path, err) } if err := toml.Unmarshal(data, &cfg); err != nil { return Config{}, "", fmt.Errorf("parse %s: %w", path, err) } if err := cfg.Validate(); err != nil { return Config{}, "", fmt.Errorf("validate %s: %w", path, err) } return cfg, path, nil } func Save(cfg Config, path string) (string, error) { if err := cfg.Validate(); err != nil { return "", err } targetPath := strings.TrimSpace(path) if targetPath == "" { var err error targetPath, err = DefaultUserPath() if err != nil { return "", err } } absPath, err := filepath.Abs(targetPath) if err != nil { return "", fmt.Errorf("resolve %s: %w", targetPath, err) } if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(absPath), err) } data, err := toml.Marshal(cfg) if err != nil { return "", fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(absPath, data, 0o644); err != nil { return "", fmt.Errorf("write %s: %w", absPath, err) } return absPath, nil } func (c *Config) Validate() error { if c.UI.Theme == "" { return errors.New("ui.theme must not be empty") } switch strings.ToLower(strings.TrimSpace(c.UI.IconMode)) { case "", "auto": c.UI.IconMode = "auto" case "nerd", "ascii": default: return errors.New("ui.icon_mode must be one of: auto, nerd, ascii") } if strings.TrimSpace(c.UI.AppTitle) == "" { c.UI.AppTitle = "vcom" } if c.Preview.MaxPreviewBytes <= 0 { return errors.New("preview.max_preview_bytes must be > 0") } if c.Preview.DirectoryPreviewLimit <= 0 { return errors.New("preview.directory_preview_limit must be > 0") } if !c.Browser.Columns.Name { return errors.New("browser.columns.name must stay enabled") } if strings.TrimSpace(c.UI.PathDisplay) == "" { c.UI.PathDisplay = "short" } if c.UI.PaneGap < 0 || c.UI.PaneGap > 4 { return errors.New("ui.pane_gap must be between 0 and 4") } if c.UI.CenterWidthPercent < 20 || c.UI.CenterWidthPercent > 60 { return errors.New("ui.center_width_percent must be between 20 and 60") } switch strings.ToLower(strings.TrimSpace(c.Browser.Sort.By)) { case "", "name": c.Browser.Sort.By = "name" case "modified", "size", "created", "extension": default: return fmt.Errorf("browser.sort.by must be one of: name, modified, size, created, extension") } return nil } func resolvePath(explicitPath string) (string, bool, error) { var candidates []string if explicitPath != "" { candidates = append(candidates, explicitPath) } else { homeDir, err := os.UserHomeDir() if err != nil { return "", false, fmt.Errorf("resolve home dir: %w", err) } xdgDir := os.Getenv("XDG_CONFIG_HOME") if xdgDir == "" { xdgDir = filepath.Join(homeDir, ".config") } candidates = append(candidates, "vcom.toml", filepath.Join("config", "vcom.toml"), filepath.Join(xdgDir, "vcom", "vcom.toml"), filepath.Join(homeDir, ".config", "vcom", "vcom.toml"), ) } for _, candidate := range candidates { if candidate == "" { continue } absPath, err := filepath.Abs(candidate) if err != nil { return "", false, fmt.Errorf("resolve %s: %w", candidate, err) } if _, err := os.Stat(absPath); err == nil { return absPath, true, nil } else if !isMissingPathError(err) { return "", false, fmt.Errorf("stat %s: %w", absPath, err) } } return "", false, nil } func isMissingPathError(err error) bool { return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) } func DefaultUserPath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home dir: %w", err) } xdgDir := os.Getenv("XDG_CONFIG_HOME") if xdgDir == "" { xdgDir = filepath.Join(homeDir, ".config") } return filepath.Join(xdgDir, "vcom", "vcom.toml"), nil }