feat: scaffold remove command foundations

This commit is contained in:
Rogee
2025-10-29 18:21:01 +08:00
parent ceea09f7be
commit 446bd46b95
25 changed files with 994 additions and 0 deletions

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

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

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