From 8b8a66f2f2eb042c83a9edc868da06edf06dd9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Sat, 26 Dec 2020 14:49:20 +0100 Subject: [PATCH] Parse config values --- core/zk/config.go | 154 +++++++++++++++++++++++++----- core/zk/config_test.go | 208 ++++++++++++++++++++++++++++++++++++++--- core/zk/zk.go | 2 +- util/assert/assert.go | 8 +- util/opt/opt.go | 28 ++++++ util/rand/rand.go | 37 ++++++++ 6 files changed, 398 insertions(+), 39 deletions(-) create mode 100644 util/opt/opt.go create mode 100644 util/rand/rand.go diff --git a/core/zk/config.go b/core/zk/config.go index f680c61..0149b2c 100644 --- a/core/zk/config.go +++ b/core/zk/config.go @@ -1,45 +1,42 @@ package zk import ( - "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2/hclsimple" "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/util/rand" ) // Config holds the user configuration of a slip box. type Config struct { - root rootConfig -} - -func (c1 Config) Equal(c2 Config) bool { - return cmp.Equal(c1.root, c2.root) + rootConfig rootConfig } type rootConfig struct { - Editor string `hcl:"editor,optional"` - Extension string `hcl:"extension,optional"` - Filename string `hcl:"filename,optional"` - Template string `hcl:"template,optional"` - RandomID *randomIDConfig `hcl:"random_id,block"` - Dirs []dirConfig `hcl:"dir,block"` - Ext map[string]string `hcl:"ext,optional"` -} - -type randomIDConfig struct { - Charset string `hcl:"charset,optional"` - Length int `hcl:"length,optional"` - Case string `hcl:"case,optional"` + Filename string `hcl:"filename,optional"` + Template string `hcl:"template,optional"` + RandomID *randomIDConfig `hcl:"random_id,block"` + Editor string `hcl:"editor,optional"` + Dirs []dirConfig `hcl:"dir,block"` + Ext map[string]string `hcl:"ext,optional"` } type dirConfig struct { Dir string `hcl:"dir,label"` Filename string `hcl:"filename,optional"` Template string `hcl:"template,optional"` + RandomID *randomIDConfig `hcl:"random_id,block"` Ext map[string]string `hcl:"ext,optional"` } -// parseConfig creates a new Config instance from its HCL representation. -func parseConfig(content []byte) (*Config, error) { +type randomIDConfig struct { + Charset string `hcl:"charset,optional"` + Length int `hcl:"length,optional"` + Case string `hcl:"case,optional"` +} + +// ParseConfig creates a new Config instance from its HCL representation. +func ParseConfig(content []byte) (*Config, error) { var root rootConfig err := hclsimple.Decode(".zk/config.hcl", content, nil, &root) if err != nil { @@ -47,3 +44,118 @@ func parseConfig(content []byte) (*Config, error) { } return &Config{root}, nil } + +// Filename returns the filename template for the notes in the given directory. +func (c *Config) Filename(dir string) string { + dirConfig := c.dirConfig(dir) + + switch { + case dirConfig != nil && dirConfig.Filename != "": + return dirConfig.Filename + case c.rootConfig.Filename != "": + return c.rootConfig.Filename + default: + return "{{random-id}}" + } +} + +// Template returns the file template to use for the notes in the given directory. +func (c *Config) Template(dir string) opt.String { + dirConfig := c.dirConfig(dir) + + switch { + case dirConfig != nil && dirConfig.Template != "": + return opt.NewString(dirConfig.Template) + case c.rootConfig.Template != "": + return opt.NewString(c.rootConfig.Template) + default: + return opt.NullString + } +} + +// RandIDOpts returns the options to use to generate a random ID for the given directory. +func (c *Config) RandIDOpts(dir string) rand.IDOpts { + toCharset := func(charset string) []rune { + switch charset { + case "alphanum": + return rand.AlphanumCharset + case "hex": + return rand.HexCharset + case "letters": + return rand.LettersCharset + case "numbers": + return rand.NumbersCharset + default: + return []rune(charset) + } + } + + toCase := func(c string) rand.Case { + switch c { + case "lower": + return rand.LowerCase + case "upper": + return rand.UpperCase + case "mixed": + return rand.MixedCase + default: + return rand.LowerCase + } + } + + // Default options + opts := rand.IDOpts{ + Charset: rand.AlphanumCharset, + Length: 5, + Case: rand.LowerCase, + } + + merge := func(more *randomIDConfig) { + if more.Charset != "" { + opts.Charset = toCharset(more.Charset) + } + if more.Length > 0 { + opts.Length = more.Length + } + if more.Case != "" { + opts.Case = toCase(more.Case) + } + } + + if root := c.rootConfig.RandomID; root != nil { + merge(root) + } + + if dir := c.dirConfig(dir); dir != nil && dir.RandomID != nil { + merge(dir.RandomID) + } + + return opts +} + +// Ext returns the extra variables for the given directory. +func (c *Config) Ext(dir string) map[string]string { + ext := make(map[string]string) + + for k, v := range c.rootConfig.Ext { + ext[k] = v + } + + if dirConfig := c.dirConfig(dir); dirConfig != nil { + for k, v := range dirConfig.Ext { + ext[k] = v + } + } + + return ext +} + +// dirConfig returns the dirConfig instance for the given directory. +func (c *Config) dirConfig(dir string) *dirConfig { + for _, dirConfig := range c.rootConfig.Dirs { + if dirConfig.Dir == dir { + return &dirConfig + } + } + return nil +} diff --git a/core/zk/config_test.go b/core/zk/config_test.go index 81806a4..ca40daf 100644 --- a/core/zk/config_test.go +++ b/core/zk/config_test.go @@ -3,23 +3,23 @@ package zk import ( "testing" + "github.com/google/go-cmp/cmp" "github.com/mickael-menu/zk/util/assert" + "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/util/rand" ) -// Parse a minimal configuration file. func TestParseMinimal(t *testing.T) { - config, err := parseConfig([]byte("")) + config, err := ParseConfig([]byte("")) assert.Nil(t, err) assert.Equal(t, config, &Config{rootConfig{}}) } -// Parse a complete configuration file. func TestParseComplete(t *testing.T) { - config, err := parseConfig([]byte(` + config, err := ParseConfig([]byte(` // Comment editor = "vim" - extension = "note" filename = "{{random-id}}.note" template = "default.note" random_id { @@ -34,6 +34,11 @@ func TestParseComplete(t *testing.T) { dir "log" { filename = "{{date}}.md" template = "log.md" + random_id { + charset = "letters" + length = 8 + case = "mixed" + } ext = { log-ext = "value" } @@ -42,21 +47,25 @@ func TestParseComplete(t *testing.T) { assert.Nil(t, err) assert.Equal(t, config, &Config{rootConfig{ - Editor: "vim", - Extension: "note", - Filename: "{{random-id}}.note", - Template: "default.note", + Filename: "{{random-id}}.note", + Template: "default.note", RandomID: &randomIDConfig{ Charset: "alphanum", Length: 4, Case: "lower", }, + Editor: "vim", Dirs: []dirConfig{ dirConfig{ Dir: "log", Filename: "{{date}}.md", Template: "log.md", - Ext: map[string]string{"log-ext": "value"}, + RandomID: &randomIDConfig{ + Charset: "letters", + Length: 8, + Case: "mixed", + }, + Ext: map[string]string{"log-ext": "value"}, }, }, Ext: map[string]string{ @@ -66,10 +75,185 @@ func TestParseComplete(t *testing.T) { }}) } -// Parsing failure func TestParseInvalidConfig(t *testing.T) { - config, err := parseConfig([]byte("unknown = 'value'")) + config, err := ParseConfig([]byte("unknown = 'value'")) assert.NotNil(t, err) assert.Nil(t, config) } + +func TestDefaultFilename(t *testing.T) { + config := &Config{} + assert.Equal(t, config.Filename(""), "{{random-id}}") + assert.Equal(t, config.Filename("."), "{{random-id}}") + assert.Equal(t, config.Filename("unknown"), "{{random-id}}") +} + +func TestCustomFilename(t *testing.T) { + config := &Config{rootConfig{ + Filename: "root-filename", + Dirs: []dirConfig{ + dirConfig{ + Dir: "log", + Filename: "log-filename", + }, + }, + }} + assert.Equal(t, config.Filename(""), "root-filename") + assert.Equal(t, config.Filename("."), "root-filename") + assert.Equal(t, config.Filename("unknown"), "root-filename") + assert.Equal(t, config.Filename("log"), "log-filename") +} + +func TestDefaultTemplate(t *testing.T) { + config := &Config{} + assert.Equal(t, config.Template(""), opt.NullString) + assert.Equal(t, config.Template("."), opt.NullString) + assert.Equal(t, config.Template("unknown"), opt.NullString) +} + +func TestCustomTemplate(t *testing.T) { + config := &Config{rootConfig{ + Template: "root.tpl", + Dirs: []dirConfig{ + dirConfig{ + Dir: "log", + Template: "log.tpl", + }, + }, + }} + assert.Equal(t, config.Template(""), opt.NewString("root.tpl")) + assert.Equal(t, config.Template("."), opt.NewString("root.tpl")) + assert.Equal(t, config.Template("unknown"), opt.NewString("root.tpl")) + assert.Equal(t, config.Template("log"), opt.NewString("log.tpl")) +} + +func TestNoExtra(t *testing.T) { + config := &Config{} + assert.Equal(t, config.Ext(""), map[string]string{}) +} + +func TestMergeExtra(t *testing.T) { + config := &Config{rootConfig{ + Ext: map[string]string{ + "hello": "world", + "salut": "le monde", + }, + Dirs: []dirConfig{ + dirConfig{ + Dir: "log", + Ext: map[string]string{ + "hello": "override", + "additional": "value", + }, + }, + }, + }} + assert.Equal(t, config.Ext(""), map[string]string{ + "hello": "world", + "salut": "le monde", + }) + assert.Equal(t, config.Ext("."), map[string]string{ + "hello": "world", + "salut": "le monde", + }) + assert.Equal(t, config.Ext("unknown"), map[string]string{ + "hello": "world", + "salut": "le monde", + }) + assert.Equal(t, config.Ext("log"), map[string]string{ + "hello": "override", + "salut": "le monde", + "additional": "value", + }) + // Makes sure we didn't modify the extra in place by getting the `log` ones. + assert.Equal(t, config.Ext(""), map[string]string{ + "hello": "world", + "salut": "le monde", + }) +} + +func TestDefaultRandIDOpts(t *testing.T) { + config := &Config{} + defaultOpts := rand.IDOpts{ + Charset: rand.AlphanumCharset, + Length: 5, + Case: rand.LowerCase, + } + + assert.Equal(t, config.RandIDOpts(""), defaultOpts) + assert.Equal(t, config.RandIDOpts("."), defaultOpts) + assert.Equal(t, config.RandIDOpts("unknown"), defaultOpts) +} + +func TestOverrideRandIDOpts(t *testing.T) { + config := &Config{rootConfig{ + RandomID: &randomIDConfig{ + Charset: "alphanum", + Length: 42, + }, + Dirs: []dirConfig{ + dirConfig{ + Dir: "log", + RandomID: &randomIDConfig{ + Length: 28, + }, + }, + }, + }} + + expectedRootOpts := rand.IDOpts{ + Charset: rand.AlphanumCharset, + Length: 42, + Case: rand.LowerCase, + } + assert.Equal(t, config.RandIDOpts(""), expectedRootOpts) + assert.Equal(t, config.RandIDOpts("."), expectedRootOpts) + assert.Equal(t, config.RandIDOpts("unknown"), expectedRootOpts) + + assert.Equal(t, config.RandIDOpts("log"), rand.IDOpts{ + Charset: rand.AlphanumCharset, + Length: 28, + Case: rand.LowerCase, + }) +} + +func TestParseRandIDCharset(t *testing.T) { + test := func(charset string, expected []rune) { + config := &Config{rootConfig{ + RandomID: &randomIDConfig{ + Charset: charset, + }, + }} + + if !cmp.Equal(config.RandIDOpts("").Charset, expected) { + t.Errorf("Didn't parse random ID charset `%v` as expected", charset) + } + } + + test("alphanum", rand.AlphanumCharset) + test("hex", rand.HexCharset) + test("letters", rand.LettersCharset) + test("numbers", rand.NumbersCharset) + test("HEX", []rune("HEX")) // case sensitive + test("custom", []rune("custom")) +} + +func TestParseRandIDCase(t *testing.T) { + test := func(letterCase string, expected rand.Case) { + config := &Config{rootConfig{ + RandomID: &randomIDConfig{ + Case: letterCase, + }, + }} + + if !cmp.Equal(config.RandIDOpts("").Case, expected) { + t.Errorf("Didn't parse random ID case `%v` as expected", letterCase) + } + } + + test("lower", rand.LowerCase) + test("upper", rand.UpperCase) + test("mixed", rand.MixedCase) + test("unknown", rand.LowerCase) +} diff --git a/core/zk/zk.go b/core/zk/zk.go index 8b212a0..fe3a8f3 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -37,7 +37,7 @@ func Open(path string) (*Zk, error) { return nil, wrap(err) } - config, err := parseConfig(configContent) + config, err := ParseConfig(configContent) if err != nil { return nil, wrap(err) } diff --git a/util/assert/assert.go b/util/assert/assert.go index c19ad83..9b59625 100644 --- a/util/assert/assert.go +++ b/util/assert/assert.go @@ -3,8 +3,6 @@ package assert import ( "reflect" "testing" - - "github.com/google/go-cmp/cmp" ) func Nil(t *testing.T, value interface{}) { @@ -24,8 +22,8 @@ func isNil(value interface{}) bool { (reflect.ValueOf(value).Kind() == reflect.Ptr && reflect.ValueOf(value).IsNil()) } -func Equal(t *testing.T, value interface{}, expected interface{}) { - if !cmp.Equal(value, expected) { - t.Errorf("Received %+v (type %v), expected %+v (type %v)", value, reflect.TypeOf(value), expected, reflect.TypeOf(expected)) +func Equal(t *testing.T, actual, expected interface{}) { + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Received %+v (type %v), expected %+v (type %v)", actual, reflect.TypeOf(actual), expected, reflect.TypeOf(expected)) } } diff --git a/util/opt/opt.go b/util/opt/opt.go new file mode 100644 index 0000000..ac10fcc --- /dev/null +++ b/util/opt/opt.go @@ -0,0 +1,28 @@ +package opt + +// String holds an optional string value. +type String struct { + value *string +} + +// NewString creates a new optional String with the given value. +func NewString(value string) String { + return String{&value} +} + +// NullString repreents an empty optional String. +var NullString = String{nil} + +// IsNull returns whether the optional String has no value. +func (s String) IsNull() bool { + return s.value == nil +} + +// OrDefault returns the optional String value or the given default string if it is null. +func (s String) OrDefault(def string) string { + if s.value == nil { + return def + } else { + return *s.value + } +} diff --git a/util/rand/rand.go b/util/rand/rand.go new file mode 100644 index 0000000..7a77aec --- /dev/null +++ b/util/rand/rand.go @@ -0,0 +1,37 @@ +package rand + +var ( + // AlphanumCharset is a charset containing letters and numbers. + AlphanumCharset = []rune("0123456789abcdefghijklmnopqrstuvwxyz") + // AlphanumCharset is a charset containing hexadecimal characters. + HexCharset = []rune("0123456789abcdef") + // LettersCharset is a charset containing only letters. + LettersCharset = []rune("0123456789abcdefghijklmnopqrstuvwxyz") + // NumbersCharset is a charset containing only numbers. + NumbersCharset = []rune("0123456789abcdefghijklmnopqrstuvwxyz") +) + +// Case represents the letter case to use when generating a string. +type Case int + +const ( + LowerCase Case = iota + UpperCase + MixedCase +) + +// IDOpts holds the options used to generate a random ID. +type IDOpts struct { + Charset []rune + Length int + Case Case +} + +// GenID creates a new random string ID using the given options. +func GenID(options IDOpts) string { + if options.Length < 1 { + panic("IDOpts.Length must be at least 1") + } + + return "" +}