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

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

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

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

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