diff --git a/core/zk/config.go b/core/zk/config.go index 098f259..d4208c5 100644 --- a/core/zk/config.go +++ b/core/zk/config.go @@ -1,40 +1,185 @@ package zk import ( + "path/filepath" + "github.com/hashicorp/hcl/v2/hclsimple" "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/util/opt" ) -// config holds the user configuration of a slip box. -type config struct { +// Config holds the global user configuration. +type Config struct { + DirConfig + Dirs map[string]DirConfig + Editor opt.String +} + +// DirConfig holds the user configuration for a given directory. +type DirConfig struct { + FilenameTemplate string + BodyTemplatePath opt.String + IDOptions IDOptions + Extra map[string]string +} + +// ConfigOverrides holds user configuration overriden values, for example fed +// from CLI flags. +type ConfigOverrides struct { + BodyTemplatePath opt.String + Extra map[string]string +} + +// ParseConfig creates a new Config instance from its HCL representation. +// templatesDir is the base path for the relative templates. +func ParseConfig(content []byte, templatesDir string) (*Config, error) { + var hcl hclConfig + err := hclsimple.Decode(".zk/config.hcl", content, nil, &hcl) + if err != nil { + return nil, errors.Wrap(err, "failed to read config") + } + + root := DirConfig{ + FilenameTemplate: "{{id}}", + BodyTemplatePath: opt.NullString, + IDOptions: IDOptions{ + Charset: CharsetAlphanum, + Length: 5, + Case: CaseLower, + }, + Extra: make(map[string]string), + } + + if hcl.Filename != "" { + root.FilenameTemplate = hcl.Filename + } + if hcl.Template != "" { + root.BodyTemplatePath = templatePathFromString(hcl.Template, templatesDir) + } + if hcl.ID != nil { + if hcl.ID.Length != 0 { + root.IDOptions.Length = hcl.ID.Length + } + if hcl.ID.Charset != "" { + root.IDOptions.Charset = charsetFromString(hcl.ID.Charset) + } + if hcl.ID.Case != "" { + root.IDOptions.Case = caseFromString(hcl.ID.Case) + } + } + if hcl.Extra != nil { + for k, v := range hcl.Extra { + root.Extra[k] = v + } + } + + config := Config{ + DirConfig: root, + Dirs: make(map[string]DirConfig), + Editor: opt.NewNotEmptyString(hcl.Editor), + } + + for _, dirHCL := range hcl.Dirs { + config.Dirs[dirHCL.Dir] = root.merge(dirHCL, templatesDir) + } + + return &config, nil +} + +func (c DirConfig) merge(hcl hclDirConfig, templatesDir string) DirConfig { + res := DirConfig{ + FilenameTemplate: c.FilenameTemplate, + BodyTemplatePath: c.BodyTemplatePath, + IDOptions: c.IDOptions, + Extra: make(map[string]string), + } + for k, v := range c.Extra { + res.Extra[k] = v + } + + if hcl.Filename != "" { + res.FilenameTemplate = hcl.Filename + } + if hcl.Template != "" { + res.BodyTemplatePath = templatePathFromString(hcl.Template, templatesDir) + } + if hcl.ID != nil { + if hcl.ID.Length != 0 { + res.IDOptions.Length = hcl.ID.Length + } + if hcl.ID.Charset != "" { + res.IDOptions.Charset = charsetFromString(hcl.ID.Charset) + } + if hcl.ID.Case != "" { + res.IDOptions.Case = caseFromString(hcl.ID.Case) + } + } + if hcl.Extra != nil { + for k, v := range hcl.Extra { + res.Extra[k] = v + } + } + return res +} + +// hclConfig holds the HCL representation of Config +type hclConfig struct { Filename string `hcl:"filename,optional"` Template string `hcl:"template,optional"` - ID *idConfig `hcl:"id,block"` - Editor string `hcl:"editor,optional"` - Dirs []dirConfig `hcl:"dir,block"` + ID *hclIDConfig `hcl:"id,block"` Extra map[string]string `hcl:"extra,optional"` + Dirs []hclDirConfig `hcl:"dir,block"` + Editor string `hcl:"editor,optional"` } -type dirConfig struct { +type hclDirConfig struct { Dir string `hcl:"dir,label"` Filename string `hcl:"filename,optional"` Template string `hcl:"template,optional"` - ID *idConfig `hcl:"id,block"` + ID *hclIDConfig `hcl:"id,block"` Extra map[string]string `hcl:"extra,optional"` } -type idConfig struct { +type hclIDConfig 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 config config - err := hclsimple.Decode(".zk/config.hcl", content, nil, &config) - if err != nil { - return nil, errors.Wrap(err, "failed to read config") +func charsetFromString(charset string) Charset { + switch charset { + case "alphanum": + return CharsetAlphanum + case "hex": + return CharsetHex + case "letters": + return CharsetLetters + case "numbers": + return CharsetNumbers + default: + return Charset(charset) } - return &config, nil +} + +func caseFromString(c string) Case { + switch c { + case "lower": + return CaseLower + case "upper": + return CaseUpper + case "mixed": + return CaseMixed + default: + return CaseLower + } +} + +func templatePathFromString(template string, templatesDir string) opt.String { + if template == "" { + return opt.NullString + } + if !filepath.IsAbs(template) { + template = filepath.Join(templatesDir, template) + } + return opt.NewString(template) } diff --git a/core/zk/config_test.go b/core/zk/config_test.go index db3de2c..ec8a8cf 100644 --- a/core/zk/config_test.go +++ b/core/zk/config_test.go @@ -1,20 +1,43 @@ package zk import ( + "fmt" "testing" + "github.com/google/go-cmp/cmp" "github.com/mickael-menu/zk/util/assert" + "github.com/mickael-menu/zk/util/opt" ) -func TestParseMinimal(t *testing.T) { - conf, err := parseConfig([]byte("")) +func TestParseDefaultConfig(t *testing.T) { + conf, err := ParseConfig([]byte(""), "") assert.Nil(t, err) - assert.Equal(t, conf, &config{}) + assert.Equal(t, conf, &Config{ + Editor: opt.NullString, + DirConfig: DirConfig{ + FilenameTemplate: "{{id}}", + BodyTemplatePath: opt.NullString, + IDOptions: IDOptions{ + Length: 5, + Charset: CharsetAlphanum, + Case: CaseLower, + }, + Extra: make(map[string]string), + }, + Dirs: make(map[string]DirConfig), + }) +} + +func TestParseInvalidConfig(t *testing.T) { + conf, err := ParseConfig([]byte("unknown = 'value'"), "") + + assert.NotNil(t, err) + assert.Nil(t, conf) } func TestParseComplete(t *testing.T) { - conf, err := parseConfig([]byte(` + conf, err := ParseConfig([]byte(` // Comment editor = "vim" filename = "{{id}}.note" @@ -40,41 +63,179 @@ func TestParseComplete(t *testing.T) { log-ext = "value" } } - `)) + dir "ref" { + filename = "{{slug title}}.md" + } + `), "") assert.Nil(t, err) - assert.Equal(t, conf, &config{ - Filename: "{{id}}.note", - Template: "default.note", - ID: &idConfig{ - Charset: "alphanum", - Length: 4, - Case: "lower", + assert.Equal(t, conf, &Config{ + DirConfig: DirConfig{ + FilenameTemplate: "{{id}}.note", + BodyTemplatePath: opt.NewString("default.note"), + IDOptions: IDOptions{ + Length: 4, + Charset: CharsetAlphanum, + Case: CaseLower, + }, + Extra: map[string]string{ + "hello": "world", + "salut": "le monde", + }, }, - Editor: "vim", - Dirs: []dirConfig{ - { - Dir: "log", - Filename: "{{date}}.md", - Template: "log.md", - ID: &idConfig{ - Charset: "letters", + Dirs: map[string]DirConfig{ + "log": { + FilenameTemplate: "{{date}}.md", + BodyTemplatePath: opt.NewString("log.md"), + IDOptions: IDOptions{ Length: 8, - Case: "mixed", + Charset: CharsetLetters, + Case: CaseMixed, + }, + Extra: map[string]string{ + "hello": "world", + "salut": "le monde", + "log-ext": "value", + }, + }, + "ref": { + FilenameTemplate: "{{slug title}}.md", + BodyTemplatePath: opt.NewString("default.note"), + IDOptions: IDOptions{ + Length: 4, + Charset: CharsetAlphanum, + Case: CaseLower, }, - Extra: map[string]string{"log-ext": "value"}, + Extra: map[string]string{ + "hello": "world", + "salut": "le monde", + }, + }, + }, + Editor: opt.NewString("vim"), + }) +} + +func TestParseMergesDirConfig(t *testing.T) { + conf, err := ParseConfig([]byte(` + filename = "root-filename" + template = "root-template" + id { + charset = "letters" + length = 42 + case = "upper" + } + extra = { + hello = "world" + salut = "le monde" + } + dir "log" { + filename = "log-filename" + template = "log-template" + id { + charset = "numbers" + length = 8 + case = "mixed" + } + extra = { + hello = "override" + log-ext = "value" + } + } + dir "inherited" {} + `), "") + + assert.Nil(t, err) + assert.Equal(t, conf, &Config{ + DirConfig: DirConfig{ + FilenameTemplate: "root-filename", + BodyTemplatePath: opt.NewString("root-template"), + IDOptions: IDOptions{ + Length: 42, + Charset: CharsetLetters, + Case: CaseUpper, + }, + Extra: map[string]string{ + "hello": "world", + "salut": "le monde", }, }, - Extra: map[string]string{ - "hello": "world", - "salut": "le monde", + Dirs: map[string]DirConfig{ + "log": { + FilenameTemplate: "log-filename", + BodyTemplatePath: opt.NewString("log-template"), + IDOptions: IDOptions{ + Length: 8, + Charset: CharsetNumbers, + Case: CaseMixed, + }, + Extra: map[string]string{ + "hello": "override", + "salut": "le monde", + "log-ext": "value", + }, + }, + "inherited": { + FilenameTemplate: "root-filename", + BodyTemplatePath: opt.NewString("root-template"), + IDOptions: IDOptions{ + Length: 42, + Charset: CharsetLetters, + Case: CaseUpper, + }, + Extra: map[string]string{ + "hello": "world", + "salut": "le monde", + }, + }, }, }) } -func TestParseInvalidConfig(t *testing.T) { - conf, err := parseConfig([]byte("unknown = 'value'")) +func TestParseIDCharset(t *testing.T) { + test := func(charset string, expected Charset) { + hcl := fmt.Sprintf(`id { charset = "%v" }`, charset) + conf, err := ParseConfig([]byte(hcl), "") + assert.Nil(t, err) + if !cmp.Equal(conf.IDOptions.Charset, expected) { + t.Errorf("Didn't parse ID charset `%v` as expected", charset) + } + } - assert.NotNil(t, err) - assert.Nil(t, conf) + test("alphanum", CharsetAlphanum) + test("hex", CharsetHex) + test("letters", CharsetLetters) + test("numbers", CharsetNumbers) + test("HEX", []rune("HEX")) // case sensitive + test("custom", []rune("custom")) +} + +func TestParseIDCase(t *testing.T) { + test := func(letterCase string, expected Case) { + hcl := fmt.Sprintf(`id { case = "%v" }`, letterCase) + conf, err := ParseConfig([]byte(hcl), "") + assert.Nil(t, err) + if !cmp.Equal(conf.IDOptions.Case, expected) { + t.Errorf("Didn't parse ID case `%v` as expected", letterCase) + } + } + + test("lower", CaseLower) + test("upper", CaseUpper) + test("mixed", CaseMixed) + test("unknown", CaseLower) +} + +func TestParseResolvesTemplatePaths(t *testing.T) { + test := func(template string, expected string) { + hcl := fmt.Sprintf(`template = "%v"`, template) + conf, err := ParseConfig([]byte(hcl), "/test/.zk/templates") + assert.Nil(t, err) + if !cmp.Equal(conf.BodyTemplatePath, opt.NewString(expected)) { + t.Errorf("Didn't resolve template `%v` as expected: %v", template, conf.BodyTemplatePath) + } + } + + test("template.tpl", "/test/.zk/templates/template.tpl") + test("/abs/template.tpl", "/abs/template.tpl") } diff --git a/core/zk/zk.go b/core/zk/zk.go index c33568a..7d23b31 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -7,7 +7,7 @@ import ( "path/filepath" "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/util/paths" ) const defaultConfig = `editor = "nvim" @@ -20,8 +20,8 @@ dir "log" { type Zk struct { // Slip box root path. Path string - // User configuration parsed from .zk/config.hsl. - config config + // Global user configuration. + Config Config } // Dir represents a directory inside a slip box. @@ -30,6 +30,8 @@ type Dir struct { Name string // Absolute path to the directory. Path string + // User configuration for this directory. + Config DirConfig } // Open locates a slip box at the given path and parses its configuration. @@ -50,14 +52,15 @@ func Open(path string) (*Zk, error) { return nil, wrap(err) } - config, err := parseConfig(configContent) + templatesDir := filepath.Join(path, ".zk/templates") + config, err := ParseConfig(configContent, templatesDir) if err != nil { return nil, wrap(err) } return &Zk{ Path: path, - config: *config, + Config: *config, }, nil } @@ -104,29 +107,20 @@ func locateRoot(path string) (string, error) { if currentPath == "/" || currentPath == "." { return "", fmt.Errorf("no slip box found in %v or a parent directory", path) } - if dotPath := filepath.Join(currentPath, ".zk"); dirExists(dotPath) { + exists, err := paths.DirExists(filepath.Join(currentPath, ".zk")) + switch { + case err != nil: + return "", err + case exists: return currentPath, nil + default: + return locate(filepath.Dir(currentPath)) } - - return locate(filepath.Dir(currentPath)) } return locate(path) } -func dirExists(path string) bool { - fi, err := os.Stat(path) - if err != nil { - return false - } - switch mode := fi.Mode(); { - case mode.IsDir(): - return true - default: - return false - } -} - // DirAt creates a Dir representation of the slip box directory at the given path. func (zk *Zk) DirAt(path string) (*Dir, error) { wrap := errors.Wrapperf("%v: not a valid slip box directory", path) @@ -141,132 +135,32 @@ func (zk *Zk) DirAt(path string) (*Dir, error) { return nil, wrap(err) } - return &Dir{ - Name: name, - Path: path, - }, nil -} - -// FilenameTemplate returns the filename template for the notes in the given directory. -func (zk *Zk) FilenameTemplate(dir Dir) string { - dirConfig := zk.dirConfig(dir) - - switch { - case dirConfig != nil && dirConfig.Filename != "": - return dirConfig.Filename - case zk.config.Filename != "": - return zk.config.Filename - default: - return "{{id}}" - } -} - -// Template returns the file template to use for the notes in the given directory. -func (zk *Zk) Template(dir Dir) opt.String { - dirConfig := zk.dirConfig(dir) - - var template string - switch { - case dirConfig != nil && dirConfig.Template != "": - template = dirConfig.Template - case zk.config.Template != "": - template = zk.config.Template - } - - if template == "" { - return opt.NullString - } - - if !filepath.IsAbs(template) { - template = filepath.Join(zk.Path, ".zk/templates", template) - } - - return opt.NewString(template) -} - -// IDOptions returns the options to use to generate an ID for the given directory. -func (zk *Zk) IDOptions(dir Dir) IDOptions { - toCharset := func(charset string) Charset { - switch charset { - case "alphanum": - return CharsetAlphanum - case "hex": - return CharsetHex - case "letters": - return CharsetLetters - case "numbers": - return CharsetNumbers - default: - return Charset(charset) - } - } - - toCase := func(c string) Case { - switch c { - case "lower": - return CaseLower - case "upper": - return CaseUpper - case "mixed": - return CaseMixed - default: - return CaseLower - } - } - - // Default options - opts := IDOptions{ - Charset: CharsetAlphanum, - Length: 5, - Case: CaseLower, - } - - merge := func(more *idConfig) { - 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 := zk.config.ID; root != nil { - merge(root) - } - - if dir := zk.dirConfig(dir); dir != nil && dir.ID != nil { - merge(dir.ID) + config, ok := zk.Config.Dirs[name] + if !ok { + // Fallback on root config. + config = zk.Config.DirConfig } - return opts + return &Dir{ + Name: name, + Path: path, + Config: config, + }, nil } -// Extra returns the extra variables for the given directory. -func (zk *Zk) Extra(dir Dir) map[string]string { - extra := make(map[string]string) - - for k, v := range zk.config.Extra { - extra[k] = v +// RequiredDirAt is the same as DirAt, but checks that the directory exists +// before returning the Dir. +func (zk *Zk) RequireDirAt(path string) (*Dir, error) { + dir, err := zk.DirAt(path) + if err != nil { + return nil, err } - - if dirConfig := zk.dirConfig(dir); dirConfig != nil { - for k, v := range dirConfig.Extra { - extra[k] = v - } + exists, err := paths.Exists(dir.Path) + if err != nil { + return nil, err } - - return extra -} - -// dirConfig returns the dirConfig instance for the given directory. -func (zk *Zk) dirConfig(dir Dir) *dirConfig { - for _, dirConfig := range zk.config.Dirs { - if dirConfig.Dir == dir.Name { - return &dirConfig - } + if !exists { + return nil, fmt.Errorf("%v: directory not found", path) } - return nil + return dir, nil } diff --git a/core/zk/zk_test.go b/core/zk/zk_test.go index cc996ec..745281b 100644 --- a/core/zk/zk_test.go +++ b/core/zk/zk_test.go @@ -5,12 +5,11 @@ import ( "path/filepath" "testing" - "github.com/google/go-cmp/cmp" "github.com/mickael-menu/zk/util/assert" "github.com/mickael-menu/zk/util/opt" ) -func TestDirAt(t *testing.T) { +func TestDirAtGivenPath(t *testing.T) { // The tests are relative to the working directory, for convenience. wd, err := os.Getwd() assert.Nil(t, err) @@ -31,190 +30,43 @@ func TestDirAt(t *testing.T) { } } -func TestDefaultFilenameTemplate(t *testing.T) { - zk := &Zk{} - assert.Equal(t, zk.FilenameTemplate(dir("")), "{{id}}") - assert.Equal(t, zk.FilenameTemplate(dir(".")), "{{id}}") - assert.Equal(t, zk.FilenameTemplate(dir("unknown")), "{{id}}") -} - -func TestCustomFilenameTemplate(t *testing.T) { - zk := &Zk{config: config{ - Filename: "root-filename", - Dirs: []dirConfig{ - { - Dir: "log", - Filename: "log-filename", - }, - }, - }} - assert.Equal(t, zk.FilenameTemplate(dir("")), "root-filename") - assert.Equal(t, zk.FilenameTemplate(dir(".")), "root-filename") - assert.Equal(t, zk.FilenameTemplate(dir("unknown")), "root-filename") - assert.Equal(t, zk.FilenameTemplate(dir("log")), "log-filename") -} - -func TestDefaultTemplate(t *testing.T) { - zk := &Zk{} - assert.Equal(t, zk.Template(dir("")), opt.NullString) - assert.Equal(t, zk.Template(dir(".")), opt.NullString) - assert.Equal(t, zk.Template(dir("unknown")), opt.NullString) -} - -func TestCustomTemplate(t *testing.T) { - zk := &Zk{ +// When requesting the root directory `.`, the config is the default one. +func TestDirAtRoot(t *testing.T) { + zk := Zk{ Path: "/test", - config: config{ - Template: "root.tpl", - Dirs: []dirConfig{ - { - Dir: "log", - Template: "log.tpl", + Config: Config{ + DirConfig: DirConfig{ + FilenameTemplate: "{{id}}.note", + BodyTemplatePath: opt.NewString("default.note"), + IDOptions: IDOptions{ + Length: 4, + Charset: CharsetAlphanum, + Case: CaseLower, }, - { - Dir: "abs", - Template: "/abs/template.tpl", + Extra: map[string]string{ + "hello": "world", }, }, - }, - } - assert.Equal(t, zk.Template(dir("")), opt.NewString("/test/.zk/templates/root.tpl")) - assert.Equal(t, zk.Template(dir(".")), opt.NewString("/test/.zk/templates/root.tpl")) - assert.Equal(t, zk.Template(dir("unknown")), opt.NewString("/test/.zk/templates/root.tpl")) - assert.Equal(t, zk.Template(dir("log")), opt.NewString("/test/.zk/templates/log.tpl")) - assert.Equal(t, zk.Template(dir("abs")), opt.NewString("/abs/template.tpl")) -} - -func TestNoExtra(t *testing.T) { - zk := &Zk{} - assert.Equal(t, zk.Extra(dir("")), map[string]string{}) -} - -func TestMergeExtra(t *testing.T) { - zk := &Zk{config: config{ - Extra: map[string]string{ - "hello": "world", - "salut": "le monde", - }, - Dirs: []dirConfig{ - { - Dir: "log", - Extra: map[string]string{ - "hello": "override", - "additional": "value", + Dirs: map[string]DirConfig{ + "log": { + FilenameTemplate: "{{date}}.md", }, }, }, - }} - assert.Equal(t, zk.Extra(dir("")), map[string]string{ - "hello": "world", - "salut": "le monde", - }) - assert.Equal(t, zk.Extra(dir(".")), map[string]string{ - "hello": "world", - "salut": "le monde", - }) - assert.Equal(t, zk.Extra(dir("unknown")), map[string]string{ - "hello": "world", - "salut": "le monde", - }) - assert.Equal(t, zk.Extra(dir("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, zk.Extra(dir("")), map[string]string{ - "hello": "world", - "salut": "le monde", - }) -} - -func TestDefaultIDOptions(t *testing.T) { - zk := &Zk{} - defaultOpts := IDOptions{ - Charset: CharsetAlphanum, - Length: 5, - Case: CaseLower, } - assert.Equal(t, zk.IDOptions(dir("")), defaultOpts) - assert.Equal(t, zk.IDOptions(dir(".")), defaultOpts) - assert.Equal(t, zk.IDOptions(dir("unknown")), defaultOpts) -} - -func TestOverrideIDOptions(t *testing.T) { - zk := &Zk{config: config{ - ID: &idConfig{ - Charset: "alphanum", - Length: 42, + dir, err := zk.DirAt(".") + assert.Nil(t, err) + assert.Equal(t, dir.Config, DirConfig{ + FilenameTemplate: "{{id}}.note", + BodyTemplatePath: opt.NewString("default.note"), + IDOptions: IDOptions{ + Length: 4, + Charset: CharsetAlphanum, + Case: CaseLower, }, - Dirs: []dirConfig{ - { - Dir: "log", - ID: &idConfig{ - Length: 28, - }, - }, + Extra: map[string]string{ + "hello": "world", }, - }} - - expectedRootOpts := IDOptions{ - Charset: CharsetAlphanum, - Length: 42, - Case: CaseLower, - } - assert.Equal(t, zk.IDOptions(dir("")), expectedRootOpts) - assert.Equal(t, zk.IDOptions(dir(".")), expectedRootOpts) - assert.Equal(t, zk.IDOptions(dir("unknown")), expectedRootOpts) - - assert.Equal(t, zk.IDOptions(dir("log")), IDOptions{ - Charset: CharsetAlphanum, - Length: 28, - Case: CaseLower, }) } - -func TestParseIDCharset(t *testing.T) { - test := func(charset string, expected Charset) { - zk := &Zk{config: config{ - ID: &idConfig{ - Charset: charset, - }, - }} - - if !cmp.Equal(zk.IDOptions(dir("")).Charset, expected) { - t.Errorf("Didn't parse ID charset `%v` as expected", charset) - } - } - - test("alphanum", CharsetAlphanum) - test("hex", CharsetHex) - test("letters", CharsetLetters) - test("numbers", CharsetNumbers) - test("HEX", []rune("HEX")) // case sensitive - test("custom", []rune("custom")) -} - -func TestParseIDCase(t *testing.T) { - test := func(letterCase string, expected Case) { - zk := &Zk{config: config{ - ID: &idConfig{ - Case: letterCase, - }, - }} - - if !cmp.Equal(zk.IDOptions(dir("")).Case, expected) { - t.Errorf("Didn't parse ID case `%v` as expected", letterCase) - } - } - - test("lower", CaseLower) - test("upper", CaseUpper) - test("mixed", CaseMixed) - test("unknown", CaseLower) -} - -func dir(name string) Dir { - return Dir{Name: name, Path: name} -} diff --git a/util/assert/assert.go b/util/assert/assert.go index 62bac5d..11d5038 100644 --- a/util/assert/assert.go +++ b/util/assert/assert.go @@ -1,6 +1,7 @@ package assert import ( + "encoding/json" "reflect" "testing" ) @@ -24,6 +25,13 @@ func isNil(value interface{}) bool { 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)) + t.Errorf("Received (type %v):\n%+v\n---\nBut expected (type %v):\n%+v", reflect.TypeOf(actual), toJSON(t, actual), reflect.TypeOf(expected), toJSON(t, expected)) } } + +func toJSON(t *testing.T, obj interface{}) string { + json, err := json.Marshal(obj) + // json, err := json.MarshalIndent(obj, "", " ") + Nil(t, err) + return string(json) +} diff --git a/util/opt/opt.go b/util/opt/opt.go index 1b542ef..d6f54ba 100644 --- a/util/opt/opt.go +++ b/util/opt/opt.go @@ -1,5 +1,7 @@ package opt +import "fmt" + // String holds an optional string value. type String struct { value *string @@ -28,9 +30,20 @@ func (s String) IsNull() bool { return s.value == nil } -// OrDefault returns the optional String value or the given default string if it is null. +// Or returns the receiver if it is not null, otherwise the given optional +// String. +func (s String) Or(other String) String { + if s.IsNull() { + return other + } else { + return s + } +} + +// 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 { + if s.IsNull() { return def } else { return *s.value @@ -42,6 +55,14 @@ func (s String) Unwrap() string { return s.OrDefault("") } +func (s String) Equal(other String) bool { + return s.value == other.value || *s.value == *other.value +} + func (s String) String() string { return s.OrDefault("") } + +func (s String) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%v"`, s)), nil +} diff --git a/util/paths/paths.go b/util/paths/paths.go index 3945ead..b4e3fe9 100644 --- a/util/paths/paths.go +++ b/util/paths/paths.go @@ -8,12 +8,31 @@ import ( // Exists returns whether the given path exists on the file system. func Exists(path string) (bool, error) { - if _, err := os.Stat(path); err == nil { - return true, nil - } else if os.IsNotExist(err) { - return false, nil + fi, err := fileInfo(path) + if err != nil { + return false, err } else { + return fi != nil, nil + } +} + +// DirExists returns whether the given path exists and is a directory. +func DirExists(path string) (bool, error) { + fi, err := fileInfo(path) + if err != nil { return false, err + } else { + return fi != nil && (*fi).Mode().IsDir(), nil + } +} + +func fileInfo(path string) (*os.FileInfo, error) { + if fi, err := os.Stat(path); err == nil { + return &fi, nil + } else if os.IsNotExist(err) { + return nil, nil + } else { + return nil, err } }