feat: add list command with global filters
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user