feat: add list command with global filters

This commit is contained in:
Rogee
2025-10-29 16:08:46 +08:00
parent 88563d48e2
commit fa57af8a26
29 changed files with 1892 additions and 25 deletions

102
internal/listing/options.go Normal file
View 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
View 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,
}
}

View 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
View 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
}