all repos

viye @ 4e123d7d637534d8d950d5453448cba198a520f9

my shot at reimplementing xiki
8 files changed, 310 insertions(+), 4 deletions(-)
add config
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/config/default.conf

Not showing binary file.

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 internal/plumbing/files/files.go
···
        38
        38
         			if err != nil {

      
        39
        39
         				return "", err

      
        40
        40
         			}

      
        41
        
        -			return viye.FormatBulltlist(ls), nil

      
        
        41
        +			return viye.FormatBulletlist(ls), nil

      
        42
        42
         		}

      
        43
        43
         		ctx.Dir = path

      
        44
        44
         		ctx.Path = ctx.Path[1:]

      
M internal/plumbing/files/files_test.go
···
        45
        45
         		if err != nil {

      
        46
        46
         			t.Fatal(err)

      
        47
        47
         		}

      
        48
        
        -		if got != "x\n" {

      
        
        48
        +		if got != "- x\n" {

      
        49
        49
         			t.Errorf("Execute = %q; want %q", got, "x\\n")

      
        50
        50
         		}

      
        51
        51
         	})

      
M internal/viye/format.go
···
        18
        18
         	return strings.Join(lines, "\n") + "\n"

      
        19
        19
         }

      
        20
        20
         

      
        21
        
        -func FormatBulltlist(s string) string {

      
        
        21
        +func FormatBulletlist(s string) string {

      
        22
        22
         	lines := strings.Split(s, "\n")

      
        23
        23
         	for i, line := range lines {

      
        24
        24
         		if line != "" {

      
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)