Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion dtree.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ import (
"fmt"
"io/fs"
"path/filepath"
"strings"
)

var (
ErrNotFound = errors.New("file not found")
)

type skipType int

const (
skipNothing skipType = iota
skipFile
skipDir
)

// Collect traverses a directory structure starting at the given root path and constructs a hierarchical representation.
// Exclude can be used to exclude specific files or folders (see filepath.Match for pattern usage, case is ignored).
// Matching for exclude pattern starts after root directory.
// Returns the root node of the constructed file tree or an error if traversal fails.
func Collect(root string) (*Node, error) {
func Collect(root string, exclude ...string) (*Node, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("get absolute path: %w", err)
Expand All @@ -26,6 +37,21 @@ func Collect(root string) (*Node, error) {
return walkErr
}

if len(exclude) > 0 {
skip, err := checkExcludeList(absRoot, path, fileInfo, exclude)
if err != nil {
return fmt.Errorf("check ignore list: %w", err)
}
switch skip {
case skipFile:
return nil
case skipDir:
return fs.SkipDir
default:
// skipNothing
}
}

node := &Node{
FullPath: path,
Info: FileInfoFromInterface(fileInfo),
Expand Down Expand Up @@ -69,3 +95,58 @@ func BuildFileTree(parents map[string]*Node) *Node {

return root
}

// checkExcludeList checks if a file or directory should be skipped based on the provided patterns.
func checkExcludeList(root, path string, fileInfo fs.FileInfo, exclude []string) (skipType, error) {
relPath, err := filepath.Rel(root, path)
if err != nil {
return skipNothing, fmt.Errorf("get relative path: %w", err)
}

skip, err := shouldSkip(relPath, exclude, true)
if err != nil {
return skipNothing, fmt.Errorf("check ignore list: %w", err)
}

if !skip {
return skipNothing, nil
}

if fileInfo.IsDir() {
return skipDir, nil
}

return skipFile, nil
}

// shouldSkip is a helper function to check if the file or folder should be skipped.
func shouldSkip(path string, pattern []string, ignoreCase bool) (bool, error) {
for _, p := range pattern {
var (
name string
match bool
err error
)

if strings.Contains(p, string(filepath.Separator)) {
name = path
} else {
name = filepath.Base(path)
}

if ignoreCase {
match, err = filepath.Match(strings.ToLower(p), strings.ToLower(name))
} else {
match, err = filepath.Match(p, name)
}
if err != nil {
return false, fmt.Errorf("pattern %s: %w", p, err)
}

if match {
return true, nil
}
}

return false, nil
}
142 changes: 142 additions & 0 deletions dtree_external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,145 @@ func TestCollect(t *testing.T) {
assert.NoFileExists(t, toRemoveNode.FullPath)
})
}

func TestCollectWithExclude(t *testing.T) {
// setup test files
testFiles := map[string][]byte{
filepath.Join("test", "test.nfo"): []byte("asd"),
filepath.Join("test", "test.mkv"): []byte("asdf"),
filepath.Join("test", "test2.mkv"): []byte("asdf"),
filepath.Join("test/Sample", "sample.mkv"): []byte("sample"),
filepath.Join("test/Subs", "subs.idx"): []byte("subs.idx"),
filepath.Join("test/Subs", "subs.sub"): []byte("subs.sub"),
}
tempDir := t.TempDir()
setupTestDir(t, tempDir, testFiles)

// create the expected structure
expectedParent := createFileNode(tempDir, true, 4096)
child := createFileNode(filepath.Join(tempDir, "test"), true, 4096)

subChild1 := createFileNode(filepath.Join(child.FullPath, "Sample"), true, 4096)
subChild2 := createFileNode(filepath.Join(child.FullPath, "Subs"), true, 4096)
subChild3 := createFileNode(filepath.Join(child.FullPath, "test.mkv"), false, 4)
subChild4 := createFileNode(filepath.Join(child.FullPath, "test.nfo"), false, 3)
subChild5 := createFileNode(filepath.Join(child.FullPath, "test2.mkv"), false, 4)

subSubChild1 := createFileNode(filepath.Join(subChild1.FullPath, "sample.mkv"), false, 6)
subSubChild2 := createFileNode(filepath.Join(subChild2.FullPath, "subs.idx"), false, 8)
subSubChild3 := createFileNode(filepath.Join(subChild2.FullPath, "subs.sub"), false, 8)
subChild1.Children = append(subChild1.Children, subSubChild1)
subChild2.Children = append(subChild2.Children, subSubChild2, subSubChild3)

child.Children = append(child.Children, subChild1, subChild2, subChild3, subChild4, subChild5)

expectedParent.Children = append(expectedParent.Children, child)

rootNode, err := dtree.Collect(tempDir)
require.NoError(t, err)

t.Run("VerifyCollectedStructure", func(t *testing.T) {
equalNode(t, expectedParent, rootNode)
})

t.Run("IgnoreSampleFolder", func(t *testing.T) {
baseDir := filepath.Join(tempDir, "test")

rootNode, err := dtree.Collect(baseDir)
require.NoError(t, err)
assert.Equal(t, "Sample", rootNode.Children[0].Info.Name)

t.Run("ByName", func(t *testing.T) {
rootNode, err := dtree.Collect(baseDir, "Sample")
require.NoError(t, err)
assert.NotEqual(t, "Sample", rootNode.Children[0].Info.Name)
})

t.Run("ByWrongName", func(t *testing.T) {
rootNode, err := dtree.Collect(baseDir, "sampl")
require.NoError(t, err)
assert.Equal(t, "Sample", rootNode.Children[0].Info.Name)
})

t.Run("ByPattern", func(t *testing.T) {
rootNode, err := dtree.Collect(baseDir, "?ample")
require.NoError(t, err)
assert.NotEqual(t, "Sample", rootNode.Children[0].Info.Name)
})
})

t.Run("IgnoreSampleFile", func(t *testing.T) {
baseDir := filepath.Join(tempDir, "test")

rootNode, err = dtree.Collect(baseDir)
require.NoError(t, err)
require.NotEmpty(t, rootNode.Children[0].Children)
assert.Equal(t, "sample.mkv", rootNode.Children[0].Children[0].Info.Name)

t.Run("ByName", func(t *testing.T) {
rootNode, err = dtree.Collect(baseDir, "sample.mkv")
require.NoError(t, err)
assert.Equal(t, "Sample", rootNode.Children[0].Info.Name)
assert.Empty(t, rootNode.Children[0].Children)
})

t.Run("ByPath", func(t *testing.T) {
rootNode, err = dtree.Collect(baseDir, "Sample/sample.mkv")
require.NoError(t, err)
assert.Equal(t, "Sample", rootNode.Children[0].Info.Name)
assert.Empty(t, rootNode.Children[0].Children)
})

t.Run("ByPattern", func(t *testing.T) {
rootNode, err = dtree.Collect(baseDir, "?amp*e.mkv")
require.NoError(t, err)
assert.Equal(t, "Sample", rootNode.Children[0].Info.Name)
assert.Empty(t, rootNode.Children[0].Children)
})
})

t.Run("IgnoreMultipleFiles", func(t *testing.T) {
baseDir := filepath.Join(tempDir, "test")

rootNode, err = dtree.Collect(baseDir)
require.NoError(t, err)

gotFile, err := rootNode.GetFileByAbsolutePath(subSubChild1.FullPath)
require.NoError(t, err)
equalNode(t, subSubChild1, gotFile)

gotFile, err = rootNode.GetFileByAbsolutePath(subChild3.FullPath)
require.NoError(t, err)
equalNode(t, subChild3, gotFile)

t.Run("ByName", func(t *testing.T) {
rootNode, err = dtree.Collect(baseDir, "sample.mkv", "test.mkv")
require.NoError(t, err)

_, err = rootNode.GetFileByAbsolutePath(subSubChild1.FullPath)
assert.ErrorIs(t, err, dtree.ErrNotFound)

_, err = rootNode.GetFileByAbsolutePath(subChild3.FullPath)
assert.ErrorIs(t, err, dtree.ErrNotFound)
})

t.Run("ByPattern", func(t *testing.T) {
rootNode, err = dtree.Collect(baseDir, "s?mple.mkv", "te*.mkv")
require.NoError(t, err)

_, err = rootNode.GetFileByAbsolutePath(subSubChild1.FullPath)
assert.ErrorIs(t, err, dtree.ErrNotFound)

_, err = rootNode.GetFileByAbsolutePath(subChild3.FullPath)
assert.ErrorIs(t, err, dtree.ErrNotFound)
})
})

// cannot be ignored...
t.Run("IgnoreBaseDir", func(t *testing.T) {
baseDir := filepath.Join(tempDir, "test")

rootNode, err = dtree.Collect(baseDir, "test")
require.NoError(t, err)
})
}
70 changes: 70 additions & 0 deletions dtree_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package dtree

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildFileTree(t *testing.T) {
Expand Down Expand Up @@ -69,3 +71,71 @@ func TestBuildFileTree(t *testing.T) {
assert.Equal(t, "release/subs/sub.idx", gotTree.Children[2].Children[0].FullPath)
assert.Equal(t, "release/subs/sub.sub", gotTree.Children[2].Children[1].FullPath)
}

func TestShouldSkip(t *testing.T) {
tests := []struct {
name string
path string
pattern []string
ignoreCase bool
want bool
expectedError error
}{
{
name: "skip by name (ignore case)",
path: "/dir/skip_mE",
pattern: []string{"skip_me"},
ignoreCase: true,
want: true,
},
{
name: "skip by name (case-sensitive)",
path: "/dir/skip_mE",
pattern: []string{"skip_me"},
ignoreCase: false,
want: false,
},
{
name: "skip by pattern (ignore case)",
path: "/dir/skip_mE",
pattern: []string{"skip?me"},
ignoreCase: true,
want: true,
},
{
name: "skip by path (ignore case)",
path: "/dir/subdir/skip_mE",
pattern: []string{"/dir/subdir/skip_me"},
ignoreCase: true,
want: true,
},
{
name: "skip by char range",
path: "/dir/subdir/skip_me",
pattern: []string{"skip_m[a-e]"},
ignoreCase: true,
want: true,
},
{
name: "invalid range",
path: "/dir/subdir/skip_me",
pattern: []string{"skip_m[a--]"},
ignoreCase: true,
want: false,
expectedError: filepath.ErrBadPattern,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotErr := shouldSkip(tt.path, tt.pattern, tt.ignoreCase)
if tt.expectedError != nil {
assert.ErrorIs(t, gotErr, tt.expectedError)
return
}
require.NoError(t, gotErr)

assert.Equal(t, tt.want, got)
})
}
}