Skip to content

Commit

Permalink
feat: scan for defaults file
Browse files Browse the repository at this point in the history
  • Loading branch information
lucassabreu committed Dec 21, 2022
1 parent a0d0e0d commit 5cf64ef
Show file tree
Hide file tree
Showing 10 changed files with 420 additions and 0 deletions.
139 changes: 139 additions & 0 deletions pkg/cmd/time-entry/util/defaults/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package defaults

import (
"encoding/json"
"os"
"path/filepath"
"strings"

"github.com/lucassabreu/clockify-cli/strhlp"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)

// DefaultTimeEntry has the default properties for the working directory
type DefaultTimeEntry struct {
Workspace string `json:"workspace,omitempty" yaml:"workspace,omitempty"`
ProjectID string `json:"project,omitempty" yaml:"project,omitempty"`
TaskID string `json:"task,omitempty" yaml:"task,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Billable *bool `json:"billable,omitempty" yaml:"billable,omitempty"`
TagIDs []string `json:"tags,omitempty" yaml:"tags,omitempty,flow"`
}

// ScanParam sets how ScanForDefaults should look for defaults
type ScanParam struct {
Dir string
Filename string
}

// WriteDefaults persists the default values to a file
func WriteDefaults(dir, filename string, d DefaultTimeEntry) error {
n := filepath.Join(dir, filename)
f, err := os.OpenFile(n, os.O_CREATE|os.O_RDWR, os.ModePerm)
if err != nil {
return err
}

if strings.HasSuffix(f.Name(), "json") {
return json.NewEncoder(f).Encode(d)
}

return yaml.NewEncoder(f).Encode(d)
}

// ScanError wraps errors from scanning for the defaults file
type ScanError struct {
Err error
}

// Error shows error message
func (s *ScanError) Error() string {
return s.Unwrap().Error()
}

// Unwrap gives access to the error chain
func (s *ScanError) Unwrap() error {
return s.Err
}

// DefaultsFileNotFoundErr is returned when the scan can't find any files
var DefaultsFileNotFoundErr = errors.New("defaults file not found")

// ScanForDefaults scan the directory informed and its parents for the defaults
// file
func ScanForDefaults(p ScanParam) func() (DefaultTimeEntry, error) {
return func() (DefaultTimeEntry, error) {
if p.Filename == "" {
p.Filename = ".clockify-defaults"
}

dir := filepath.FromSlash(p.Dir)
d := DefaultTimeEntry{}
for {
f, err := firstMatch(dir, p.Filename)
if err != nil {
return d, &ScanError{
Err: errors.Wrap(
err, "failed to open defaults file"),
}
}

if f == nil {
nDir := filepath.Dir(dir)
if nDir == dir {
return d, DefaultsFileNotFoundErr
}

dir = nDir
continue
}

if strings.HasSuffix(f.Name(), "json") {
err = json.NewDecoder(f).Decode(&d)
} else {
err = yaml.NewDecoder(f).Decode(&d)
}

if err != nil {
return d, &ScanError{
Err: errors.Wrap(
err, "failed to decode defaults file"),
}
}

return d, nil
}
}
}

func firstMatch(dir, filename string) (*os.File, error) {
ms, _ := filepath.Glob(filepath.Join(dir, filename+".*"))
if len(ms) == 0 {
return nil, nil
}

ms = strhlp.Filter(
func(s string) bool {
return strings.HasSuffix(s, ".json") ||
strings.HasSuffix(s, ".yml") ||
strings.HasSuffix(s, ".yaml")
},
ms,
)

for _, m := range ms {
entry, err := os.Open(m)
if err != nil {
return nil, err
}

s, err := entry.Stat()
if err != nil || s.IsDir() {
continue

}
return entry, nil
}
return nil, nil
}
245 changes: 245 additions & 0 deletions pkg/cmd/time-entry/util/defaults/defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package defaults_test

import (
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"

"github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults"
"github.com/stretchr/testify/assert"
)

func TestWriteDefaults(t *testing.T) {
tts := []struct {
filename string
d defaults.DefaultTimeEntry
}{
{
filename: "y_empty.yml",
d: defaults.DefaultTimeEntry{},
},
{
filename: "j_empty.json",
d: defaults.DefaultTimeEntry{},
},
{
filename: "j_complete.json",
d: defaults.DefaultTimeEntry{
Workspace: "w",
ProjectID: "p",
TaskID: "t",
Description: "d",
TagIDs: []string{"t1", "t2"},
},
},
{
filename: "y_complete.yaml",
d: defaults.DefaultTimeEntry{
Workspace: "w",
ProjectID: "p",
TaskID: "t",
Description: "d",
TagIDs: []string{"t1", "t2"},
},
},
}

dir := t.TempDir()
for i := range tts {
tt := &tts[i]
t.Run(tt.filename, func(t *testing.T) {
timeout(t, 6*time.Second, func() {
err := defaults.WriteDefaults(dir, tt.filename, tt.d)
if !assert.NoError(t, err) {
return
}

f := strings.Split(tt.filename, ".")[0]

r, err := defaults.ScanForDefaults(defaults.ScanParam{
Dir: dir,
Filename: f,
})()

assert.NoError(t, err)
assert.Equal(t, tt.d, r)
})
})
}
}

func TestWriteDefaults_ShouldFail_WhenPermAreMissing(t *testing.T) {
dir := t.TempDir()
_ = os.Chmod(dir, 0444)
timeout(t, 5*time.Second, func() {
err := defaults.WriteDefaults(dir, "fail", defaults.DefaultTimeEntry{})
assert.Error(t, err)
})
}

func timeout(t *testing.T, d time.Duration, f func()) {
done := make(chan bool)
defer close(done)

go func() {
f()
done <- true
}()

select {
case <-done:
case <-time.After(d):
t.Error("timeout " + d.String())
}
}

func TestScanForDefaults_ShouldFail(t *testing.T) {
wd, _ := os.Getwd()

dir := t.TempDir()
f, _ := os.OpenFile(
filepath.Join(dir, "not-open.yaml"), os.O_CREATE, os.ModePerm)
_ = f.Chmod(0000)
_ = f.Close()

tts := []struct {
dir string
filename string
err interface{}
}{
{
dir: wd,
filename: "not-found",
err: defaults.DefaultsFileNotFoundErr,
},
{
dir: filepath.Join(wd, "test_data", "test_cur"),
filename: "not-right",
err: "invalid character",
},
{
dir: dir,
filename: "not-open",
err: "permission denied",
},
{
dir: filepath.Join(wd, "test_data", "test_empty", "dir.yaml"),
filename: "dir",
err: defaults.DefaultsFileNotFoundErr,
},
}

for i := range tts {
tt := &tts[i]
t.Run(tt.filename, func(t *testing.T) {
timeout(t, 5*time.Second, func() {
d, err := defaults.ScanForDefaults(defaults.ScanParam{
Dir: tt.dir,
Filename: tt.filename,
})()

assert.Equal(t, d, defaults.DefaultTimeEntry{})
assert.Error(t, err)
switch v := tt.err.(type) {
case error:
assert.ErrorIs(t, err, v)
case string:
assert.Regexp(t, v, err)
}
})
})
}
}

func TestScanForDefaults_ShouldLookUpperDirs(t *testing.T) {
wd, _ := os.Getwd()
tts := []struct {
name string
param defaults.ScanParam
expected defaults.DefaultTimeEntry
}{
{
name: "test_cur",
param: defaults.ScanParam{
Dir: "./test_data/test_cur",
},
expected: defaults.DefaultTimeEntry{
Workspace: "w",
ProjectID: "p",
TaskID: "t",
Description: "d",
TagIDs: []string{"t1", "t2"},
},
},
{
name: "test_cur, filename as defaults",
param: defaults.ScanParam{
Dir: "./test_data/test_cur",
Filename: "defaults",
},
expected: defaults.DefaultTimeEntry{
Workspace: "W",
ProjectID: "P",
TaskID: "T",
Description: "D",
},
},
{
name: "down again",
param: defaults.ScanParam{
Dir: "./test_data/test_cur/down/again",
},
expected: defaults.DefaultTimeEntry{
Workspace: "w",
ProjectID: "p",
TaskID: "t",
Description: "d",
TagIDs: []string{"t1", "t2"},
},
},
{
name: "down path, filename as defaults",
param: defaults.ScanParam{
Dir: "./test_data/test_cur/down/again",
Filename: "defaults",
},
expected: defaults.DefaultTimeEntry{
Workspace: "W",
ProjectID: "P",
TaskID: "T",
Description: "D",
},
},
{
name: "test_incompl",
param: defaults.ScanParam{
Dir: "./test_data/test_incompl",
},
expected: defaults.DefaultTimeEntry{
Workspace: "w",
ProjectID: "p",
},
},
{
name: "test_empty",
param: defaults.ScanParam{
Dir: "./test_data/test_empty/down/here",
},
expected: defaults.DefaultTimeEntry{},
},
}

for i := range tts {
tt := &tts[i]
t.Run(tt.name, func(t *testing.T) {
timeout(t, 1*time.Second, func() {
tt.param.Dir = path.Join(wd, tt.param.Dir)
d, _ := defaults.ScanForDefaults(tt.param)()
assert.Equal(t, tt.expected, d)
})
})
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
workspace: "w"
project: "p"
task: "t"
description: "d"
tags:
- "t1"
- "t2"
Loading

0 comments on commit 5cf64ef

Please sign in to comment.