feat: add list command with global filters
This commit is contained in:
66
internal/filters/extensions.go
Normal file
66
internal/filters/extensions.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseExtensions converts a raw `|`-delimited extension string into a
|
||||
// normalized, deduplicated slice. Tokens must be prefixed with a dot.
|
||||
func ParseExtensions(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tokens := strings.Split(raw, "|")
|
||||
seen := make(map[string]struct{}, len(tokens))
|
||||
result := make([]string, 0, len(tokens))
|
||||
|
||||
for _, token := range tokens {
|
||||
trimmed := strings.TrimSpace(token)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("extensions string contains empty token")
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, ".") {
|
||||
return nil, fmt.Errorf("extension %q must start with '.'", trimmed)
|
||||
}
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MergeExtensions merges two extension slices, deduplicating case-insensitively.
|
||||
func MergeExtensions(base, extra []string) []string {
|
||||
if len(extra) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(base)+len(extra))
|
||||
merged := make([]string, 0, len(base)+len(extra))
|
||||
|
||||
for _, ext := range base {
|
||||
lower := strings.ToLower(ext)
|
||||
if _, exists := seen[lower]; exists {
|
||||
continue
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
merged = append(merged, lower)
|
||||
}
|
||||
|
||||
for _, ext := range extra {
|
||||
lower := strings.ToLower(ext)
|
||||
if _, exists := seen[lower]; exists {
|
||||
continue
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
merged = append(merged, lower)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
102
internal/listing/options.go
Normal file
102
internal/listing/options.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/filters"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
flagPath = "path"
|
||||
flagRecursive = "recursive"
|
||||
flagIncludeDirs = "include-dirs"
|
||||
flagHidden = "hidden"
|
||||
flagExtensions = "extensions"
|
||||
)
|
||||
|
||||
// RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations.
|
||||
func RegisterScopeFlags(flags *pflag.FlagSet) {
|
||||
flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)")
|
||||
flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories")
|
||||
flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results")
|
||||
flags.Bool(flagHidden, false, "Include hidden files and directories")
|
||||
flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)")
|
||||
}
|
||||
|
||||
// ScopeFromCmd builds a ListingRequest populated from scope flags on the provided command.
|
||||
func ScopeFromCmd(cmd *cobra.Command) (*ListingRequest, error) {
|
||||
path, err := getStringFlag(cmd, flagPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = cwd
|
||||
}
|
||||
|
||||
recursive, err := getBoolFlag(cmd, flagRecursive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
includeDirs, err := getBoolFlag(cmd, flagIncludeDirs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
includeHidden, err := getBoolFlag(cmd, flagHidden)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extRaw, err := getStringFlag(cmd, flagExtensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extensions, err := filters.ParseExtensions(extRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := &ListingRequest{
|
||||
WorkingDir: path,
|
||||
IncludeDirectories: includeDirs,
|
||||
Recursive: recursive,
|
||||
IncludeHidden: includeHidden,
|
||||
Extensions: extensions,
|
||||
Format: FormatTable,
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getStringFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
if f := cmd.Flags().Lookup(name); f != nil {
|
||||
return cmd.Flags().GetString(name)
|
||||
}
|
||||
if f := cmd.InheritedFlags().Lookup(name); f != nil {
|
||||
return cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return "", fmt.Errorf("flag %s not defined", name)
|
||||
}
|
||||
|
||||
func getBoolFlag(cmd *cobra.Command, name string) (bool, error) {
|
||||
if f := cmd.Flags().Lookup(name); f != nil {
|
||||
return cmd.Flags().GetBool(name)
|
||||
}
|
||||
if f := cmd.InheritedFlags().Lookup(name); f != nil {
|
||||
return cmd.InheritedFlags().GetBool(name)
|
||||
}
|
||||
return false, fmt.Errorf("flag %s not defined", name)
|
||||
}
|
||||
170
internal/listing/service.go
Normal file
170
internal/listing/service.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/output"
|
||||
"github.com/rogeecn/renamer/internal/traversal"
|
||||
)
|
||||
|
||||
// walker abstracts traversal implementation for easier testing.
|
||||
type walker interface {
|
||||
Walk(root string, recursive bool, includeDirs bool, includeHidden bool, maxDepth int, fn func(relPath string, entry fs.DirEntry, depth int) error) error
|
||||
}
|
||||
|
||||
// Service orchestrates filesystem traversal, filtering, and output formatting
|
||||
// for the read-only `renamer list` command.
|
||||
type Service struct {
|
||||
walker walker
|
||||
}
|
||||
|
||||
// Option configures optional dependencies for the Service.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithWalker provides a custom traversal walker (useful for tests).
|
||||
func WithWalker(w walker) Option {
|
||||
return func(s *Service) {
|
||||
s.walker = w
|
||||
}
|
||||
}
|
||||
|
||||
// NewService initializes a listing Service with default dependencies.
|
||||
func NewService(opts ...Option) *Service {
|
||||
service := &Service{
|
||||
walker: traversal.NewWalker(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// List executes a listing request, writing formatted output to sink while
|
||||
// returning the computed summary for downstream consumers.
|
||||
func (s *Service) List(ctx context.Context, req *ListingRequest, formatter output.Formatter, sink io.Writer) (output.Summary, error) {
|
||||
var summary output.Summary
|
||||
|
||||
if formatter == nil {
|
||||
return summary, errors.New("formatter cannot be nil")
|
||||
}
|
||||
|
||||
if sink == nil {
|
||||
sink = io.Discard
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
if err := formatter.Begin(sink); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
extensions := make(map[string]struct{}, len(req.Extensions))
|
||||
for _, ext := range req.Extensions {
|
||||
extensions[ext] = struct{}{}
|
||||
}
|
||||
|
||||
err := s.walker.Walk(
|
||||
req.WorkingDir,
|
||||
req.Recursive,
|
||||
req.IncludeDirectories,
|
||||
req.IncludeHidden,
|
||||
req.MaxDepth,
|
||||
func(relPath string, entry fs.DirEntry, depth int) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
listingEntry, err := s.toListingEntry(req.WorkingDir, relPath, entry, depth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.IncludeDirectories && listingEntry.Type != EntryTypeDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply extension filtering to files only.
|
||||
if listingEntry.Type == EntryTypeFile && len(extensions) > 0 {
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if _, match := extensions[ext]; !match {
|
||||
return nil
|
||||
}
|
||||
listingEntry.MatchedExtension = ext
|
||||
}
|
||||
|
||||
outEntry := toOutputEntry(listingEntry)
|
||||
|
||||
if err := formatter.WriteEntry(sink, outEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Add(outEntry)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
if err := formatter.WriteSummary(sink, summary); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) toListingEntry(root, rel string, entry fs.DirEntry, depth int) (ListingEntry, error) {
|
||||
fullPath := filepath.Join(root, rel)
|
||||
entryType := classifyEntry(entry)
|
||||
|
||||
var size int64
|
||||
if entryType == EntryTypeFile {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return ListingEntry{}, err
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
if entryType == EntryTypeSymlink {
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return ListingEntry{
|
||||
Path: filepath.ToSlash(rel),
|
||||
Type: entryType,
|
||||
SizeBytes: size,
|
||||
Depth: depth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func classifyEntry(entry fs.DirEntry) EntryType {
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return EntryTypeSymlink
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return EntryTypeDir
|
||||
}
|
||||
return EntryTypeFile
|
||||
}
|
||||
|
||||
func toOutputEntry(entry ListingEntry) output.Entry {
|
||||
return output.Entry{
|
||||
Path: entry.Path,
|
||||
Type: string(entry.Type),
|
||||
SizeBytes: entry.SizeBytes,
|
||||
Depth: entry.Depth,
|
||||
MatchedExtension: entry.MatchedExtension,
|
||||
}
|
||||
}
|
||||
20
internal/listing/summary.go
Normal file
20
internal/listing/summary.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package listing
|
||||
|
||||
import "strings"
|
||||
|
||||
// EmptyResultMessage returns a contextual message when no entries match.
|
||||
func EmptyResultMessage(req *ListingRequest) string {
|
||||
if req == nil {
|
||||
return "No entries matched the provided filters."
|
||||
}
|
||||
|
||||
if len(req.Extensions) > 0 {
|
||||
return "No entries matched extensions: " + strings.Join(req.Extensions, ", ")
|
||||
}
|
||||
|
||||
if req.IncludeHidden {
|
||||
return "No entries matched the provided filters (including hidden files)."
|
||||
}
|
||||
|
||||
return "No entries matched the provided filters."
|
||||
}
|
||||
97
internal/listing/types.go
Normal file
97
internal/listing/types.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// FormatTable renders output in a column-aligned table for human review.
|
||||
FormatTable = "table"
|
||||
// FormatPlain renders newline-delimited paths for scripting.
|
||||
FormatPlain = "plain"
|
||||
)
|
||||
|
||||
// ListingRequest captures scope and formatting preferences for a listing run.
|
||||
type ListingRequest struct {
|
||||
WorkingDir string
|
||||
IncludeDirectories bool
|
||||
Recursive bool
|
||||
IncludeHidden bool
|
||||
Extensions []string
|
||||
Format string
|
||||
MaxDepth int
|
||||
}
|
||||
|
||||
// ListingEntry represents a single filesystem node discovered during traversal.
|
||||
type ListingEntry struct {
|
||||
Path string
|
||||
Type EntryType
|
||||
SizeBytes int64
|
||||
Depth int
|
||||
MatchedExtension string
|
||||
}
|
||||
|
||||
// EntryType captures the classification of a filesystem node.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
EntryTypeFile EntryType = "file"
|
||||
EntryTypeDir EntryType = "directory"
|
||||
EntryTypeSymlink EntryType = "symlink"
|
||||
)
|
||||
|
||||
// Validate ensures the request is well-formed before execution.
|
||||
func (r *ListingRequest) Validate() error {
|
||||
if r == nil {
|
||||
return errors.New("listing request cannot be nil")
|
||||
}
|
||||
|
||||
if r.WorkingDir == "" {
|
||||
return errors.New("working directory must be provided")
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(r.WorkingDir) {
|
||||
abs, err := filepath.Abs(r.WorkingDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve working directory: %w", err)
|
||||
}
|
||||
r.WorkingDir = abs
|
||||
}
|
||||
|
||||
if r.MaxDepth < 0 {
|
||||
return errors.New("max depth cannot be negative")
|
||||
}
|
||||
|
||||
switch r.Format {
|
||||
case "":
|
||||
r.Format = FormatTable
|
||||
case FormatTable, FormatPlain:
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q", r.Format)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
filtered := r.Extensions[:0]
|
||||
for _, ext := range r.Extensions {
|
||||
trimmed := strings.TrimSpace(ext)
|
||||
if trimmed == "" {
|
||||
return errors.New("extensions cannot include empty values")
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, ".") {
|
||||
return fmt.Errorf("extension %q must start with '.'", ext)
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if _, exists := seen[lower]; exists {
|
||||
continue
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
filtered = append(filtered, lower)
|
||||
}
|
||||
r.Extensions = filtered
|
||||
|
||||
return nil
|
||||
}
|
||||
21
internal/output/factory.go
Normal file
21
internal/output/factory.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package output
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Format identifiers mirrored from listing package to avoid import cycle.
|
||||
const (
|
||||
FormatTable = "table"
|
||||
FormatPlain = "plain"
|
||||
)
|
||||
|
||||
// NewFormatter selects the appropriate renderer based on format key.
|
||||
func NewFormatter(format string) (Formatter, error) {
|
||||
switch format {
|
||||
case FormatPlain:
|
||||
return NewPlainFormatter(), nil
|
||||
case FormatTable, "":
|
||||
return NewTableFormatter(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format %q", format)
|
||||
}
|
||||
}
|
||||
52
internal/output/formatter.go
Normal file
52
internal/output/formatter.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Formatter renders listing entries in a chosen representation.
|
||||
type Formatter interface {
|
||||
Begin(w io.Writer) error
|
||||
WriteEntry(w io.Writer, entry Entry) error
|
||||
WriteSummary(w io.Writer, summary Summary) error
|
||||
}
|
||||
|
||||
// Entry represents a single listing output record in a formatter-agnostic form.
|
||||
type Entry struct {
|
||||
Path string
|
||||
Type string
|
||||
SizeBytes int64
|
||||
Depth int
|
||||
MatchedExtension string
|
||||
}
|
||||
|
||||
// Summary aggregates counts for final reporting.
|
||||
type Summary struct {
|
||||
Files int
|
||||
Directories int
|
||||
Symlinks int
|
||||
}
|
||||
|
||||
// Add records a new entry in the summary counters.
|
||||
func (s *Summary) Add(entry Entry) {
|
||||
switch entry.Type {
|
||||
case "file":
|
||||
s.Files++
|
||||
case "directory":
|
||||
s.Directories++
|
||||
case "symlink":
|
||||
s.Symlinks++
|
||||
}
|
||||
}
|
||||
|
||||
// Total returns the sum of all entry classifications.
|
||||
func (s Summary) Total() int {
|
||||
return s.Files + s.Directories + s.Symlinks
|
||||
}
|
||||
|
||||
// DefaultSummaryLine produces a human-readable summary string for any format.
|
||||
func DefaultSummaryLine(summary Summary) string {
|
||||
return fmt.Sprintf("Total: %d entries (files: %d, directories: %d, symlinks: %d)",
|
||||
summary.Total(), summary.Files, summary.Directories, summary.Symlinks)
|
||||
}
|
||||
28
internal/output/plain.go
Normal file
28
internal/output/plain.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// plainFormatter emits one entry per line suitable for piping into other tools.
|
||||
type plainFormatter struct{}
|
||||
|
||||
// NewPlainFormatter constructs a formatter for plain output.
|
||||
func NewPlainFormatter() Formatter {
|
||||
return &plainFormatter{}
|
||||
}
|
||||
|
||||
func (plainFormatter) Begin(io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (plainFormatter) WriteEntry(w io.Writer, entry Entry) error {
|
||||
_, err := fmt.Fprintln(w, entry.Path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (plainFormatter) WriteSummary(w io.Writer, summary Summary) error {
|
||||
_, err := fmt.Fprintln(w, DefaultSummaryLine(summary))
|
||||
return err
|
||||
}
|
||||
48
internal/output/table.go
Normal file
48
internal/output/table.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// tableFormatter renders aligned columns for human-friendly review.
|
||||
type tableFormatter struct {
|
||||
writer *tabwriter.Writer
|
||||
}
|
||||
|
||||
// NewTableFormatter constructs a table formatter.
|
||||
func NewTableFormatter() Formatter {
|
||||
return &tableFormatter{}
|
||||
}
|
||||
|
||||
func (f *tableFormatter) Begin(w io.Writer) error {
|
||||
f.writer = tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
_, err := fmt.Fprintln(f.writer, "PATH\tTYPE\tSIZE")
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *tableFormatter) WriteEntry(w io.Writer, entry Entry) error {
|
||||
if f.writer == nil {
|
||||
return fmt.Errorf("table formatter not initialized")
|
||||
}
|
||||
|
||||
size := "-"
|
||||
if entry.Type == "file" && entry.SizeBytes >= 0 {
|
||||
size = fmt.Sprintf("%d", entry.SizeBytes)
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(f.writer, "%s\t%s\t%s\n", entry.Path, entry.Type, size)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *tableFormatter) WriteSummary(w io.Writer, summary Summary) error {
|
||||
if f.writer == nil {
|
||||
return fmt.Errorf("table formatter not initialized")
|
||||
}
|
||||
if err := f.writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, DefaultSummaryLine(summary))
|
||||
return err
|
||||
}
|
||||
143
internal/traversal/walker.go
Normal file
143
internal/traversal/walker.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package traversal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Walker streams filesystem entries relative to a working directory.
|
||||
type Walker struct{}
|
||||
|
||||
// NewWalker constructs a new Walker with default behavior.
|
||||
func NewWalker() *Walker {
|
||||
return &Walker{}
|
||||
}
|
||||
|
||||
// Walk traverses starting at root and invokes fn for each matching entry.
|
||||
//
|
||||
// The callback receives the relative path, os.DirEntry metadata, and depth.
|
||||
// Directories that are symbolic links are not descended into when recursive is true.
|
||||
func (w *Walker) Walk(
|
||||
root string,
|
||||
recursive bool,
|
||||
includeDirs bool,
|
||||
includeHidden bool,
|
||||
maxDepth int,
|
||||
fn func(relPath string, entry fs.DirEntry, depth int) error,
|
||||
) error {
|
||||
if root == "" {
|
||||
return errors.New("walk root cannot be empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errors.New("walk root must be a directory")
|
||||
}
|
||||
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walker := func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// propagate traversal errors to caller for logging/handling
|
||||
return err
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(rootAbs, path)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
|
||||
rel = filepath.Clean(rel)
|
||||
depth := depthFor(rel)
|
||||
|
||||
if rel == "." {
|
||||
// Skip emitting the root directory unless explicitly requested.
|
||||
if includeDirs {
|
||||
return fn(rel, d, depth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if maxDepth > 0 && depth > maxDepth {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !includeHidden && isHidden(rel) {
|
||||
if d.IsDir() && recursive {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if !recursive && depth > 0 {
|
||||
return fs.SkipDir
|
||||
}
|
||||
if !includeDirs {
|
||||
// continue traversal but don't emit directory
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if d.Type()&os.ModeSymlink != 0 && d.IsDir() {
|
||||
// emit symlink entry but do not traverse into it
|
||||
if err := fn(rel, d, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
if recursive {
|
||||
return nil
|
||||
}
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return fn(rel, d, depth)
|
||||
}
|
||||
|
||||
if recursive {
|
||||
return filepath.WalkDir(rootAbs, walker)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(rootAbs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(rootAbs, entry.Name())
|
||||
if err := walker(path, entry, nil); err != nil {
|
||||
if errors.Is(err, fs.SkipDir) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func depthFor(rel string) int {
|
||||
if rel == "." || rel == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rel, string(filepath.Separator))
|
||||
}
|
||||
|
||||
func isHidden(rel string) bool {
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
for _, part := range parts {
|
||||
if len(part) > 0 && part[0] == '.' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user