diff --git a/dtree.go b/dtree.go index b5891ba..532219e 100644 --- a/dtree.go +++ b/dtree.go @@ -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) @@ -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), @@ -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 +} diff --git a/dtree_external_test.go b/dtree_external_test.go index ffbcbb0..5b05324 100644 --- a/dtree_external_test.go +++ b/dtree_external_test.go @@ -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) + }) +} diff --git a/dtree_internal_test.go b/dtree_internal_test.go index 0a7973a..f7c2618 100644 --- a/dtree_internal_test.go +++ b/dtree_internal_test.go @@ -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) { @@ -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) + }) + } +}