feat: scaffold remove command foundations
This commit is contained in:
8
internal/remove/errors.go
Normal file
8
internal/remove/errors.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package remove
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNoTokens indicates that no removal tokens were provided.
|
||||
ErrNoTokens = errors.New("at least one non-empty token is required")
|
||||
)
|
||||
34
internal/remove/parser.go
Normal file
34
internal/remove/parser.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package remove
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParseArgsResult captures parser output for sequential removals.
|
||||
type ParseArgsResult struct {
|
||||
Tokens []string
|
||||
Duplicates []string
|
||||
}
|
||||
|
||||
// ParseArgs splits, trims, and deduplicates tokens while preserving order.
|
||||
func ParseArgs(args []string) (ParseArgsResult, error) {
|
||||
result := ParseArgsResult{Tokens: make([]string, 0, len(args))}
|
||||
|
||||
seen := make(map[string]int)
|
||||
for _, raw := range args {
|
||||
token := strings.TrimSpace(raw)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token]; exists {
|
||||
result.Duplicates = append(result.Duplicates, token)
|
||||
continue
|
||||
}
|
||||
seen[token] = len(result.Tokens)
|
||||
result.Tokens = append(result.Tokens, token)
|
||||
}
|
||||
|
||||
if len(result.Tokens) == 0 {
|
||||
return ParseArgsResult{}, ErrNoTokens
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
53
internal/remove/request.go
Normal file
53
internal/remove/request.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package remove
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/listing"
|
||||
)
|
||||
|
||||
// Request encapsulates the options required for remove operations.
|
||||
// It mirrors the listing scope so preview/apply flows stay consistent.
|
||||
type Request struct {
|
||||
WorkingDir string
|
||||
Tokens []string
|
||||
IncludeDirectories bool
|
||||
Recursive bool
|
||||
IncludeHidden bool
|
||||
Extensions []string
|
||||
}
|
||||
|
||||
// FromListing builds a Request from the shared listing scope plus ordered tokens.
|
||||
func FromListing(scope *listing.ListingRequest, tokens []string) (*Request, error) {
|
||||
if scope == nil {
|
||||
return nil, fmt.Errorf("scope must not be nil")
|
||||
}
|
||||
req := &Request{
|
||||
WorkingDir: scope.WorkingDir,
|
||||
IncludeDirectories: scope.IncludeDirectories,
|
||||
Recursive: scope.Recursive,
|
||||
IncludeHidden: scope.IncludeHidden,
|
||||
Extensions: append([]string(nil), scope.Extensions...),
|
||||
Tokens: append([]string(nil), tokens...),
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Validate ensures the request has the required data before traversal happens.
|
||||
func (r *Request) Validate() error {
|
||||
if r.WorkingDir == "" {
|
||||
return fmt.Errorf("working directory must be provided")
|
||||
}
|
||||
if len(r.Tokens) == 0 {
|
||||
return fmt.Errorf("at least one removal token is required")
|
||||
}
|
||||
for i, token := range r.Tokens {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token at position %d is empty after trimming", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
113
internal/remove/summary.go
Normal file
113
internal/remove/summary.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package remove
|
||||
|
||||
import "sort"
|
||||
|
||||
// Summary aggregates results across preview/apply phases.
|
||||
type Summary struct {
|
||||
totalCandidates int
|
||||
changedCount int
|
||||
conflicts []Conflict
|
||||
empties []string
|
||||
tokenMatches map[string]int
|
||||
duplicates []string
|
||||
}
|
||||
|
||||
// Conflict describes a rename conflict detected during planning.
|
||||
type Conflict struct {
|
||||
Original string
|
||||
Proposed string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// NewSummary constructs a ready-to-use Summary.
|
||||
func NewSummary() Summary {
|
||||
return Summary{
|
||||
tokenMatches: make(map[string]int),
|
||||
conflicts: make([]Conflict, 0),
|
||||
empties: make([]string, 0),
|
||||
duplicates: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordCandidate increments the total candidate count.
|
||||
func (s *Summary) RecordCandidate() {
|
||||
s.totalCandidates++
|
||||
}
|
||||
|
||||
// RecordChange increments changed items.
|
||||
func (s *Summary) RecordChange() {
|
||||
s.changedCount++
|
||||
}
|
||||
|
||||
// AddTokenMatch records the number of matches for a token.
|
||||
func (s *Summary) AddTokenMatch(token string, count int) {
|
||||
s.tokenMatches[token] += count
|
||||
}
|
||||
|
||||
// AddConflict registers a detected conflict.
|
||||
func (s *Summary) AddConflict(c Conflict) {
|
||||
s.conflicts = append(s.conflicts, c)
|
||||
}
|
||||
|
||||
// AddEmpty registers a path skipped due to empty result names.
|
||||
func (s *Summary) AddEmpty(path string) {
|
||||
s.empties = append(s.empties, path)
|
||||
}
|
||||
|
||||
// AddDuplicate tracks duplicate tokens encountered during parsing.
|
||||
func (s *Summary) AddDuplicate(token string) {
|
||||
s.duplicates = append(s.duplicates, token)
|
||||
}
|
||||
|
||||
// TotalCandidates returns how many items were considered.
|
||||
func (s Summary) TotalCandidates() int {
|
||||
return s.totalCandidates
|
||||
}
|
||||
|
||||
// ChangedCount returns the number of items whose names changed.
|
||||
func (s Summary) ChangedCount() int {
|
||||
return s.changedCount
|
||||
}
|
||||
|
||||
// Conflicts returns a copy of conflict info.
|
||||
func (s Summary) Conflicts() []Conflict {
|
||||
out := make([]Conflict, len(s.conflicts))
|
||||
copy(out, s.conflicts)
|
||||
return out
|
||||
}
|
||||
|
||||
// Empties returns paths skipped for empty basename results.
|
||||
func (s Summary) Empties() []string {
|
||||
out := make([]string, len(s.empties))
|
||||
copy(out, s.empties)
|
||||
return out
|
||||
}
|
||||
|
||||
// TokenMatches returns a sorted slice of tokens and counts.
|
||||
func (s Summary) TokenMatches() []struct {
|
||||
Token string
|
||||
Count int
|
||||
} {
|
||||
pairs := make([]struct {
|
||||
Token string
|
||||
Count int
|
||||
}, 0, len(s.tokenMatches))
|
||||
for token, count := range s.tokenMatches {
|
||||
pairs = append(pairs, struct {
|
||||
Token string
|
||||
Count int
|
||||
}{Token: token, Count: count})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
return pairs[i].Token < pairs[j].Token
|
||||
})
|
||||
return pairs
|
||||
}
|
||||
|
||||
// Duplicates returns duplicates flagged by the parser.
|
||||
func (s Summary) Duplicates() []string {
|
||||
out := make([]string, len(s.duplicates))
|
||||
copy(out, s.duplicates)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
73
internal/remove/traversal.go
Normal file
73
internal/remove/traversal.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package remove
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/traversal"
|
||||
)
|
||||
|
||||
// Candidate represents a file or directory eligible for token removal.
|
||||
type Candidate struct {
|
||||
RelativePath string
|
||||
OriginalPath string
|
||||
BaseName string
|
||||
IsDir bool
|
||||
Depth int
|
||||
}
|
||||
|
||||
// Traverse walks the working directory according to the request scope and invokes fn for each
|
||||
// candidate (files by default, directories when IncludeDirectories is true).
|
||||
func Traverse(ctx context.Context, req *Request, fn func(Candidate) error) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extensions := make(map[string]struct{}, len(req.Extensions))
|
||||
for _, ext := range req.Extensions {
|
||||
lower := strings.ToLower(ext)
|
||||
extensions[lower] = struct{}{}
|
||||
}
|
||||
|
||||
walker := traversal.NewWalker()
|
||||
|
||||
return walker.Walk(
|
||||
req.WorkingDir,
|
||||
req.Recursive,
|
||||
req.IncludeDirectories,
|
||||
req.IncludeHidden,
|
||||
0,
|
||||
func(relPath string, entry fs.DirEntry, depth int) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
isDir := entry.IsDir()
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
|
||||
if !isDir && len(extensions) > 0 {
|
||||
if _, ok := extensions[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
candidate := Candidate{
|
||||
RelativePath: filepath.ToSlash(relPath),
|
||||
OriginalPath: filepath.Join(req.WorkingDir, relPath),
|
||||
BaseName: entry.Name(),
|
||||
IsDir: isDir,
|
||||
Depth: depth,
|
||||
}
|
||||
|
||||
if candidate.RelativePath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(candidate)
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user