diff --git a/core/record.go b/core/record.go index e4da1a0..65dbc8d 100644 --- a/core/record.go +++ b/core/record.go @@ -1,9 +1,7 @@ package core import ( - "encoding/json" "errors" - "io/ioutil" "os" "os/exec" "path/filepath" @@ -42,12 +40,13 @@ func (r *Record) Duration() time.Duration { // LoadRecord loads the record with the given start time. Returns // ErrRecordNotFound if the record cannot be found. func (t *Timetrace) LoadRecord(start time.Time) (*Record, error) { - path := t.fs.RecordFilepath(start) + path := t.recordFS.FilepathByTime(start) return t.loadRecord(path) } func (t *Timetrace) LoadBackupRecord(start time.Time) (*Record, error) { - path := t.fs.RecordBackupFilepath(start) + // path := t.fs.RecordBackupFilepath(start) + path := t.recordFS.BackupByTime(start) return t.loadRecord(path) } @@ -60,54 +59,29 @@ func (t *Timetrace) ListRecords(date time.Time) ([]*Record, error) { // SaveRecord persists the given record. Returns ErrRecordAlreadyExists if the // record already exists and saving isn't forced. func (t *Timetrace) SaveRecord(record Record, force bool) error { - path := t.fs.RecordFilepath(record.Start) + path := t.recordFS.FilepathByTime(record.Start) - if _, err := os.Stat(path); err == nil && !force { + if t.recordFS.Exists(path) { return ErrRecordAlreadyExists } - if err := t.fs.EnsureRecordDir(record.Start); err != nil { + if err := t.recordFS.EnsureDir(record.Start); err != nil { return err } - - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - - bytes, err := json.MarshalIndent(&record, "", "\t") - if err != nil { - return err - } - - _, err = file.Write(bytes) - - return err + return t.recordFS.Save(path, &record) } // BackupRecord creates a backup of the given record file func (t *Timetrace) BackupRecord(recordKey time.Time) error { - path := t.fs.RecordFilepath(recordKey) + path := t.recordFS.FilepathByTime(recordKey) record, err := t.loadRecord(path) if err != nil { return err } // create a new .bak filepath from the record struct - backupPath := t.fs.RecordBackupFilepath(recordKey) + backupPath := t.recordFS.BackupByTime(recordKey) - backupFile, err := os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - - bytes, err := json.MarshalIndent(&record, "", "\t") - if err != nil { - return err - } - - _, err = backupFile.Write(bytes) - - return err + return t.recordFS.Save(backupPath, &record) } func (t *Timetrace) RevertRecord(recordKey time.Time) error { @@ -116,21 +90,9 @@ func (t *Timetrace) RevertRecord(recordKey time.Time) error { return err } - path := t.fs.RecordFilepath(recordKey) + path := t.recordFS.FilepathByTime(recordKey) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - - bytes, err := json.MarshalIndent(&record, "", "\t") - if err != nil { - return err - } - - _, err = file.Write(bytes) - - return err + return t.recordFS.Save(path, &record) } // RevertRecordsByProject is a function called if user opts to also revert records when they revert a project. @@ -154,7 +116,7 @@ func (t *Timetrace) RevertRecordsByProject(key string) error { keys = append(keys, key) // get all record dirs and filepaths in order to load the record for matching the parent key - allRecordDirs, err := t.fs.RecordDirs() + allRecordDirs, err := t.recordFS.Dirs() if err != nil { return err } @@ -184,13 +146,12 @@ func (t *Timetrace) RevertRecordsByProject(key string) error { // DeleteRecord removes the given record. Returns ErrRecordNotFound if the // project doesn't exist. func (t *Timetrace) DeleteRecord(record Record) error { - path := t.fs.RecordFilepath(record.Start) + path := t.recordFS.FilepathByTime(record.Start) - if _, err := os.Stat(path); os.IsNotExist(err) { + if _, err := t.fs.Stat(path); os.IsNotExist(err) { return ErrRecordNotFound } - - return os.Remove(path) + return t.recordFS.Delete(path) } func (t *Timetrace) DeleteRecordsByProject(key string) error { @@ -215,7 +176,7 @@ func (t *Timetrace) DeleteRecordsByProject(key string) error { keys = append(keys, key) // get all record dirs and filepaths in order to load the record for matching the parent key - allRecordDirs, err := t.fs.RecordDirs() + allRecordDirs, err := t.recordFS.Dirs() if err != nil { return err } @@ -246,7 +207,7 @@ func (t *Timetrace) DeleteRecordsByProject(key string) error { // EditRecordManual opens the record file in the preferred or default editor. func (t *Timetrace) EditRecordManual(recordTime time.Time) error { - path := t.fs.RecordFilepath(recordTime) + path := t.recordFS.FilepathByTime(recordTime) if _, err := t.loadRecord(path); err != nil { return err @@ -263,7 +224,7 @@ func (t *Timetrace) EditRecordManual(recordTime time.Time) error { // EditRecord loads the record internally, applies the option values and saves the record func (t *Timetrace) EditRecord(recordTime time.Time, plus string, minus string) error { - path := t.fs.RecordFilepath(recordTime) + path := t.recordFS.FilepathByTime(recordTime) record, err := t.loadRecord(path) if err != nil { @@ -286,7 +247,7 @@ func (t *Timetrace) EditRecord(recordTime time.Time, plus string, minus string) func (t *Timetrace) loadAllRecords(date time.Time) ([]*Record, error) { dir := t.fs.RecordDirFromDate(date) - recordFilepaths, err := t.fs.RecordFilepaths(dir, func(_, _ string) bool { + recordFilepaths, err := t.recordFS.Filepaths(dir, func(_, _ string) bool { return true }) if err != nil { @@ -307,9 +268,9 @@ func (t *Timetrace) loadAllRecords(date time.Time) ([]*Record, error) { } func (t *Timetrace) loadAllRecordsSortedAscending(date time.Time) ([]*Record, error) { - dir := t.fs.RecordDirFromDate(date) + dir := t.recordFS.DirByDate(date) - recordFilepaths, err := t.fs.RecordFilepaths(dir, func(a, b string) bool { + recordFilepaths, err := t.recordFS.Filepaths(dir, func(a, b string) bool { timeA, _ := time.Parse(recordLayout, a) timeB, _ := time.Parse(recordLayout, b) return timeA.Before(timeB) @@ -334,7 +295,7 @@ func (t *Timetrace) loadAllRecordsSortedAscending(date time.Time) ([]*Record, er // LoadLatestRecord loads the youngest record. This may also be a record from // another day. If there is no latest record, nil and no error will be returned. func (t *Timetrace) LoadLatestRecord() (*Record, error) { - latestDirs, err := t.fs.RecordDirs() + latestDirs, err := t.recordFS.Dirs() if err != nil { return nil, err } @@ -348,7 +309,7 @@ func (t *Timetrace) LoadLatestRecord() (*Record, error) { return nil, err } - latestRecords, err := t.fs.RecordFilepaths(dir, func(a, b string) bool { + latestRecords, err := t.recordFS.Filepaths(dir, func(a, b string) bool { timeA, _ := time.Parse(recordLayout, a) timeB, _ := time.Parse(recordLayout, b) return timeA.Before(timeB) @@ -369,9 +330,9 @@ func (t *Timetrace) LoadLatestRecord() (*Record, error) { // loadOldestRecord returns the oldest record of the given date. If there is no // oldest record, nil and no error will be returned. func (t *Timetrace) loadOldestRecord(date time.Time) (*Record, error) { - dir := t.fs.RecordDirFromDate(date) + dir := t.recordFS.DirByDate(date) - oldestRecords, err := t.fs.RecordFilepaths(dir, func(a, b string) bool { + oldestRecords, err := t.recordFS.Filepaths(dir, func(a, b string) bool { timeA, _ := time.Parse(recordLayout, a) timeB, _ := time.Parse(recordLayout, b) return timeA.After(timeB) @@ -393,7 +354,7 @@ func (t *Timetrace) loadOldestRecord(date time.Time) (*Record, error) { // through the filter options. // !imporant: .bak files will be ignored by this function - only .json files in the directory will be read! func (t *Timetrace) loadFromRecordDir(recordDir string, filter ...func(*Record) bool) ([]*Record, error) { - filesInfo, err := ioutil.ReadDir(recordDir) + filesInfo, err := t.recordFS.DirInfo(recordDir) if err != nil { return nil, err } @@ -425,7 +386,7 @@ outer: // loadBackupsFromRecordDir loads all records for one directory and returns them. The slice can be filtered // through the filter options. func (t *Timetrace) loadBackupsFromRecordDir(recordDir string, filter ...func(*Record) bool) ([]*Record, error) { - filesInfo, err := ioutil.ReadDir(recordDir) + filesInfo, err := t.recordFS.DirInfo(recordDir) if err != nil { return nil, err } @@ -455,9 +416,10 @@ outer: return foundRecords, nil } +// loadRecord loads a record based of its file path func (t *Timetrace) loadRecord(path string) (*Record, error) { - file, err := ioutil.ReadFile(path) - if err != nil { + var record Record + if err := t.recordFS.Load(path, &record); err != nil { if os.IsNotExist(err) { if strings.HasSuffix(path, ".bak") { return nil, ErrBackupRecordNotFound @@ -466,13 +428,6 @@ func (t *Timetrace) loadRecord(path string) (*Record, error) { } return nil, err } - - var record Record - - if err := json.Unmarshal(file, &record); err != nil { - return nil, err - } - return &record, nil } diff --git a/core/timetrace.go b/core/timetrace.go index e41e4fb..cef6239 100644 --- a/core/timetrace.go +++ b/core/timetrace.go @@ -3,6 +3,7 @@ package core import ( "errors" "io" + "io/fs" "os" "path/filepath" "time" @@ -29,24 +30,64 @@ type Report struct { // Filesystem represents a filesystem used for storing and loading resources. type Filesystem interface { - ProjectFilepath(key string) string - ProjectBackupFilepath(key string) string - ProjectFilepaths() ([]string, error) - ProjectBackupFilepaths() ([]string, error) - RecordFilepath(start time.Time) string - RecordBackupFilepath(start time.Time) string - RecordFilepaths(dir string, less func(a, b string) bool) ([]string, error) - RecordDirs() ([]string, error) - ReportDir() string - RecordDirFromDate(date time.Time) string - EnsureDirectories() error - EnsureRecordDir(date time.Time) error - WriteReport(path string, data []byte) error + ProjectFilepath(key string) string // implemented: + ProjectBackupFilepath(key string) string // implemented: + ProjectFilepaths() ([]string, error) // implemented: + ProjectBackupFilepaths() ([]string, error) // implemented: + RecordFilepath(start time.Time) string // implemented: + RecordBackupFilepath(start time.Time) string // implemented: + RecordFilepaths(dir string, less func(a, b string) bool) ([]string, error) // implemented: + RecordDirs() ([]string, error) // implemented: + ReportDir() string // implemented: + RecordDirFromDate(date time.Time) string // implemented: + WriteReport(path string, data []byte) error // implemented: + EnsureRecordDir(date time.Time) error // implemented: + + EnsureDirectories() error // in which interface does this func fit?? + Stat(path string) (*os.FileInfo, error) // in which interface does this func fit?? +} + +type ProjectFS interface { + // CRUD operations to interface with stored projects + Load(key string, v interface{}) error + Save(key string, v interface{}) error + Backup(key string, b []byte) error + Delete(key string) error + + Filepath(key string) string + BackupFilepath(key string) string + Filepaths() ([]string, error) + BackupFilepaths() ([]string, error) +} + +type RecordFS interface { + // CRUD operations to interface with stored records + Load(key string, v interface{}) error + Save(key string, v interface{}) error + Backup(key string, b []byte) error + Delete(path string) error + Exists(path string) bool + + BackupByTime(start time.Time) string + Filepaths(dir string, less func(a, b string) bool) ([]string, error) + FilepathByTime(start time.Time) string + Dirs() ([]string, error) + DirInfo(path string) ([]fs.FileInfo, error) + DirByDate(date time.Time) string + EnsureDir(date time.Time) error +} + +type ReportFS interface { + Write(path string, data []byte) error + Dir() string } type Timetrace struct { config *config.Config fs Filesystem + recordFS RecordFS + projectFS ProjectFS + reportFS ReportFS formatter *Formatter } diff --git a/fs/fs.go b/fs/fs.go index 5fb4725..381418b 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -12,6 +12,7 @@ import ( "time" "github.com/dominikbraun/timetrace/config" + "github.com/spf13/afero" ) const ( @@ -28,12 +29,14 @@ const ( ) type Fs struct { + Wrapper afero.Fs config *config.Config sanitizer *strings.Replacer } func New(config *config.Config) *Fs { return &Fs{ + Wrapper: afero.NewMemMapFs(), // change later config: config, sanitizer: strings.NewReplacer("/", "-", "\\", "-"), } @@ -58,7 +61,7 @@ func (fs *Fs) ProjectBackupFilepath(key string) string { func (fs *Fs) ProjectFilepaths() ([]string, error) { dir := fs.projectsDir() - items, err := ioutil.ReadDir(dir) + items, err := afero.ReadDir(fs.Wrapper, dir) if err != nil { return nil, err } @@ -212,7 +215,7 @@ func (fs *Fs) EnsureDirectories() error { } for _, dir := range dirs { - if err := os.MkdirAll(dir, 0777); err != nil { + if err := fs.Wrapper.MkdirAll(dir, 0777); err != nil { return err } } @@ -272,3 +275,7 @@ func (fs *Fs) WriteReport(filepath string, data []byte) error { } return nil } + +func (fs *Fs) Stat(path string) (*os.FileInfo, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/fs/fs_test.go b/fs/fs_test.go new file mode 100644 index 0000000..4a2182d --- /dev/null +++ b/fs/fs_test.go @@ -0,0 +1,60 @@ +package fs + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/dominikbraun/timetrace/config" +) + +func TestProjectFilepaths(t *testing.T) { + + tt := []struct { + name string + mock *Fs + list []string + want []string + err error + }{ + { + name: "sorted project lists", + mock: createMockFS(t), + list: []string{"project_1", "prject_2"}, + want: []string{"project_1", "prject_2"}, + err: nil, + }, + } + + for _, tc := range tt { + insertProjects(t, tc.mock, tc.list...) + + projects, err := tc.mock.ProjectFilepaths() + if err != tc.err { + t.Fatalf("[%s] want-err: %v, got-err: %v", tc.name, tc.err, err) + } + if !reflect.DeepEqual(tc.want, projects) { + t.Fatalf("[%s] want-projects: %v, got-projects: %v", tc.name, tc.want, projects) + } + } +} + +func createMockFS(t *testing.T) *Fs { + c, err := config.FromFile() + if err != nil { + t.Fatalf("[createMockFS] could not create config: %v", err) + } + mock := New(c) + if err := mock.EnsureDirectories(); err != nil { + t.Fatalf("[createMockFS] could not ensure dirs: %v", err) + } + return mock +} + +func insertProjects(t *testing.T, fs *Fs, projects ...string) { + for _, project := range projects { + if err := fs.Wrapper.MkdirAll(filepath.Join(fs.projectsDir(), project), 0777); err != nil { + t.Fatalf("[insertProject] could not insert project: %v", err) + } + } +} diff --git a/go.mod b/go.mod index cd1861d..3d13234 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/enescakir/emoji v1.0.0 github.com/fatih/color v1.12.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.8.1 ) diff --git a/interfaces.go b/interfaces.go new file mode 100644 index 0000000..8d56a1a --- /dev/null +++ b/interfaces.go @@ -0,0 +1,27 @@ +package main + +type ProjectFS interface { + Load(key string) (*Project, error) + Save(key string, b []byte) error + Backup(key string, b []byte) error +} + +type RecordFS interface { + Load(path string) (*Record, error) + Save(path, b []byte) error + Backup(key string, b []byte) error + Delete(path string) error +} + +type Loader interface { + Load(key string, v interface{}) error +} + +type Saver interface { + Save(key string, v interface{}) error +} + +type Backuper interface { + Backup(key string, v interface{}) error + Revert(key string, v interface{}) error +}