diff --git a/.vscode/settings.json b/.vscode/settings.json index d58617b..1be9af1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,6 +39,7 @@ "Fastward", "fieldalignment", "figo", + "foobarbaz", "fortytw", "fsys", "Fugazi", diff --git a/base-op.go b/base-op.go index 05a7229..77fbf91 100644 --- a/base-op.go +++ b/base-op.go @@ -2,6 +2,7 @@ package nef type baseOp[F ExistsInFS] struct { fS F + calc PathCalc root string } diff --git a/ensure-path-at.go b/ensure-path-at.go deleted file mode 100644 index bb5328d..0000000 --- a/ensure-path-at.go +++ /dev/null @@ -1,48 +0,0 @@ -package nef - -import ( - "os" - "path/filepath" - "strings" -) - -// EnsurePathAt ensures that the specified path exists (including any non -// existing intermediate directories). Given a path and a default filename, -// the specified path is created in the following manner: -// - If the path denotes a file (path does not end is a directory separator), then -// the parent folder is created if it doesn't exist on the file-system provided. -// - If the path denotes a directory, then that directory is created. -// -// The returned string represents the file, so if the path specified was a -// directory path, then the defaultFilename provided is joined to the path -// and returned, otherwise the original path is returned un-modified. -// Note: filepath.Join does not preserve a trailing separator, therefore to make sure -// a path is interpreted as a directory and not a file, then the separator has -// to be appended manually onto the end of the path. -// If vfs is not provided, then the path is ensured directly on the native file -// system. - -func EnsurePathAt(path, defaultFilename string, perm os.FileMode, - fS ...MakeDirFS, -) (at string, err error) { - var ( - directory, file string - ) - - if strings.HasSuffix(path, string(os.PathSeparator)) { - directory = path - file = defaultFilename - } else { - directory, file = filepath.Split(path) - } - - if len(fS) > 0 { - if !fS[0].DirectoryExists(directory) { - err = fS[0].MakeDirAll(directory, perm) - } - } else { - err = os.MkdirAll(directory, perm) - } - - return filepath.Clean(filepath.Join(directory, file)), err -} diff --git a/ensure-path-at_test.go b/ensure-path-at_test.go deleted file mode 100644 index eee89af..0000000 --- a/ensure-path-at_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package nef_test - -import ( - "errors" - "fmt" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" //nolint:revive // ok - . "github.com/onsi/gomega" //nolint:revive // ok - - nef "github.com/snivilised/nefilim" - lab "github.com/snivilised/nefilim/internal/laboratory" - "github.com/snivilised/nefilim/test/luna" -) - -const ( - home = "home/prodigy" -) - -var _ = Describe("EnsurePathAt", Ordered, func() { - var ( - mocks *nef.ResolveMocks - fS nef.UniversalFS - root string - ) - - BeforeAll(func() { - root = luna.Repo("test") - }) - - BeforeEach(func() { - scratch(root) - - mocks = &nef.ResolveMocks{ - HomeFunc: func() (string, error) { - return filepath.Join(root, "scratch", home), nil - }, - AbsFunc: func(_ string) (string, error) { - return "", errors.New("not required for these tests") - }, - } - - fS = nef.NewUniversalABS() - }) - - DescribeTable("with absolute fs", - func(entry *ensureTE) { - home, _ := mocks.HomeFunc() - location := filepath.Join(home, entry.relative) - - if entry.directory { - location += string(filepath.Separator) - } - - actual, err := nef.EnsurePathAt(location, "default-test.log", lab.Perms.Dir, fS) - directory, _ := filepath.Split(actual) - directory = filepath.Clean(directory) - expected := luna.Combine(home, entry.expected) - - Expect(err).Error().To(BeNil()) - Expect(actual).To(Equal(expected)) - Expect(luna.AsDirectory(directory)).To(luna.ExistInFS(fS)) - }, - func(entry *ensureTE) string { - return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", entry.given, entry.should) - }, - - Entry(nil, &ensureTE{ - given: "path is file", - should: "create parent directory and return specified file path", - relative: filepath.Join("logs", "test.log"), - expected: "logs/test.log", - }), - - Entry(nil, &ensureTE{ - given: "path is directory", - should: "create parent directory and return default file path", - relative: "logs/", - directory: true, - expected: "logs/default-test.log", - }), - ) -}) diff --git a/fs-absolute.go b/fs-absolute.go index 7782031..60e3fe1 100644 --- a/fs-absolute.go +++ b/fs-absolute.go @@ -3,28 +3,43 @@ package nef import ( "io/fs" "os" + "strings" ) -type absoluteFS struct{} +type absoluteFS struct { + calc PathCalc +} // NewUniversalABS creates an absolute universal file system func NewUniversalABS() UniversalFS { - return &absoluteFS{} + return &absoluteFS{ + calc: &AbsoluteCalc{}, + } } // NewTraverseABS creates an absolute traverse file system func NewTraverseABS() TraverseFS { - return &absoluteFS{} + return &absoluteFS{ + calc: &AbsoluteCalc{}, + } } // NewReaderABS creates an absolute reader file system func NewReaderABS() ReaderFS { - return &absoluteFS{} + return &absoluteFS{ + calc: &AbsoluteCalc{}, + } } // NewWriterABS creates an absolute writer file system func NewWriterABS() WriterFS { - return &absoluteFS{} + return &absoluteFS{ + calc: &AbsoluteCalc{}, + } +} + +func (f *absoluteFS) Calc() PathCalc { + return f.calc } // FileExists does file exist at the path specified @@ -103,8 +118,24 @@ func (f *absoluteFS) MakeDirAll(name string, perm os.FileMode) error { } // Ensure is not currently implemented on absoluteFS -func (f *absoluteFS) Ensure(_ PathAs) (string, error) { - panic("NOT-IMPL: absoluteFS.Ensure") +func (f *absoluteFS) Ensure(as PathAs) (at string, err error) { + var ( + directory, file string + ) + calc := f.calc + + if strings.HasSuffix(as.Name, string(os.PathSeparator)) { + directory = as.Name + file = as.Default + } else { + directory, file = calc.Split(as.Name) + } + + if !f.DirectoryExists(directory) { + err = f.MakeDirAll(directory, as.Perm) + } + + return calc.Clean(calc.Join(directory, file)), err } // Move is not currently implemented on absoluteFS diff --git a/fs-change_test.go b/fs-change_test.go index 5be4ce6..e60af6c 100644 --- a/fs-change_test.go +++ b/fs-change_test.go @@ -2,7 +2,6 @@ package nef_test import ( "fmt" - "path/filepath" . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok @@ -65,7 +64,7 @@ var _ = Describe("op: change", Ordered, func() { Expect(require(root, entry.from)).To(Succeed()) }, action: func(entry fsTE[nef.UniversalFS], fS nef.UniversalFS) { - destination := filepath.Base(entry.to) + destination := fS.Calc().Base(entry.to) Expect(fS.Change(entry.from, destination)).To(Succeed(), fmt.Sprintf("OVERWRITE: %v", entry.overwrite), ) @@ -88,7 +87,8 @@ var _ = Describe("op: change", Ordered, func() { Expect(fS.Change(entry.from, entry.to)).To(Succeed(), fmt.Sprintf("OVERWRITE: %v", entry.overwrite), ) - Expect(luna.AsFile(luna.Yoke(lab.Static.FS.Scratch, entry.to))).To(luna.ExistInFS(fS)) + calc := fS.Calc() + Expect(luna.AsFile(calc.Join(lab.Static.FS.Scratch, entry.to))).To(luna.ExistInFS(fS)) Expect(luna.AsDirectory(entry.to)).NotTo(luna.ExistInFS(fS)) }, }), @@ -107,7 +107,8 @@ var _ = Describe("op: change", Ordered, func() { Expect(fS.Change(entry.from, entry.to)).To(Succeed(), fmt.Sprintf("OVERWRITE: %v", entry.overwrite), ) - file := luna.Yoke(lab.Static.FS.Scratch, entry.to) + calc := fS.Calc() + file := calc.Join(lab.Static.FS.Scratch, entry.to) Expect(luna.AsFile(file)).To(luna.ExistInFS(fS)) Expect(luna.AsDirectory(entry.to)).NotTo(luna.ExistInFS(fS)) }, @@ -125,7 +126,7 @@ var _ = Describe("op: change", Ordered, func() { Expect(require(root, entry.to)).To(Succeed()) }, action: func(entry fsTE[nef.UniversalFS], fS nef.UniversalFS) { - destination := filepath.Base(entry.to) + destination := fS.Calc().Base(entry.to) Expect(fS.Change(entry.from, destination)).NotTo(Succeed(), fmt.Sprintf("OVERWRITE: %v", entry.overwrite), ) @@ -141,14 +142,14 @@ var _ = Describe("op: change", Ordered, func() { require: lab.Static.FS.Scratch, from: lab.Static.FS.Change.From.File, to: lab.Static.FS.Change.To.File, - arrange: func(entry fsTE[nef.UniversalFS], _ nef.UniversalFS) { + arrange: func(entry fsTE[nef.UniversalFS], fS nef.UniversalFS) { Expect(require(root, entry.require, entry.from, )).To(Succeed()) Expect(require(root, entry.require, - luna.Yoke(lab.Static.FS.Scratch, entry.to), + fS.Calc().Join(lab.Static.FS.Scratch, entry.to), )).To(Succeed()) }, action: func(entry fsTE[nef.UniversalFS], fS nef.UniversalFS) { diff --git a/fs-changer.go b/fs-changer.go index 89c8225..ea789bf 100644 --- a/fs-changer.go +++ b/fs-changer.go @@ -2,7 +2,6 @@ package nef import ( "os" - "path/filepath" "strings" "sync" @@ -81,8 +80,8 @@ func (m *baseChanger) rename(from, to string) error { } return os.Rename( - filepath.Join(m.root, from), - filepath.Join(m.root, destination), + m.calc.Join(m.root, from), + m.calc.Join(m.root, destination), ) } @@ -102,12 +101,15 @@ func (l *lazyChanger) instance(root string, overwrite bool, fS ChangerFS) change func (l *lazyChanger) create(root string, overwrite bool, fS ChangerFS) changer { // create an interface for this function // + calc := fS.Calc() + return lo.TernaryF(overwrite, func() changer { return &overwriteChanger{ baseChanger: baseChanger{ baseOp: baseOp[ChangerFS]{ fS: fS, + calc: calc, root: root, }, }, @@ -118,6 +120,7 @@ func (l *lazyChanger) create(root string, overwrite bool, fS ChangerFS) changer baseChanger: baseChanger{ baseOp: baseOp[ChangerFS]{ fS: fS, + calc: calc, root: root, }, }, diff --git a/fs-ensure-at_test.go b/fs-ensure_test.go similarity index 62% rename from fs-ensure-at_test.go rename to fs-ensure_test.go index 8a58ec6..dcbfa7c 100644 --- a/fs-ensure-at_test.go +++ b/fs-ensure_test.go @@ -1,6 +1,7 @@ package nef_test import ( + "errors" "fmt" "path/filepath" @@ -62,7 +63,7 @@ var _ = Describe("Ensure", Ordered, func() { }, ) Expect(err).To(Succeed()) - _, file := filepath.Split(lab.Static.FS.Ensure.Log.File) + _, file := fS.Calc().Split(lab.Static.FS.Ensure.Log.File) Expect(result).To(Equal(file)) }, }), @@ -77,7 +78,7 @@ var _ = Describe("Ensure", Ordered, func() { Expect(require(root, entry.target)).To(Succeed()) }, action: func(entry fsTE[nef.MakeDirFS], fS nef.MakeDirFS) { - _, file := filepath.Split(lab.Static.FS.Ensure.Default.File) + _, file := fS.Calc().Split(lab.Static.FS.Ensure.Default.File) result, err := fS.Ensure( nef.PathAs{ Name: entry.target, @@ -97,12 +98,12 @@ var _ = Describe("Ensure", Ordered, func() { require: lab.Static.FS.Scratch, target: lab.Static.FS.Ensure.Log.File, from: lab.Static.FS.Ensure.Home, - arrange: func(entry fsTE[nef.MakeDirFS], _ nef.MakeDirFS) { - parent := luna.Yoke(entry.require, entry.from) + arrange: func(entry fsTE[nef.MakeDirFS], fS nef.MakeDirFS) { + parent := fS.Calc().Join(entry.require, entry.from) Expect(require(root, parent)).To(Succeed()) }, action: func(entry fsTE[nef.MakeDirFS], fS nef.MakeDirFS) { - _, file := filepath.Split(lab.Static.FS.Ensure.Default.File) + _, file := fS.Calc().Split(lab.Static.FS.Ensure.Default.File) result, err := fS.Ensure( nef.PathAs{ Name: entry.target, @@ -114,7 +115,7 @@ var _ = Describe("Ensure", Ordered, func() { Expect(err).To(Succeed()) ensureAt := lab.Static.FS.Ensure.Default.Directory Expect(luna.AsDirectory(ensureAt)).To(luna.ExistInFS(fS)) - _, file = filepath.Split(entry.target) + _, file = fS.Calc().Split(entry.target) Expect(result).To(Equal(file)) }, }), @@ -127,11 +128,11 @@ var _ = Describe("Ensure", Ordered, func() { target: lab.Static.FS.Ensure.Log.Directory, from: lab.Static.FS.Ensure.Home, arrange: func(entry fsTE[nef.MakeDirFS], _ nef.MakeDirFS) { - parent := luna.Yoke(entry.require, entry.from) + parent := fS.Calc().Join(entry.require, entry.from) Expect(require(root, parent)).To(Succeed()) }, action: func(entry fsTE[nef.MakeDirFS], fS nef.MakeDirFS) { - _, file := filepath.Split(lab.Static.FS.Ensure.Default.File) + _, file := fS.Calc().Split(lab.Static.FS.Ensure.Default.File) result, err := fS.Ensure( nef.PathAs{ Name: entry.target, @@ -147,3 +148,79 @@ var _ = Describe("Ensure", Ordered, func() { }), ) }) + +var _ = Describe("Ensure", Ordered, func() { + const ( + home = "home/prodigy" + ) + + var ( + mocks *nef.ResolveMocks + fS nef.UniversalFS + calc nef.PathCalc + root string + ) + + BeforeAll(func() { + root = luna.Repo("test") + }) + + BeforeEach(func() { + fS = nef.NewUniversalABS() + calc = fS.Calc() + + scratch(root) + + mocks = &nef.ResolveMocks{ + HomeFunc: func() (string, error) { + return calc.Join(root, "scratch", home), nil + }, + AbsFunc: func(_ string) (string, error) { + return "", errors.New("not required for these tests") + }, + } + }) + + DescribeTable("with absolute fs", + func(entry *ensureTE) { + home, _ := mocks.HomeFunc() + location := calc.Join(home, entry.relative) + + if entry.directory { + location += string(filepath.Separator) + } + + actual, err := fS.Ensure(nef.PathAs{ + Name: location, + Default: "default-test.log", + Perm: lab.Perms.Dir, + }) + + directory, _ := calc.Split(actual) + directory = calc.Clean(directory) + expected := luna.Combine(home, entry.expected) + + Expect(err).Error().To(BeNil()) + Expect(actual).To(Equal(expected)) + Expect(luna.AsDirectory(directory)).To(luna.ExistInFS(fS)) + }, + func(entry *ensureTE) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", entry.given, entry.should) + }, + + Entry(nil, &ensureTE{ + given: "path is file", + should: "create parent directory and return specified file path", + relative: filepath.Join("logs", "test.log"), // (can't use calc here, not set yet) + expected: "logs/test.log", + }), + + Entry(nil, &ensureTE{ + given: "path is directory", + should: "create parent directory and return default file path", + relative: "logs/", + directory: true, + expected: "logs/default-test.log", + }), + ) +}) diff --git a/fs-move_test.go b/fs-move_test.go index f9776aa..7f3292a 100644 --- a/fs-move_test.go +++ b/fs-move_test.go @@ -2,7 +2,6 @@ package nef_test import ( "fmt" - "path/filepath" . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok @@ -278,7 +277,7 @@ var _ = Describe("op: move", Ordered, func() { Expect(require(root, entry.require, entry.from)).To(Succeed()) }, action: func(entry fsTE[nef.UniversalFS], fS nef.UniversalFS) { - destination := filepath.Join(entry.to, lab.Static.Foo) + destination := fS.Calc().Join(entry.to, lab.Static.Foo) Expect(fS.Move(entry.from, destination)).NotTo(Succeed(), fmt.Sprintf("OVERWRITE: %v", entry.overwrite), ) diff --git a/fs-mover-tentative.go b/fs-mover-tentative.go index ad5073b..8febcf8 100644 --- a/fs-mover-tentative.go +++ b/fs-mover-tentative.go @@ -1,9 +1,5 @@ package nef -import ( - "path/filepath" -) - type tentativeMover struct { baseMover } @@ -24,7 +20,7 @@ func (m *tentativeMover) moveDirectoryWithName(from, to string) error { // 'to' includes the file name eg: // from/file.txt => to/file.txt // - if filepath.Dir(from) == filepath.Dir(to) { + if m.calc.Dir(from) == m.calc.Dir(to) { return NewRejectSameDirMoveError(moveOpName, from, to) } @@ -35,7 +31,7 @@ func (m *tentativeMover) moveItemWithoutName(from, to string) error { // 'to' does not include the file name, so it has to be appended, eg: // from/file.txt => to/ // - if _, err := m.fS.Stat(filepath.Join(to, filepath.Base(from))); err == nil { + if _, err := m.fS.Stat(m.calc.Join(to, m.calc.Base(from))); err == nil { return NewInvalidBinaryFsOpError("Move", from, to) } @@ -47,7 +43,7 @@ func (m *tentativeMover) rejectOverwriteOrNoOp(from, to string) error { // they are not in the same location then we reject the overwrite attempt // otherwise they are the same item and this should effectively be a no op. // - if filepath.Dir(from) != filepath.Dir(to) { + if m.calc.Dir(from) != m.calc.Dir(to) { return NewInvalidBinaryFsOpError(moveOpName, from, to) } diff --git a/fs-mover.go b/fs-mover.go index 8a52a4a..35a1a07 100644 --- a/fs-mover.go +++ b/fs-mover.go @@ -2,7 +2,6 @@ package nef import ( "os" - "path/filepath" "sync" "github.com/snivilised/nefilim/internal/third/lo" @@ -32,6 +31,7 @@ type ( baseMover struct { root string fS MoverFS + calc PathCalc actions movers } ) @@ -81,13 +81,13 @@ func (m *baseMover) moveItemWithName(from, to string) error { // 'to' includes the file name eg: // from/file.txt => to/file.txt // - if filepath.Dir(from) == filepath.Dir(to) { + if m.calc.Dir(from) == m.calc.Dir(to) { return NewRejectSameDirMoveError(moveOpName, from, to) } return os.Rename( - filepath.Join(m.root, from), - filepath.Join(m.root, to), + m.calc.Join(m.root, from), + m.calc.Join(m.root, to), ) } @@ -96,14 +96,14 @@ func (m *baseMover) moveItemWithoutName(from, to string) error { // from/file.txt => to/ // return os.Rename( - filepath.Join(m.root, from), - filepath.Join(m.root, to, filepath.Base(from)), + m.calc.Join(m.root, from), + m.calc.Join(m.root, to, m.calc.Base(from)), ) } func (m *baseMover) moveItemWithoutNameClash(from, to string) error { - fromBase := filepath.Base(from) - toBase := filepath.Base(to) + fromBase := m.calc.Base(from) + toBase := m.calc.Base(to) if fromBase == toBase { // If there were a merge facility, this is where we would implement this, @@ -129,12 +129,14 @@ func (l *lazyMover) instance(root string, overwrite bool, fS MoverFS) mover { } func (l *lazyMover) create(root string, overwrite bool, fS MoverFS) mover { + calc := fS.Calc() return lo.TernaryF(overwrite, func() mover { return &overwriteMover{ baseMover: baseMover{ root: root, fS: fS, + calc: calc, }, } }, @@ -143,6 +145,7 @@ func (l *lazyMover) create(root string, overwrite bool, fS MoverFS) mover { baseMover: baseMover{ root: root, fS: fS, + calc: calc, }, } }, diff --git a/fs-relative.go b/fs-relative.go index 5b59a95..b9b23f9 100644 --- a/fs-relative.go +++ b/fs-relative.go @@ -4,7 +4,6 @@ import ( "fmt" "io/fs" "os" - "path/filepath" ) // 🔥 An important note about using standard golang file systems (io.fs/fs.FS) @@ -30,7 +29,8 @@ import ( // is the local file system. This means that paths used only need to use '/'. And // the silly thing is, characters like ':', or '\' for windows should not be // treated as separators by the underlying file system. So really using -// filepath.Separator with a virtual file system is not valid. +// filepath.Separator with a virtual file system is not valid. This is why +// there is a PathCalc. // func sanitise(root string) string { @@ -43,6 +43,7 @@ func sanitise(root string) string { type openFS struct { fS fs.FS root string + calc PathCalc } func (f *openFS) Open(name string) (fs.File, error) { @@ -56,13 +57,17 @@ type statFS struct { *openFS } +func (f *openFS) Calc() PathCalc { + return f.calc +} + func NewStatFS(rel Rel) fs.StatFS { ents := compose(sanitise(rel.Root)) return &ents.stat } func (f *statFS) Stat(name string) (fs.FileInfo, error) { - return os.Stat(filepath.Join(f.root, name)) + return os.Stat(f.calc.Join(f.root, name)) } // 🧩 ---> file system query @@ -130,6 +135,11 @@ func NewExistsInFS(rel Rel) ExistsInFS { return &ents.exists } +func (f *existsInFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + // FileExists does file exist at the path specified func (f *existsInFS) FileExists(name string) bool { info, err := f.Stat(name) @@ -219,6 +229,11 @@ func NewMakeDirFS(rel Rel) MakeDirFS { return &ents.writer } +func (f *makeDirAllFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + // Mkdir creates a new directory with the specified name and permission // bits (before umask). // If there is an error, it will be of type *PathError. @@ -231,7 +246,7 @@ func (f *makeDirAllFS) MakeDir(name string, perm os.FileMode) error { return nil } - path := filepath.Join(f.statFS.root, name) + path := f.statFS.calc.Join(f.statFS.root, name) return os.Mkdir(path, perm) } @@ -250,7 +265,7 @@ func (f *makeDirAllFS) MakeDirAll(name string, perm os.FileMode) error { if f.DirectoryExists(name) { return nil } - path := filepath.Join(f.statFS.root, name) + path := f.statFS.calc.Join(f.statFS.root, name) return os.MkdirAll(path, perm) } @@ -278,7 +293,7 @@ func (f *makeDirAllFS) Ensure(as PathAs, ) if f.FileExists(as.Name) { - _, file = filepath.Split(as.Name) + _, file = f.statFS.calc.Split(as.Name) return file, nil } @@ -307,7 +322,7 @@ func (f *removeFS) Remove(name string) error { return NewInvalidPathError("Remove", name) } - path := filepath.Join(f.root, filepath.Clean(name)) + path := f.calc.Join(f.root, f.calc.Clean(name)) return os.Remove(path) } @@ -316,7 +331,7 @@ func (f *removeFS) RemoveAll(path string) error { return NewInvalidPathError("RemoveAll", path) } - return os.RemoveAll(filepath.Join(f.root, filepath.Clean(path))) + return os.RemoveAll(f.calc.Join(f.root, f.calc.Clean(path))) } // 🎯 renameFS @@ -327,10 +342,10 @@ type renameFS struct { // Rename delegates to the Rename functionality implemented in the standard // library. -func (s *renameFS) Rename(from, to string) error { +func (f *renameFS) Rename(from, to string) error { return os.Rename( - filepath.Join(s.root, from), - filepath.Join(s.root, to), + f.calc.Join(f.root, from), + f.calc.Join(f.root, to), ) } @@ -368,7 +383,7 @@ func (f *writeFileFS) Create(name string) (fs.File, error) { return nil, os.ErrExist } - path := filepath.Join(f.root, name) + path := f.calc.Join(f.root, name) return os.Create(path) } @@ -382,7 +397,7 @@ func (f *writeFileFS) WriteFile(name string, data []byte, perm os.FileMode) erro return NewInvalidPathError("WriteFile", name) } - path := filepath.Join(f.root, name) + path := f.calc.Join(f.root, name) return os.WriteFile(path, data, perm) } @@ -396,6 +411,11 @@ type readerFS struct { *statFS } +func (f *readerFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + // NewReaderFS func NewReaderFS(rel Rel) ReaderFS { ents := compose(sanitise(rel.Root)) @@ -410,6 +430,11 @@ type aggregatorFS struct { changer lazyChanger } +func (f *aggregatorFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + // Move is similar to rename but it has distinctly different semantics, which // also varies depending on whether the file system was created with overwrite // enabled or not. @@ -450,12 +475,22 @@ func NewWriterFS(rel Rel) WriterFS { return &ents.writer } +func (f *writerFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + // 🎯 mutatorFS type mutatorFS struct { *readerFS *writerFS } +func (f *mutatorFS) Calc() PathCalc { + // disambiguator + return f.statFS.calc +} + func newMutatorFS(rel *Rel) *mutatorFS { ents := compose(sanitise(rel.Root)).mutate(rel.Overwrite) @@ -524,6 +559,9 @@ func compose(root string) *entities { open := openFS{ fS: os.DirFS(root), root: root, + calc: &RelativeCalc{ + Root: root, + }, } read := readDirFS{ openFS: &open, diff --git a/nefilim-defs.go b/nefilim-defs.go index 123550b..46c230e 100644 --- a/nefilim-defs.go +++ b/nefilim-defs.go @@ -28,8 +28,14 @@ type ( T TraverseFS } + // FSPathCalc the path calculator used by the FS + FSPathCalc interface { + Calc() PathCalc + } + // ExistsInFS contains methods that check the existence of file system items. ExistsInFS interface { + FSPathCalc // FileExists does file exist at the path specified FileExists(name string) bool diff --git a/nefilim-suite_test.go b/nefilim-suite_test.go index 59e218b..ee2524e 100644 --- a/nefilim-suite_test.go +++ b/nefilim-suite_test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" "testing" @@ -21,6 +20,21 @@ func TestNefilim(t *testing.T) { RunSpecs(t, "Nefilim Suite") } +type CalcType uint + +const ( + CalcTypeAbsolute CalcType = iota + CalcTypeRelative +) + +func (c CalcType) String() string { + if c == CalcTypeAbsolute { + return "ABSOLUTE" + } + + return "RELATIVE" +} + type ( ensureTE struct { given string @@ -37,6 +51,40 @@ type ( expect string } + manyResultAction func(calc nef.PathCalc) []string + + singleAsserter func(string) + manyAsserter func([]string) + pairAsserter func(string, string) + + calcTE struct { + given string + should string + } + + genericCalcTE[I, R string | []string] struct { + calcTE + input I + expect map[CalcType]R + } + + calcVariadicToOneTE struct { + calcTE + input []string + expect map[CalcType]string + } + + pair struct { + dir string + file string + } + + calcOneToPairTE struct { + calcTE + input string + expect map[CalcType]pair + } + funcFS[T any] func(entry fsTE[T], fS T) fsTE[T any] struct { @@ -134,19 +182,6 @@ func errorAbsResolver(_ string) (string, error) { return "", errors.New("failed to resolve abs") } -func TrimRoot(root string) string { - // omit leading '/', because test-fs stupidly doesn't like it, - // so we have to jump through hoops - if strings.HasPrefix(root, string(filepath.Separator)) { - return root[1:] - } - - pattern := `^[a-zA-Z]:[\\/]*` - re := regexp.MustCompile(pattern) - - return re.ReplaceAllString(root, "") -} - func Normalise(p string) string { return strings.ReplaceAll(p, "/", string(filepath.Separator)) } diff --git a/path-calc.go b/path-calc.go new file mode 100644 index 0000000..428ead6 --- /dev/null +++ b/path-calc.go @@ -0,0 +1,166 @@ +package nef + +import ( + "path/filepath" + "strings" +) + +// Path +type PathCalc interface { + Base(path string) string + Clean(path string) string + Dir(name string) string + Elements(path string) []string + Join(elements ...string) string + Split(path string) (dir, file string) + Truncate(path string) string +} + +type AbsoluteCalc struct { +} + +// Base returns the last element of the path +func (c *AbsoluteCalc) Base(path string) string { + return filepath.Base(path) +} + +// Clean returns the shortest path name equivalent to path +// by purely lexical processing. +func (c *AbsoluteCalc) Clean(path string) string { + return filepath.Clean(path) +} + +// Dir returns all but the last element of the path +func (c *AbsoluteCalc) Dir(name string) string { + return filepath.Dir(name) +} + +// Join joins any number of path elements into a single path +func (c *AbsoluteCalc) Join(elements ...string) string { + return filepath.Join(elements...) +} + +// Split splits the path immediately following the final separator +func (c *AbsoluteCalc) Split(path string) (dir, file string) { + return filepath.Split(path) +} + +func (c *AbsoluteCalc) Truncate(path string) string { + if path == "" { + return "." + } + + var ( + separator = string(filepath.Separator) + ) + + if !strings.HasSuffix(path, separator) { + return path + } + + return path[:strings.LastIndex(path, separator)] +} + +func (c *AbsoluteCalc) Elements(path string) []string { + if path == "" { + return []string{} + } + + return strings.Split(path, string(filepath.Separator)) +} + +const ( + separator = '/' +) + +var ( + separatorStr = string(separator) +) + +type RelativeCalc struct { + Root string +} + +func (c *RelativeCalc) IsPathSeparator(ch uint8) bool { + return separator == ch +} + +// Base returns the last element of the path +func (c *RelativeCalc) Base(path string) string { + if path == "" { + return "." + } + + if !strings.Contains(path, separatorStr) { + return path + } + + return path[strings.LastIndex(path, separatorStr)+1:] +} + +// Clean returns the shortest path name equivalent to path +// by purely lexical processing. +func (c *RelativeCalc) Clean(path string) string { + clean := filepath.Clean(path) + + if clean == separatorStr { + return "." + } + + if strings.HasPrefix(clean, separatorStr) { + clean = clean[1:] + } + + return clean +} + +func (c *RelativeCalc) Elements(path string) []string { + if path == "" { + return []string{} + } + + return strings.Split(path, separatorStr) +} + +// Dir returns all but the last element of the path +func (c *RelativeCalc) Dir(path string) string { + if path == "" { + return "." + } + + if !strings.Contains(path, separatorStr) { + return "." + } + + return path[:strings.LastIndex(path, separatorStr)] +} + +// Join joins any number of path elements into a single path +func (c *RelativeCalc) Join(elements ...string) string { + return strings.Join(elements, separatorStr) +} + +// Split splits the path immediately following the final separator +func (c *RelativeCalc) Split(path string) (dir, file string) { + if path == "" { + return "", "" + } + + if !strings.Contains(path, separatorStr) { + return "", path + } + + return c.Dir(path), c.Base(path) +} + +func (c *RelativeCalc) Truncate(path string) string { + if path == "" { + return "." + } + + if !strings.HasSuffix(path, separatorStr) { + return path + } + + return path[:strings.LastIndex(path, separatorStr)] +} diff --git a/path-calc_test.go b/path-calc_test.go new file mode 100644 index 0000000..3b1df2d --- /dev/null +++ b/path-calc_test.go @@ -0,0 +1,471 @@ +package nef_test + +import ( + "fmt" + "path/filepath" + "runtime" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + nef "github.com/snivilised/nefilim" +) + +type ( + PathCalcs map[CalcType]nef.PathCalc +) + +var ( + static = struct { + foo, + bar, + baz, + foobar, + foobarbaz, + root string + }{ + foo: "foo.txt", + bar: "bar.txt", + baz: "baz.txt", + foobar: "foo/bar", + foobarbaz: "foo/bar/baz.txt", + root: "/home/root", + } +) + +var _ = Describe("PathCalc", func() { + DescribeTable("Base", + func(entry *genericCalcTE[string, string]) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Base(entry.input)).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Base' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *genericCalcTE[string, string]) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is empty", + should: "return .", + }, + input: "", + expect: map[CalcType]string{ + CalcTypeAbsolute: ".", + CalcTypeRelative: ".", + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is single element", + should: "return original path", + }, + input: static.foo, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foo, + CalcTypeRelative: static.foo, + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: static.foobarbaz, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.baz, + CalcTypeRelative: static.baz, + }, + }), + ) + + if runtime.GOOS != "windows" { + DescribeTable("Clean", + func(entry *genericCalcTE[string, string]) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Clean(entry.input)).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Clean' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *genericCalcTE[string, string]) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is empty", + should: "return .", + }, + input: "", + expect: map[CalcType]string{ + CalcTypeAbsolute: ".", + CalcTypeRelative: ".", + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is single element", + should: "return original path", + }, + input: static.foo, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foo, + CalcTypeRelative: static.foo, + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: static.foobarbaz, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobarbaz, + CalcTypeRelative: static.foobarbaz, + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path contains consecutive separators", + should: "remove consecutive separators", + }, + input: "foo//bar///baz.txt", + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobarbaz, + CalcTypeRelative: static.foobarbaz, + }, + }), + + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path represents root", + should: "return appropriate root path", + }, + input: "/", + expect: map[CalcType]string{ + CalcTypeAbsolute: "/", + CalcTypeRelative: ".", + }, + }), + + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path ends with /", + should: "remove trailing /", + }, + input: "foo/bar/", + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobar, + CalcTypeRelative: static.foobar, + }, + }), + + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path starts with /", + should: "clean as appropriate", + }, + input: "/foo/bar", + expect: map[CalcType]string{ + CalcTypeAbsolute: "/foo/bar", + CalcTypeRelative: static.foobar, + }, + }), + ) + } + + DescribeTable("Dir", + func(entry *genericCalcTE[string, string]) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Dir(entry.input)).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Dir' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *genericCalcTE[string, string]) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is empty", + should: "return .", + }, + input: "", + expect: map[CalcType]string{ + CalcTypeAbsolute: ".", + CalcTypeRelative: ".", + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is single element", + should: "return .", + }, + input: static.foo, + expect: map[CalcType]string{ + CalcTypeAbsolute: ".", + CalcTypeRelative: ".", + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: static.foobarbaz, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobar, + CalcTypeRelative: static.foobar, + }, + }), + ) + + DescribeTable("Elements", + func(entry *genericCalcTE[string, []string]) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Elements(entry.input)).To(HaveExactElements(entry.expect[ct]), + fmt.Sprintf("💥 'Elements' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *genericCalcTE[string, []string]) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &genericCalcTE[string, []string]{ + calcTE: calcTE{ + given: "path is empty", + should: "return empty slice", + }, + input: "", + expect: map[CalcType][]string{ + CalcTypeAbsolute: {}, + CalcTypeRelative: {}, + }, + }), + Entry(nil, &genericCalcTE[string, []string]{ + calcTE: calcTE{ + given: "path is single element", + should: "single element slice", + }, + input: static.foo, + expect: map[CalcType][]string{ + CalcTypeAbsolute: {static.foo}, + CalcTypeRelative: {static.foo}, + }, + }), + Entry(nil, &genericCalcTE[string, []string]{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: static.foobarbaz, + expect: map[CalcType][]string{ + CalcTypeAbsolute: {"foo", "bar", "baz.txt"}, + CalcTypeRelative: {"foo", "bar", "baz.txt"}, + }, + }), + ) + + DescribeTable("Join", + func(entry *calcVariadicToOneTE) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Join(entry.input...)).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Join' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *calcVariadicToOneTE) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &calcVariadicToOneTE{ + calcTE: calcTE{ + given: "path is empty", + should: "return .", + }, + input: []string{}, + expect: map[CalcType]string{ + CalcTypeAbsolute: "", + CalcTypeRelative: "", + }, + }), + Entry(nil, &calcVariadicToOneTE{ + calcTE: calcTE{ + given: "path is single element", + should: "return original path", + }, + input: []string{static.foo}, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foo, + CalcTypeRelative: static.foo, + }, + }), + Entry(nil, &calcVariadicToOneTE{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: []string{"foo", "bar", "baz.txt"}, + expect: map[CalcType]string{ + CalcTypeAbsolute: filepath.Join("foo", "bar", "baz.txt"), + CalcTypeRelative: static.foobarbaz, + }, + }), + ) + + DescribeTable("Split", + func(entry *calcOneToPairTE) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + dir, file := calc.Split(entry.input) + Expect(pair{dir: dir, file: file}).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Split' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *calcOneToPairTE) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &calcOneToPairTE{ + calcTE: calcTE{ + given: "path is empty", + should: "return nothing", + }, + input: "", + expect: map[CalcType]pair{ + CalcTypeAbsolute: {dir: "", file: ""}, + CalcTypeRelative: {dir: "", file: ""}, + }, + }), + Entry(nil, &calcOneToPairTE{ + calcTE: calcTE{ + given: "path is single element", + should: "return .", + }, + input: static.foo, + expect: map[CalcType]pair{ + CalcTypeAbsolute: {dir: "", file: static.foo}, + CalcTypeRelative: {dir: "", file: static.foo}, + }, + }), + Entry(nil, &calcOneToPairTE{ + calcTE: calcTE{ + given: "path is multi element", + should: "return last element", + }, + input: static.foobarbaz, + expect: map[CalcType]pair{ + CalcTypeAbsolute: {dir: "foo/bar/", file: "baz.txt"}, + CalcTypeRelative: {dir: static.foobar, file: "baz.txt"}, + }, + }), + ) + + DescribeTable("Truncate", + func(entry *genericCalcTE[string, string]) { + calcs := PathCalcs{ + CalcTypeAbsolute: &nef.AbsoluteCalc{}, + CalcTypeRelative: &nef.RelativeCalc{ + Root: static.root, + }, + } + + for ct, calc := range calcs { + Expect(calc.Truncate(entry.input)).To(Equal(entry.expect[ct]), + fmt.Sprintf("💥 'Truncate' failed for input: '%v' (CALC:%v)", entry.input, ct), + ) + } + }, + func(entry *genericCalcTE[string, string]) string { + return fmt.Sprintf("🧪 ===> given: '%v', should: '%v'", + entry.given, entry.should, + ) + }, + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path is empty", + should: "return .", + }, + input: "", + expect: map[CalcType]string{ + CalcTypeAbsolute: ".", + CalcTypeRelative: ".", + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path does not contain a trailing separator", + should: "return original path", + }, + input: static.foobar, + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobar, + CalcTypeRelative: static.foobar, + }, + }), + Entry(nil, &genericCalcTE[string, string]{ + calcTE: calcTE{ + given: "path contains trailing separator", + should: "return truncate the separator", + }, + input: "foo/bar/", + expect: map[CalcType]string{ + CalcTypeAbsolute: static.foobar, + CalcTypeRelative: static.foobar, + }, + }), + ) +}) diff --git a/separate_test.go b/separate_test.go index 879a246..70e33e9 100644 --- a/separate_test.go +++ b/separate_test.go @@ -15,6 +15,7 @@ import ( var _ = Describe("Separate", Ordered, func() { var ( fS nef.UniversalFS + calc nef.PathCalc root, separate string expectations = struct { @@ -40,14 +41,16 @@ var _ = Describe("Separate", Ordered, func() { BeforeEach(func() { scratch(root) fS = nef.NewUniversalABS() + calc = fS.Calc() }) When("directory contains mixed entries", func() { It("🧪 should: separate files from directories", func() { + Expect(requires(fS, root, separate, - luna.Yoke(separate, expectations.foo), - luna.Yoke(separate, expectations.bar), - luna.Yoke(separate, expectations.baz), + calc.Join(separate, expectations.foo), + calc.Join(separate, expectations.bar), + calc.Join(separate, expectations.baz), )).To(Succeed()) Expect(requires(fS, root, filepath.Join(separate, "x"))).To(Succeed()) Expect(requires(fS, root, filepath.Join(separate, "y"))).To(Succeed()) @@ -76,9 +79,9 @@ var _ = Describe("Separate", Ordered, func() { When("directory contains only file entries", func() { It("🧪 should: return files", func() { Expect(requires(fS, root, separate, - luna.Yoke(separate, expectations.foo), - luna.Yoke(separate, expectations.bar), - luna.Yoke(separate, expectations.baz), + calc.Join(separate, expectations.foo), + calc.Join(separate, expectations.bar), + calc.Join(separate, expectations.baz), )).To(Succeed()) full := filepath.Join(root, separate) diff --git a/test/luna/fs-mem.go b/test/luna/fs-mem.go index 2c6b41f..9f96f7e 100644 --- a/test/luna/fs-mem.go +++ b/test/luna/fs-mem.go @@ -20,6 +20,7 @@ import ( // without having to provide a full implementation from scratch. type MemFS struct { fstest.MapFS + calc nef.PathCalc } var ( @@ -32,6 +33,10 @@ func NewMemFS() *MemFS { } } +func (f *MemFS) Calc() nef.PathCalc { + return f.calc +} + func (f *MemFS) FileExists(name string) bool { if mapFile, found := f.MapFS[name]; found && !mapFile.Mode.IsDir() { return true diff --git a/test/luna/utilities-rel.go b/test/luna/utilities-rel.go deleted file mode 100644 index 4062d6a..0000000 --- a/test/luna/utilities-rel.go +++ /dev/null @@ -1,11 +0,0 @@ -package luna - -import ( - "strings" -) - -// Yoke is similar to filepath.Join but it is meant specifically for relative file -// systems where the rules of a path are different; see fs.ValidPath -func Yoke(segments ...string) string { - return strings.Join(segments, "/") -}