8 files changed,
310 insertions(+),
4 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-23 14:51:52 +0300
Authored at:
2026-07-23 14:18:42 +0300
Change ID:
kpkronrxwuqkzpnrtuvzykxkqunpnqtr
Parent:
e80da34
A
internal/config/config.go
··· 1 +package config 2 + 3 +import ( 4 + "bytes" 5 + "errors" 6 + "maps" 7 + "os" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + _ "embed" 13 +) 14 + 15 +var ( 16 + ErrConfigFileNotFound = errors.New("config file not found") 17 + ErrInvalidConfig = errors.New("config file is invalid") 18 +) 19 + 20 +//go:embed default.conf 21 +var defaultConfig []byte 22 + 23 +type Config struct { 24 + cfgPath string 25 + cfg map[string]string 26 +} 27 + 28 +func Load() (*Config, error) { 29 + cfgDir, err := os.UserConfigDir() 30 + if err != nil { 31 + return nil, err 32 + } 33 + 34 + cfgPath := filepath.Join(cfgDir, "viye.conf") 35 + c := Config{cfgPath: cfgPath} 36 + 37 + // write default config 38 + if _, serr := os.Stat(cfgPath); serr != nil { 39 + if !os.IsNotExist(serr) { 40 + return nil, serr 41 + } 42 + if cerr := os.WriteFile(cfgPath, defaultConfig, 0o644); cerr != nil { 43 + return nil, cerr 44 + } 45 + c.cfg = make(map[string]string) 46 + if cerr := c.read(defaultConfig); cerr != nil { 47 + return nil, cerr 48 + } 49 + return &c, nil 50 + } 51 + 52 + file, err := os.ReadFile(cfgPath) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + c.cfg = make(map[string]string) 58 + if err := c.read(file); err != nil { 59 + return nil, err 60 + } 61 + 62 + return &c, nil 63 +} 64 + 65 +func (c *Config) GetAll() map[string]string { return c.cfg } 66 +func (c *Config) Get(key string) string { return c.cfg[key] } 67 +func (c *Config) Set(key, value string) error { 68 + c.cfg[key] = value 69 + return c.save() 70 +} 71 + 72 +func (c *Config) Delete(key string) error { 73 + delete(c.cfg, key) 74 + return c.save() 75 +} 76 + 77 +func (c *Config) read(inp []byte) error { 78 + for line := range strings.SplitSeq(string(inp), "\n") { 79 + if line == "" { 80 + continue 81 + } 82 + parts := strings.Split(line, " = ") 83 + if len(parts) < 2 || len(parts) > 2 { 84 + return ErrInvalidConfig // TODO: improve error reporting 85 + } 86 + key := strings.TrimSpace(parts[0]) 87 + if strings.Contains(key, " ") { 88 + return ErrInvalidConfig 89 + } 90 + c.cfg[key] = strings.TrimSpace(parts[1]) 91 + } 92 + return nil 93 +} 94 + 95 +func (c *Config) save() error { 96 + var buf bytes.Buffer 97 + for _, key := range slices.Sorted(maps.Keys(c.cfg)) { 98 + val := c.cfg[key] 99 + _, _ = buf.WriteString(key) 100 + _, _ = buf.WriteString(" = ") 101 + _, _ = buf.WriteString(val) 102 + _ = buf.WriteByte('\n') 103 + } 104 + err := os.WriteFile(c.cfgPath, buf.Bytes(), 0o644) 105 + return err 106 +}
A
internal/config/config_test.go
··· 1 +package config 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 +) 8 + 9 +func TestLoad(t *testing.T) { 10 + t.Run("creates default config when file missing", func(t *testing.T) { 11 + want := Config{cfg: make(map[string]string)} 12 + if err := want.read(defaultConfig); err != nil { 13 + t.Fatal(err) 14 + } 15 + 16 + dir := t.TempDir() 17 + t.Setenv("XDG_CONFIG_HOME", dir) 18 + 19 + cfg, err := Load() 20 + if err != nil { 21 + t.Fatalf("Load() = _, %v", err) 22 + } 23 + for key, val := range want.cfg { 24 + if cfg.Get(key) != val { 25 + t.Fatalf("got %s=%q, want %q", key, cfg.Get(key), val) 26 + } 27 + } 28 + }) 29 + 30 + t.Run("loads existing config file", func(t *testing.T) { 31 + dir := t.TempDir() 32 + t.Setenv("XDG_CONFIG_HOME", dir) 33 + 34 + cfgDir := filepath.Join(dir, "viye.conf") 35 + if err := os.WriteFile(cfgDir, []byte("custom = value\n"), 0o644); err != nil { 36 + t.Fatal(err) 37 + } 38 + 39 + cfg, err := Load() 40 + if err != nil { 41 + t.Fatalf("Load() = _, %v", err) 42 + } 43 + if cfg.Get("custom") != "value" { 44 + t.Fatalf("got custom=%q, want %q", cfg.Get("custom"), "value") 45 + } 46 + }) 47 + 48 + t.Run("invalid config file returns error", func(t *testing.T) { 49 + dir := t.TempDir() 50 + t.Setenv("XDG_CONFIG_HOME", dir) 51 + 52 + cfgDir := filepath.Join(dir, "viye.conf") 53 + if err := os.WriteFile(cfgDir, []byte("bad line\n"), 0o644); err != nil { 54 + t.Fatal(err) 55 + } 56 + 57 + _, err := Load() 58 + if err != ErrInvalidConfig { 59 + t.Fatalf("got %v, want ErrInvalidConfig", err) 60 + } 61 + }) 62 +} 63 + 64 +func TestSet(t *testing.T) { 65 + dir := t.TempDir() 66 + path := filepath.Join(dir, "conf") 67 + c := Config{ 68 + cfgPath: path, 69 + cfg: map[string]string{}, 70 + } 71 + 72 + if err := c.Set("key", "val"); err != nil { 73 + t.Fatal(err) 74 + } 75 + if c.cfg["key"] != "val" { 76 + t.Fatalf("in-memory: got %v, want key=val", c.cfg) 77 + } 78 + 79 + b, err := os.ReadFile(path) 80 + if err != nil { 81 + t.Fatal(err) 82 + } 83 + if string(b) != "key = val\n" { 84 + t.Fatalf("on disk: got %q, want %q", string(b), "key = val\n") 85 + } 86 +} 87 + 88 +func TestRead(t *testing.T) { 89 + t.Run("valid config", func(t *testing.T) { 90 + c := Config{cfg: make(map[string]string)} 91 + err := c.read([]byte("key1 = value1\nkey2 = value2\n")) 92 + if err != nil { 93 + t.Fatalf("unexpected error: %v", err) 94 + } 95 + if c.cfg["key1"] != "value1" || c.cfg["key2"] != "value2" { 96 + t.Fatalf("got %v, want key1=value1 key2=value2", c.cfg) 97 + } 98 + }) 99 + 100 + t.Run("trailing newline", func(t *testing.T) { 101 + c := Config{cfg: make(map[string]string)} 102 + err := c.read([]byte("key = val\n")) 103 + if err != nil { 104 + t.Fatalf("unexpected error: %v", err) 105 + } 106 + if c.cfg["key"] != "val" { 107 + t.Fatalf("got %v, want key=val", c.cfg) 108 + } 109 + }) 110 + 111 + t.Run("empty lines", func(t *testing.T) { 112 + c := Config{cfg: make(map[string]string)} 113 + err := c.read([]byte("\nkey = val\n\n")) 114 + if err != nil { 115 + t.Fatalf("unexpected error: %v", err) 116 + } 117 + if c.cfg["key"] != "val" { 118 + t.Fatalf("got %v, want key=val", c.cfg) 119 + } 120 + }) 121 + 122 + t.Run("multiple equals signs", func(t *testing.T) { 123 + c := Config{cfg: make(map[string]string)} 124 + err := c.read([]byte("key = val = extra")) 125 + if err != ErrInvalidConfig { 126 + t.Fatalf("got %v, want ErrInvalidConfig", err) 127 + } 128 + }) 129 + 130 + t.Run("no separator", func(t *testing.T) { 131 + c := Config{cfg: make(map[string]string)} 132 + err := c.read([]byte("justtext")) 133 + if err != ErrInvalidConfig { 134 + t.Fatalf("got %v, want ErrInvalidConfig", err) 135 + } 136 + }) 137 +}
A
internal/plumbing/config/config.go
··· 1 +package config 2 + 3 +import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/olexsmir/viye/internal/config" 8 + "github.com/olexsmir/viye/internal/viye" 9 +) 10 + 11 +type Tool struct{} 12 + 13 +func (Tool) Name() string { return "config" } 14 +func (Tool) Match(c *viye.Context) bool { return c.Path[0] == "config" } 15 +func (Tool) Execute(c *viye.Context) (string, error) { 16 + cfg, err := config.Load() 17 + if err != nil { 18 + return "", fmt.Errorf("couldn't load config") 19 + } 20 + 21 + if len(c.Body) > 0 { 22 + seen := make(map[string]bool, len(c.Body)) 23 + for _, line := range c.Body { 24 + line = strings.TrimPrefix(line, ": ") 25 + key, val, ok := strings.Cut(line, " = ") 26 + if !ok { 27 + continue 28 + } 29 + key, val = strings.TrimSpace(key), strings.TrimSpace(val) 30 + if strings.Contains(key, " ") { 31 + return "", fmt.Errorf("key %q contains spaces", key) 32 + } 33 + seen[key] = true 34 + if err := cfg.Set(key, val); err != nil { 35 + return "", err 36 + } 37 + } 38 + for key := range cfg.GetAll() { 39 + if !seen[key] { 40 + if err := cfg.Delete(key); err != nil { 41 + return "", err 42 + } 43 + } 44 + } 45 + } 46 + 47 + conf := cfg.GetAll() 48 + maxLen := 0 49 + for key := range conf { 50 + if len(key) > maxLen { 51 + maxLen = len(key) 52 + } 53 + } 54 + var buf strings.Builder 55 + for key, val := range conf { 56 + fmt.Fprintf(&buf, ": %-*s = %s\n", maxLen, key, val) 57 + } 58 + 59 + buf.WriteByte('\n') 60 + return buf.String(), nil 61 +}
M
main.go
··· 4 4 "fmt" 5 5 "os" 6 6 7 - "github.com/olexsmir/viye/internal/plumbing/json" 7 + "github.com/olexsmir/viye/internal/plumbing/config" 8 8 "github.com/olexsmir/viye/internal/plumbing/files" 9 9 "github.com/olexsmir/viye/internal/plumbing/gobin" 10 10 "github.com/olexsmir/viye/internal/plumbing/ip" 11 + "github.com/olexsmir/viye/internal/plumbing/json" 11 12 "github.com/olexsmir/viye/internal/plumbing/shell" 12 13 "github.com/olexsmir/viye/internal/plumbing/url" 13 14 "github.com/olexsmir/viye/internal/viye" ··· 21 22 v.Register(&ip.Tool{}) 22 23 v.Register(&gobin.Tool{}) 23 24 v.Register(&json.Tool{}) 25 + v.Register(&config.Tool{}) 24 26 25 27 if err := v.Run(os.Stdout, os.Args); err != nil { 26 28 fmt.Fprintf(os.Stderr, "viye: %v\n", err)