Files
renamer/internal/listing/service.go
2025-10-29 16:08:46 +08:00

171 lines
3.8 KiB
Go

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