Add regex command implementation
This commit is contained in:
@@ -9,6 +9,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-29
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
|
||||
- Local filesystem + `.renamer` ledger files (004-extension-rename)
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages (005-add-insert-command)
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages (006-add-regex-command)
|
||||
- Local filesystem and `.renamer` ledger files (006-add-regex-command)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -41,9 +43,9 @@ tests/
|
||||
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
||||
|
||||
## Recent Changes
|
||||
- 006-add-regex-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages
|
||||
- 005-add-insert-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages
|
||||
- 004-extension-rename: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages
|
||||
- 003-add-remove-command: Added sequential `renamer remove` subcommand, automation-friendly ledger metadata, and CLI warnings for duplicates/empty results
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
102
cmd/regex.go
Normal file
102
cmd/regex.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/listing"
|
||||
"github.com/rogeecn/renamer/internal/regex"
|
||||
)
|
||||
|
||||
func newRegexCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "regex <pattern> <template>",
|
||||
Short: "Rename files using regex capture groups",
|
||||
Long: `Preview and apply filename changes by extracting capture groups from a regular
|
||||
expression pattern. Placeholders like @1, @2 refer to captured groups; @0 expands to the full match,
|
||||
and @@ emits a literal @. Undefined placeholders and invalid replacement templates result in
|
||||
validation errors before any filesystem changes occur.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
scope, err := listing.ScopeFromCmd(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dryRun, err := getBool(cmd, "dry-run")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
autoApply, err := getBool(cmd, "yes")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRun && autoApply {
|
||||
return errors.New("--dry-run cannot be combined with --yes; remove one of them")
|
||||
}
|
||||
|
||||
request := regex.NewRequest(scope.WorkingDir)
|
||||
request.Pattern = args[0]
|
||||
request.Template = args[1]
|
||||
request.IncludeDirectories = scope.IncludeDirectories
|
||||
request.Recursive = scope.Recursive
|
||||
request.IncludeHidden = scope.IncludeHidden
|
||||
request.Extensions = append([]string(nil), scope.Extensions...)
|
||||
request.DryRun = dryRun
|
||||
request.AutoConfirm = autoApply
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
summary, planned, err := regex.Preview(cmd.Context(), request, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, warning := range summary.Warnings {
|
||||
fmt.Fprintf(out, "Warning: %s\n", warning)
|
||||
}
|
||||
|
||||
if len(summary.Conflicts) > 0 {
|
||||
for _, conflict := range summary.Conflicts {
|
||||
fmt.Fprintf(out, "CONFLICT: %s -> %s (%s)\n", conflict.OriginalPath, conflict.ProposedPath, conflict.Reason)
|
||||
}
|
||||
return errors.New("conflicts detected; aborting")
|
||||
}
|
||||
|
||||
if summary.Changed == 0 {
|
||||
fmt.Fprintln(out, "No regex renames required.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoApply {
|
||||
fmt.Fprintf(out, "Preview complete: %d matched, %d changed, %d skipped.\n", summary.Matched, summary.Changed, summary.Skipped)
|
||||
fmt.Fprintln(out, "Preview complete. Re-run with --yes to apply.")
|
||||
return nil
|
||||
}
|
||||
|
||||
entry, err := regex.Apply(cmd.Context(), request, planned, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entry.Operations) == 0 {
|
||||
fmt.Fprintln(out, "Nothing to apply; files already matched requested pattern.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Applied %d regex renames. Ledger updated.\n", len(entry.Operations))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Example = ` renamer regex "^(\\w+)-(\\d+)" "@2_@1" --dry-run
|
||||
renamer regex "^(build)_(\\d+)_v(.*)$" "release-@2-@1-v@3" --yes --path ./artifacts
|
||||
renamer regex "^(.*)$" "release-@1" --dry-run # fails when placeholders are undefined`
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(newRegexCommand())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewRemoveCommand())
|
||||
cmd.AddCommand(NewExtensionCommand())
|
||||
cmd.AddCommand(newInsertCommand())
|
||||
cmd.AddCommand(newRegexCommand())
|
||||
cmd.AddCommand(newUndoCommand())
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -48,6 +48,13 @@ func newUndoCommand() *cobra.Command {
|
||||
fmt.Fprintf(out, "Inserted text %q removed\n", insertText)
|
||||
}
|
||||
}
|
||||
case "regex":
|
||||
if pattern, ok := entry.Metadata["pattern"].(string); ok && pattern != "" {
|
||||
fmt.Fprintf(out, "Reverted regex pattern %q\n", pattern)
|
||||
}
|
||||
if template, ok := entry.Metadata["template"].(string); ok && template != "" {
|
||||
fmt.Fprintf(out, "Template restored to %q\n", template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,23 @@ filesystem. Use these options at the root command level so they apply to all sub
|
||||
| `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. |
|
||||
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
|
||||
|
||||
## Regex Command Quick Reference
|
||||
|
||||
```bash
|
||||
renamer regex <pattern> <template> [flags]
|
||||
```
|
||||
|
||||
- Patterns compile with Go’s RE2 engine and are matched against filename stems; invalid expressions fail fast with helpful errors.
|
||||
- Templates support numbered placeholders (`@0`, `@1`, …) along with escaped `@@` for literal at-signs; undefined captures block the run.
|
||||
- Preview mode (`--dry-run`, default) renders the rename plan with change/skipped/conflict statuses; apply with `--yes` writes a ledger entry for undo.
|
||||
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) control candidate discovery just like other commands, and conflicts or empty targets exit non-zero.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
- Preview captured group swapping: `renamer regex "^(\w+)-(\d+)" "@2_@1" --dry-run --path ./samples`
|
||||
- Limit by extensions and directories: `renamer regex '^(build)_(\d+)_v(.*)$' 'release-@2-@1-v@3' --extensions .zip|.tar.gz --include-dirs --recursive`
|
||||
- Automation-friendly apply with undo: `renamer regex '^(feature)-(.*)$' '@2-@1' --yes --path ./staging && renamer undo --path ./staging`
|
||||
|
||||
## Insert Command Quick Reference
|
||||
|
||||
```bash
|
||||
|
||||
92
internal/regex/apply.go
Normal file
92
internal/regex/apply.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/history"
|
||||
)
|
||||
|
||||
// Apply executes the planned regex renames and writes a ledger entry.
|
||||
func Apply(ctx context.Context, req Request, planned []PlannedRename, summary Summary) (history.Entry, error) {
|
||||
reqCopy := req
|
||||
if err := reqCopy.Validate(); err != nil {
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
entry := history.Entry{Command: "regex"}
|
||||
|
||||
if len(planned) == 0 {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
sort.SliceStable(planned, func(i, j int) bool {
|
||||
return planned[i].Depth > planned[j].Depth
|
||||
})
|
||||
|
||||
done := make([]history.Operation, 0, len(planned))
|
||||
groupsMeta := make(map[string][]string, len(planned))
|
||||
|
||||
revert := func() error {
|
||||
for i := len(done) - 1; i >= 0; i-- {
|
||||
op := done[i]
|
||||
source := filepath.Join(reqCopy.WorkingDir, filepath.FromSlash(op.To))
|
||||
destination := filepath.Join(reqCopy.WorkingDir, filepath.FromSlash(op.From))
|
||||
if err := os.Rename(source, destination); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, plan := range planned {
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(plan.TargetAbsolute), 0o755); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, fmt.Errorf("prepare target directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(plan.SourceAbsolute, plan.TargetAbsolute); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
relFrom := filepath.ToSlash(plan.SourceRelative)
|
||||
relTo := filepath.ToSlash(plan.TargetRelative)
|
||||
done = append(done, history.Operation{From: relFrom, To: relTo})
|
||||
if len(plan.MatchGroups) > 0 {
|
||||
groupsMeta[relFrom] = append([]string(nil), plan.MatchGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(done) == 0 {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
entry.Operations = done
|
||||
metadata := map[string]any{
|
||||
"pattern": reqCopy.Pattern,
|
||||
"template": reqCopy.Template,
|
||||
"matched": summary.Matched,
|
||||
"changed": summary.Changed,
|
||||
}
|
||||
if len(groupsMeta) > 0 {
|
||||
metadata["matchGroups"] = groupsMeta
|
||||
}
|
||||
entry.Metadata = metadata
|
||||
|
||||
if err := history.Append(reqCopy.WorkingDir, entry); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
4
internal/regex/doc.go
Normal file
4
internal/regex/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package regex provides the request, preview summary, and supporting types for the
|
||||
// `renamer regex` command. The concrete planning and execution logic will be added as the
|
||||
// feature progresses through later phases.
|
||||
package regex
|
||||
69
internal/regex/engine.go
Normal file
69
internal/regex/engine.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Engine encapsulates a compiled regex pattern and parsed template for reuse across candidates.
|
||||
type Engine struct {
|
||||
re *regexp.Regexp
|
||||
tmpl template
|
||||
groups int
|
||||
}
|
||||
|
||||
// NewEngine compiles the regex pattern and template into a reusable Engine instance.
|
||||
func NewEngine(pattern, tmpl string) (*Engine, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed, maxGroup, err := parseTemplate(tmpl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if maxGroup > re.NumSubexp() {
|
||||
return nil, ErrTemplateGroupOutOfRange{Group: maxGroup, Available: re.NumSubexp()}
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
re: re,
|
||||
tmpl: parsed,
|
||||
groups: re.NumSubexp(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Apply evaluates the regex against input and renders the replacement when it matches.
|
||||
// When no match occurs, matched is false without error.
|
||||
func (e *Engine) Apply(input string) (output string, matchGroups []string, matched bool, err error) {
|
||||
submatches := e.re.FindStringSubmatch(input)
|
||||
if submatches == nil {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
|
||||
rendered, err := e.tmpl.render(submatches)
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
|
||||
// Exclude the full match from the recorded match group slice.
|
||||
groups := make([]string, 0, len(submatches)-1)
|
||||
if len(submatches) > 1 {
|
||||
groups = append(groups, submatches[1:]...)
|
||||
}
|
||||
|
||||
return rendered, groups, true, nil
|
||||
}
|
||||
|
||||
// ErrTemplateGroupOutOfRange indicates that the template references a capture group that the regex
|
||||
// does not provide.
|
||||
type ErrTemplateGroupOutOfRange struct {
|
||||
Group int
|
||||
Available int
|
||||
}
|
||||
|
||||
func (e ErrTemplateGroupOutOfRange) Error() string {
|
||||
return fmt.Sprintf("template references @%d but pattern only defines %d groups", e.Group, e.Available)
|
||||
}
|
||||
190
internal/regex/preview.go
Normal file
190
internal/regex/preview.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlannedRename represents a proposed rename resulting from preview.
|
||||
type PlannedRename struct {
|
||||
SourceRelative string
|
||||
SourceAbsolute string
|
||||
TargetRelative string
|
||||
TargetAbsolute string
|
||||
MatchGroups []string
|
||||
Depth int
|
||||
}
|
||||
|
||||
// Preview evaluates the regex rename request and returns a summary plus the planned operations.
|
||||
func Preview(ctx context.Context, req Request, out io.Writer) (Summary, []PlannedRename, error) {
|
||||
reqCopy := req
|
||||
if err := reqCopy.Validate(); err != nil {
|
||||
return Summary{}, nil, err
|
||||
}
|
||||
|
||||
engine, err := NewEngine(reqCopy.Pattern, reqCopy.Template)
|
||||
if err != nil {
|
||||
return Summary{}, nil, err
|
||||
}
|
||||
|
||||
summary := Summary{
|
||||
LedgerMetadata: map[string]any{
|
||||
"pattern": reqCopy.Pattern,
|
||||
"template": reqCopy.Template,
|
||||
},
|
||||
Entries: make([]PreviewEntry, 0),
|
||||
}
|
||||
|
||||
planned := make([]PlannedRename, 0)
|
||||
plannedTargets := make(map[string]string)
|
||||
plannedTargetsFold := make(map[string]string)
|
||||
|
||||
err = TraverseCandidates(ctx, &reqCopy, func(candidate Candidate) error {
|
||||
summary.TotalCandidates++
|
||||
|
||||
rendered, groups, matched, err := engine.Apply(candidate.Stem)
|
||||
if err != nil {
|
||||
summary.Warnings = append(summary.Warnings, err.Error())
|
||||
summary.Skipped++
|
||||
summary.Entries = append(summary.Entries, PreviewEntry{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: candidate.RelativePath,
|
||||
Status: EntrySkipped,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if !matched {
|
||||
summary.Skipped++
|
||||
summary.Entries = append(summary.Entries, PreviewEntry{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: candidate.RelativePath,
|
||||
Status: EntrySkipped,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
summary.Matched++
|
||||
|
||||
proposedName := rendered
|
||||
if !candidate.IsDir && candidate.Extension != "" {
|
||||
proposedName += candidate.Extension
|
||||
}
|
||||
|
||||
dir := filepath.Dir(candidate.RelativePath)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
|
||||
var proposedRelative string
|
||||
if dir != "" {
|
||||
proposedRelative = filepath.ToSlash(filepath.Join(dir, proposedName))
|
||||
} else {
|
||||
proposedRelative = filepath.ToSlash(proposedName)
|
||||
}
|
||||
|
||||
matchEntry := PreviewEntry{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: proposedRelative,
|
||||
MatchGroups: groups,
|
||||
}
|
||||
|
||||
if proposedRelative == candidate.RelativePath {
|
||||
summary.Entries = append(summary.Entries, PreviewEntry{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: candidate.RelativePath,
|
||||
Status: EntryNoChange,
|
||||
MatchGroups: groups,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if proposedName == "" || proposedRelative == "" {
|
||||
summary.Conflicts = append(summary.Conflicts, Conflict{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: proposedRelative,
|
||||
Reason: ConflictInvalidTemplate,
|
||||
})
|
||||
summary.Skipped++
|
||||
matchEntry.Status = EntrySkipped
|
||||
summary.Entries = append(summary.Entries, matchEntry)
|
||||
return nil
|
||||
}
|
||||
|
||||
if existing, ok := plannedTargets[proposedRelative]; ok && existing != candidate.RelativePath {
|
||||
summary.Conflicts = append(summary.Conflicts, Conflict{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: proposedRelative,
|
||||
Reason: ConflictDuplicateTarget,
|
||||
})
|
||||
summary.Skipped++
|
||||
matchEntry.Status = EntrySkipped
|
||||
summary.Entries = append(summary.Entries, matchEntry)
|
||||
return nil
|
||||
}
|
||||
|
||||
casefoldKey := strings.ToLower(proposedRelative)
|
||||
if existing, ok := plannedTargetsFold[casefoldKey]; ok && existing != candidate.RelativePath {
|
||||
summary.Conflicts = append(summary.Conflicts, Conflict{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: proposedRelative,
|
||||
Reason: ConflictDuplicateTarget,
|
||||
})
|
||||
summary.Skipped++
|
||||
matchEntry.Status = EntrySkipped
|
||||
summary.Entries = append(summary.Entries, matchEntry)
|
||||
return nil
|
||||
}
|
||||
|
||||
targetAbsolute := filepath.Join(reqCopy.WorkingDir, filepath.FromSlash(proposedRelative))
|
||||
if info, statErr := os.Stat(targetAbsolute); statErr == nil {
|
||||
origInfo, origErr := os.Stat(candidate.OriginalPath)
|
||||
if origErr != nil || !os.SameFile(info, origInfo) {
|
||||
reason := ConflictExistingFile
|
||||
if info.IsDir() {
|
||||
reason = ConflictExistingDir
|
||||
}
|
||||
summary.Conflicts = append(summary.Conflicts, Conflict{
|
||||
OriginalPath: candidate.RelativePath,
|
||||
ProposedPath: proposedRelative,
|
||||
Reason: reason,
|
||||
})
|
||||
summary.Skipped++
|
||||
matchEntry.Status = EntrySkipped
|
||||
summary.Entries = append(summary.Entries, matchEntry)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
plannedTargets[proposedRelative] = candidate.RelativePath
|
||||
plannedTargetsFold[casefoldKey] = candidate.RelativePath
|
||||
|
||||
matchEntry.Status = EntryChanged
|
||||
summary.Entries = append(summary.Entries, matchEntry)
|
||||
summary.Changed++
|
||||
|
||||
planned = append(planned, PlannedRename{
|
||||
SourceRelative: candidate.RelativePath,
|
||||
SourceAbsolute: candidate.OriginalPath,
|
||||
TargetRelative: proposedRelative,
|
||||
TargetAbsolute: targetAbsolute,
|
||||
MatchGroups: groups,
|
||||
Depth: candidate.Depth,
|
||||
})
|
||||
|
||||
if out != nil {
|
||||
fmt.Fprintf(out, "%s -> %s\n", candidate.RelativePath, proposedRelative)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Summary{}, nil, err
|
||||
}
|
||||
|
||||
return summary, planned, nil
|
||||
}
|
||||
69
internal/regex/request.go
Normal file
69
internal/regex/request.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Request captures the inputs required to evaluate regex-based rename operations.
|
||||
type Request struct {
|
||||
WorkingDir string
|
||||
Pattern string
|
||||
Template string
|
||||
IncludeDirectories bool
|
||||
Recursive bool
|
||||
IncludeHidden bool
|
||||
Extensions []string
|
||||
DryRun bool
|
||||
AutoConfirm bool
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// NewRequest constructs a Request with the supplied working directory and defaults the
|
||||
// timestamp to the current UTC time. Additional fields should be set by the caller.
|
||||
func NewRequest(workingDir string) Request {
|
||||
return Request{
|
||||
WorkingDir: workingDir,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ensures the request has usable defaults and a resolvable working directory.
|
||||
func (r *Request) Validate() error {
|
||||
if r == nil {
|
||||
return errors.New("regex request cannot be nil")
|
||||
}
|
||||
|
||||
if r.Pattern == "" {
|
||||
return errors.New("regex pattern is required")
|
||||
}
|
||||
|
||||
if r.WorkingDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine working directory: %w", err)
|
||||
}
|
||||
r.WorkingDir = cwd
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
info, err := os.Stat(r.WorkingDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat working directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("working directory %q is not a directory", r.WorkingDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
47
internal/regex/summary.go
Normal file
47
internal/regex/summary.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package regex
|
||||
|
||||
// Summary describes the outcome of previewing or applying a regex rename request.
|
||||
type Summary struct {
|
||||
TotalCandidates int
|
||||
Matched int
|
||||
Changed int
|
||||
Skipped int
|
||||
Conflicts []Conflict
|
||||
Warnings []string
|
||||
Entries []PreviewEntry
|
||||
LedgerMetadata map[string]any
|
||||
}
|
||||
|
||||
// ConflictReason enumerates reasons a proposed rename cannot proceed.
|
||||
type ConflictReason string
|
||||
|
||||
const (
|
||||
ConflictDuplicateTarget ConflictReason = "duplicate_target"
|
||||
ConflictExistingFile ConflictReason = "existing_file"
|
||||
ConflictExistingDir ConflictReason = "existing_directory"
|
||||
ConflictInvalidTemplate ConflictReason = "invalid_template"
|
||||
)
|
||||
|
||||
// Conflict reports a blocked rename candidate to the CLI and callers.
|
||||
type Conflict struct {
|
||||
OriginalPath string
|
||||
ProposedPath string
|
||||
Reason ConflictReason
|
||||
}
|
||||
|
||||
// EntryStatus captures the preview disposition for a candidate path.
|
||||
type EntryStatus string
|
||||
|
||||
const (
|
||||
EntryChanged EntryStatus = "changed"
|
||||
EntryNoChange EntryStatus = "no_change"
|
||||
EntrySkipped EntryStatus = "skipped"
|
||||
)
|
||||
|
||||
// PreviewEntry documents a single rename candidate and the proposed output.
|
||||
type PreviewEntry struct {
|
||||
OriginalPath string
|
||||
ProposedPath string
|
||||
Status EntryStatus
|
||||
MatchGroups []string
|
||||
}
|
||||
109
internal/regex/template.go
Normal file
109
internal/regex/template.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type templateSegment struct {
|
||||
literal string
|
||||
group int
|
||||
}
|
||||
|
||||
const literalSegment = -1
|
||||
|
||||
// template represents a parsed replacement template with capture placeholders.
|
||||
type template struct {
|
||||
segments []templateSegment
|
||||
}
|
||||
|
||||
// parseTemplate converts a string containing literal text, numbered placeholders (@0, @1, ...),
|
||||
// and escaped @@ sequences into a template structure. It returns the template, the highest
|
||||
// placeholder index encountered, or an error when syntax is invalid.
|
||||
func parseTemplate(input string) (template, int, error) {
|
||||
segments := make([]templateSegment, 0)
|
||||
var literal strings.Builder
|
||||
maxGroup := 0
|
||||
|
||||
i := 0
|
||||
for i < len(input) {
|
||||
ch := input[i]
|
||||
if ch != '@' {
|
||||
literal.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Flush any buffered literal before handling placeholder/escape.
|
||||
flushLiteral := func() {
|
||||
if literal.Len() == 0 {
|
||||
return
|
||||
}
|
||||
segments = append(segments, templateSegment{literal: literal.String(), group: literalSegment})
|
||||
literal.Reset()
|
||||
}
|
||||
|
||||
if i+1 >= len(input) {
|
||||
return template{}, 0, fmt.Errorf("dangling @ at end of template")
|
||||
}
|
||||
|
||||
next := input[i+1]
|
||||
if next == '@' {
|
||||
flushLiteral()
|
||||
literal.WriteByte('@')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
j := i + 1
|
||||
for j < len(input) && input[j] >= '0' && input[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
if j == i+1 {
|
||||
return template{}, 0, fmt.Errorf("invalid placeholder at offset %d", i)
|
||||
}
|
||||
|
||||
indexStr := input[i+1 : j]
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil {
|
||||
return template{}, 0, fmt.Errorf("invalid placeholder index @%s", indexStr)
|
||||
}
|
||||
|
||||
flushLiteral()
|
||||
segments = append(segments, templateSegment{group: index})
|
||||
if index > maxGroup {
|
||||
maxGroup = index
|
||||
}
|
||||
|
||||
i = j
|
||||
}
|
||||
|
||||
if literal.Len() > 0 {
|
||||
segments = append(segments, templateSegment{literal: literal.String(), group: literalSegment})
|
||||
}
|
||||
|
||||
return template{segments: segments}, maxGroup, nil
|
||||
}
|
||||
|
||||
// render produces the output string for a given set of submatches. The slice must contain the
|
||||
// full match at index 0 followed by capture groups. Missing groups (e.g., optional matches)
|
||||
// expand to empty strings. Referencing a group index beyond the available matches returns an error.
|
||||
func (t template) render(submatches []string) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
for _, segment := range t.segments {
|
||||
if segment.group == literalSegment {
|
||||
builder.WriteString(segment.literal)
|
||||
continue
|
||||
}
|
||||
|
||||
if segment.group >= len(submatches) {
|
||||
return "", ErrUndefinedPlaceholder{Index: segment.group}
|
||||
}
|
||||
|
||||
builder.WriteString(submatches[segment.group])
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
86
internal/regex/traversal.go
Normal file
86
internal/regex/traversal.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package regex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/traversal"
|
||||
)
|
||||
|
||||
// Candidate represents a file or directory subject to regex evaluation.
|
||||
type Candidate struct {
|
||||
RelativePath string
|
||||
OriginalPath string
|
||||
BaseName string
|
||||
Stem string
|
||||
Extension string
|
||||
IsDir bool
|
||||
Depth int
|
||||
}
|
||||
|
||||
// TraverseCandidates walks the working directory and invokes fn for each eligible candidate.
|
||||
func TraverseCandidates(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()
|
||||
name := entry.Name()
|
||||
stem := name
|
||||
ext := ""
|
||||
if !isDir {
|
||||
if dot := strings.IndexRune(name, '.'); dot > 0 {
|
||||
ext = name[dot:]
|
||||
stem = name[:dot]
|
||||
}
|
||||
|
||||
if len(extensions) > 0 {
|
||||
lower := strings.ToLower(ext)
|
||||
if _, ok := extensions[lower]; !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(relPath)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidate := Candidate{
|
||||
RelativePath: rel,
|
||||
OriginalPath: filepath.Join(req.WorkingDir, relPath),
|
||||
BaseName: name,
|
||||
Stem: stem,
|
||||
Extension: ext,
|
||||
IsDir: isDir,
|
||||
Depth: depth,
|
||||
}
|
||||
|
||||
return fn(candidate)
|
||||
},
|
||||
)
|
||||
}
|
||||
30
internal/regex/validate.go
Normal file
30
internal/regex/validate.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package regex
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValidateTemplate ensures the parsed template does not reference capture groups beyond the
|
||||
// pattern's capabilities and returns a descriptive error for CLI presentation.
|
||||
func ValidateTemplate(engine *Engine, tmpl template) error {
|
||||
if engine == nil {
|
||||
return fmt.Errorf("internal error: regex engine not initialized")
|
||||
}
|
||||
max := 0
|
||||
for _, segment := range tmpl.segments {
|
||||
if segment.group > max {
|
||||
max = segment.group
|
||||
}
|
||||
}
|
||||
if max > engine.groups {
|
||||
return ErrTemplateGroupOutOfRange{Group: max, Available: engine.groups}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrUndefinedPlaceholder indicates that the template references a group with no match result.
|
||||
type ErrUndefinedPlaceholder struct {
|
||||
Index int
|
||||
}
|
||||
|
||||
func (e ErrUndefinedPlaceholder) Error() string {
|
||||
return fmt.Sprintf("template references @%d but the pattern did not produce that group", e.Index)
|
||||
}
|
||||
75
scripts/smoke-test-regex.sh
Executable file
75
scripts/smoke-test-regex.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN=(go run)
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
WORK_DIR="$TMP_DIR/workspace"
|
||||
PREVIEW_LOG=""
|
||||
SCOPE_LOG=""
|
||||
APPLY_LOG=""
|
||||
UNDO_LOG=""
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
for log in "$PREVIEW_LOG" "$SCOPE_LOG" "$APPLY_LOG" "$UNDO_LOG"; do
|
||||
[[ -n "${log:-}" ]] && rm -f "$log"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$WORK_DIR" "$WORK_DIR/artifacts"
|
||||
|
||||
# Seed workspace with fixtures referenced in quickstart docs.
|
||||
cp -R "$ROOT_DIR/tests/fixtures/regex/baseline/." "$WORK_DIR/"
|
||||
cp "$ROOT_DIR/tests/fixtures/regex/mixed/feature-demo_2025-10-01.txt" "$WORK_DIR/"
|
||||
cp "$ROOT_DIR/tests/fixtures/regex/mixed/build_101_release.tar.gz" "$WORK_DIR/artifacts/build_101_vrelease.tar.gz"
|
||||
cp "$ROOT_DIR/tests/fixtures/regex/mixed/build_102_hotfix.tar.gz" "$WORK_DIR/artifacts/build_102_vhotfix.tar.gz"
|
||||
mkdir -p "$WORK_DIR/artifacts/build_103_varchive"
|
||||
printf 'Quarterly summary\n' >"$WORK_DIR/2025-01_report.txt"
|
||||
|
||||
PREVIEW_LOG="$(mktemp)"
|
||||
SCOPE_LOG="$(mktemp)"
|
||||
APPLY_LOG="$(mktemp)"
|
||||
UNDO_LOG="$(mktemp)"
|
||||
|
||||
run_cli() {
|
||||
local log="$1"
|
||||
shift
|
||||
"${BIN[@]}" "$ROOT_DIR/main.go" "$@" >"$log"
|
||||
cat "$log"
|
||||
}
|
||||
|
||||
echo "Quickstart #1: Preview captured group substitution (--dry-run)."
|
||||
run_cli "$PREVIEW_LOG" regex '^(\\d{4})-(\\d{2})_(.*)$' 'Q@2-@1_@3' --dry-run --path "$WORK_DIR"
|
||||
if ! grep -q 'Q01-2025_report.txt' "$PREVIEW_LOG"; then
|
||||
echo "Expected preview rename for 2025-01_report.txt missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Quickstart #2: Scope-limited preview with extensions and directories."
|
||||
run_cli "$SCOPE_LOG" regex '^(build)_(\\d+)_v(.*)$' 'release-@2-@1-v@3' --dry-run --path "$WORK_DIR/artifacts" --extensions '.zip|.tar.gz' --include-dirs
|
||||
if ! grep -q 'release-101-build-vrelease.tar.gz' "$SCOPE_LOG"; then
|
||||
echo "Expected scoped preview for build artifacts missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Quickstart #3: Apply regex rename non-interactively (--yes)."
|
||||
run_cli "$APPLY_LOG" regex '^(feature)-(.*)$' '@2-@1' --yes --path "$WORK_DIR"
|
||||
if ! [[ -f "$WORK_DIR/demo_2025-10-01-feature.txt" ]]; then
|
||||
echo "Applied rename did not produce demo_2025-10-01-feature.txt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Quickstart #4: Undo the latest regex batch."
|
||||
run_cli "$UNDO_LOG" undo --path "$WORK_DIR"
|
||||
if ! [[ -f "$WORK_DIR/feature-demo_2025-10-01.txt" ]]; then
|
||||
echo "Undo did not restore feature-demo_2025-10-01.txt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Regex smoke test completed successfully."
|
||||
34
specs/006-add-regex-command/checklists/requirements.md
Normal file
34
specs/006-add-regex-command/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Regex Command for Pattern-Based Renaming
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2025-10-30
|
||||
**Feature**: specs/001-add-regex-command/spec.md
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
248
specs/006-add-regex-command/contracts/regex-command.yaml
Normal file
248
specs/006-add-regex-command/contracts/regex-command.yaml
Normal file
@@ -0,0 +1,248 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Renamer Regex Command API
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Contract representation of the `renamer regex` command workflows (preview/apply/undo)
|
||||
for automation harnesses and documentation parity.
|
||||
servers:
|
||||
- url: cli://renamer
|
||||
description: Command-line invocation surface
|
||||
paths:
|
||||
/regex/preview:
|
||||
post:
|
||||
summary: Preview regex-based rename results
|
||||
description: Mirrors `renamer regex <pattern> <template> --dry-run`
|
||||
operationId: previewRegex
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegexRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful preview
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegexPreview'
|
||||
'400':
|
||||
description: Validation error (invalid pattern, undefined placeholders)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/regex/apply:
|
||||
post:
|
||||
summary: Apply regex-based renaming
|
||||
description: Mirrors `renamer regex <pattern> <template> --yes`
|
||||
operationId: applyRegex
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RegexRequest'
|
||||
- type: object
|
||||
properties:
|
||||
dryRun:
|
||||
type: boolean
|
||||
const: false
|
||||
responses:
|
||||
'200':
|
||||
description: Apply succeeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegexApplyResult'
|
||||
'409':
|
||||
description: Conflict detected (duplicate targets, existing files)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConflictResponse'
|
||||
'400':
|
||||
description: Validation error (invalid pattern/template)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/regex/undo:
|
||||
post:
|
||||
summary: Undo the latest regex rename batch
|
||||
description: Mirrors `renamer undo` when the last ledger entry corresponds to a regex command.
|
||||
operationId: undoRegex
|
||||
responses:
|
||||
'200':
|
||||
description: Undo succeeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UndoResult'
|
||||
'409':
|
||||
description: Ledger inconsistent or no regex entry found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
components:
|
||||
schemas:
|
||||
RegexRequest:
|
||||
type: object
|
||||
required:
|
||||
- workingDir
|
||||
- pattern
|
||||
- template
|
||||
properties:
|
||||
workingDir:
|
||||
type: string
|
||||
pattern:
|
||||
type: string
|
||||
description: RE2-compatible regular expression applied to filename stem.
|
||||
template:
|
||||
type: string
|
||||
description: Replacement template supporting placeholders `@0..@n`.
|
||||
includeDirs:
|
||||
type: boolean
|
||||
default: false
|
||||
recursive:
|
||||
type: boolean
|
||||
default: false
|
||||
includeHidden:
|
||||
type: boolean
|
||||
default: false
|
||||
extensionFilter:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: true
|
||||
autoConfirm:
|
||||
type: boolean
|
||||
default: false
|
||||
RegexPreview:
|
||||
type: object
|
||||
required:
|
||||
- totalCandidates
|
||||
- matched
|
||||
- changed
|
||||
- entries
|
||||
properties:
|
||||
totalCandidates:
|
||||
type: integer
|
||||
minimum: 0
|
||||
matched:
|
||||
type: integer
|
||||
minimum: 0
|
||||
changed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
conflicts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Conflict'
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PreviewEntry'
|
||||
RegexApplyResult:
|
||||
type: object
|
||||
required:
|
||||
- totalApplied
|
||||
- skipped
|
||||
- ledgerEntryId
|
||||
properties:
|
||||
totalApplied:
|
||||
type: integer
|
||||
minimum: 0
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
ledgerEntryId:
|
||||
type: string
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
UndoResult:
|
||||
type: object
|
||||
required:
|
||||
- restored
|
||||
- ledgerEntryId
|
||||
properties:
|
||||
restored:
|
||||
type: integer
|
||||
ledgerEntryId:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
Conflict:
|
||||
type: object
|
||||
required:
|
||||
- originalPath
|
||||
- proposedPath
|
||||
- reason
|
||||
properties:
|
||||
originalPath:
|
||||
type: string
|
||||
proposedPath:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- duplicate_target
|
||||
- existing_file
|
||||
- existing_directory
|
||||
- invalid_template
|
||||
PreviewEntry:
|
||||
type: object
|
||||
required:
|
||||
- originalPath
|
||||
- proposedPath
|
||||
- status
|
||||
properties:
|
||||
originalPath:
|
||||
type: string
|
||||
proposedPath:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- changed
|
||||
- no_change
|
||||
- skipped
|
||||
matchGroups:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
remediation:
|
||||
type: string
|
||||
ConflictResponse:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
- conflicts
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
conflicts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Conflict'
|
||||
62
specs/006-add-regex-command/data-model.md
Normal file
62
specs/006-add-regex-command/data-model.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Data Model – Regex Command
|
||||
|
||||
## Entity: RegexRequest
|
||||
- **Fields**
|
||||
- `WorkingDir string` — Absolute path derived from CLI `--path` or current directory.
|
||||
- `Pattern string` — User-supplied regular expression.
|
||||
- `Template string` — Replacement string with `@n` placeholders.
|
||||
- `IncludeDirs bool` — Mirrors `--include-dirs` flag.
|
||||
- `Recursive bool` — Mirrors `--recursive` flag.
|
||||
- `IncludeHidden bool` — True only when `--hidden` is supplied.
|
||||
- `ExtensionFilter []string` — Filter tokens from `--extensions`.
|
||||
- `DryRun bool` — Preview-only execution state.
|
||||
- `AutoConfirm bool` — Captures `--yes` for non-interactive runs.
|
||||
- `Timestamp time.Time` — Invocation timestamp for ledger correlation.
|
||||
- **Validation Rules**
|
||||
- Regex must compile; invalid patterns produce errors.
|
||||
- Template may reference `@0` (full match) and numbered groups; referencing undefined groups is invalid.
|
||||
- Prohibit control characters and path separators in resulting names.
|
||||
- **Relationships**
|
||||
- Consumed by regex engine to build rename plan.
|
||||
- Serialized into ledger metadata alongside summary output.
|
||||
|
||||
## Entity: RegexSummary
|
||||
- **Fields**
|
||||
- `TotalCandidates int` — Items inspected after scope filtering.
|
||||
- `Matched int` — Files whose names matched the regex.
|
||||
- `Changed int` — Entries that will change after template substitution.
|
||||
- `Skipped int` — Non-matching or invalid-template entries.
|
||||
- `Conflicts []Conflict` — Rename collisions or generated duplicates.
|
||||
- `Warnings []string` — Validation notices (unused groups, truncated templates).
|
||||
- `Entries []PreviewEntry` — Original/proposed mappings with status.
|
||||
- `LedgerMetadata map[string]any` — Snapshot persisted with ledger entry (pattern, template, scope flags).
|
||||
- **Validation Rules**
|
||||
- Conflicts must be empty before apply.
|
||||
- `Matched = Changed + (matched entries with no change)` for consistency.
|
||||
- **Relationships**
|
||||
- Drives preview rendering.
|
||||
- Input for ledger writer and undo verification.
|
||||
|
||||
## Entity: Conflict
|
||||
- **Fields**
|
||||
- `OriginalPath string`
|
||||
- `ProposedPath string`
|
||||
- `Reason string` — (`duplicate_target`, `existing_file`, `invalid_template`).
|
||||
- **Validation Rules**
|
||||
- `ProposedPath` unique among planned operations.
|
||||
- Reason drawn from known enum for consistent messaging.
|
||||
- **Relationships**
|
||||
- Reported in preview output and blocks apply.
|
||||
|
||||
## Entity: PreviewEntry
|
||||
- **Fields**
|
||||
- `OriginalPath string`
|
||||
- `ProposedPath string`
|
||||
- `Status string` — `changed`, `no_change`, `skipped`.
|
||||
- `MatchGroups []string` — Captured groups applied to template.
|
||||
- **Validation Rules**
|
||||
- `ProposedPath` equals `OriginalPath` when `Status == "no_change"`.
|
||||
- `MatchGroups` length must equal number of captured groups.
|
||||
- **Relationships**
|
||||
- Displayed in preview output.
|
||||
- Persisted alongside ledger metadata for undo.
|
||||
96
specs/006-add-regex-command/plan.md
Normal file
96
specs/006-add-regex-command/plan.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Implementation Plan: Regex Command for Pattern-Based Renaming
|
||||
|
||||
**Branch**: `006-add-regex-command` | **Date**: 2025-10-30 | **Spec**: `specs/006-add-regex-command/spec.md`
|
||||
**Input**: Feature specification from `/specs/006-add-regex-command/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver a `renamer regex` subcommand that compiles a user-supplied pattern, substitutes numbered capture groups into a replacement template, surfaces deterministic previews, and records ledger metadata so undo and automation workflows remain safe and auditable.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.24
|
||||
**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages
|
||||
**Storage**: Local filesystem and `.renamer` ledger files
|
||||
**Testing**: `go test ./...`, contract suites under `tests/contract`, integration flows under `tests/integration`, targeted smoke script
|
||||
**Target Platform**: Cross-platform CLI (Linux, macOS, Windows shells)
|
||||
**Project Type**: Single CLI project (`cmd/`, `internal/`, `tests/`, `scripts/`)
|
||||
**Performance Goals**: Preview + apply 500 regex-driven renames in <2 minutes end-to-end
|
||||
**Constraints**: Preview-first confirmation, reversible ledger entries, Unicode-safe regex evaluation, conflict detection before apply
|
||||
**Scale/Scope**: Expected to operate on thousands of entries per invocation within local directories
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Preview flow MUST show deterministic rename mappings and require explicit confirmation (Preview-First Safety). ✅ Use shared preview renderer to list original → proposed names plus skipped/conflict indicators prior to apply.
|
||||
- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). ✅ Append ledger entries containing pattern, template, captured groups per file, enabling `renamer undo` to restore originals.
|
||||
- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). ✅ Build a dedicated regex rule that compiles patterns, validates templates, and plugs into traversal pipeline without altering shared state.
|
||||
- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). ✅ Reuse traversal filters so regex respects directory, recursion, hidden, and extension flags identically to other commands.
|
||||
- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). ✅ Add Cobra subcommand with documented flags, examples, help output, and contract/integration coverage for preview/apply/undo flows.
|
||||
|
||||
*Post-Design Verification (2025-10-30): Research, data model, contracts, and quickstart documents confirm preview coverage, ledger metadata, regex template validation, and CLI UX updates — no gate violations detected.*
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/006-add-regex-command/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md # Generated via /speckit.tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
cmd/
|
||||
├── root.go
|
||||
├── list.go
|
||||
├── replace.go
|
||||
├── remove.go
|
||||
├── extension.go
|
||||
├── insert.go
|
||||
├── regex.go # NEW
|
||||
└── undo.go
|
||||
|
||||
internal/
|
||||
├── filters/
|
||||
├── history/
|
||||
├── listing/
|
||||
├── output/
|
||||
├── remove/
|
||||
├── replace/
|
||||
├── extension/
|
||||
├── insert/
|
||||
└── regex/ # NEW: pattern compilation, template evaluation, engine, ledger metadata
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
├── fixtures/
|
||||
└── unit/
|
||||
|
||||
scripts/
|
||||
├── smoke-test-list.sh
|
||||
├── smoke-test-replace.sh
|
||||
├── smoke-test-remove.sh
|
||||
├── smoke-test-extension.sh
|
||||
├── smoke-test-insert.sh
|
||||
└── smoke-test-regex.sh # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Extend the single CLI project by introducing `cmd/regex.go`, a new `internal/regex` package for rule evaluation, and corresponding contract/integration tests plus a smoke script under existing directories.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
28
specs/006-add-regex-command/quickstart.md
Normal file
28
specs/006-add-regex-command/quickstart.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Quickstart – Regex Command
|
||||
|
||||
1. **Preview a capture-group rename before applying.**
|
||||
```bash
|
||||
renamer regex '^(\d{4})-(\d{2})_(.*)$' 'Q@2-@1_@3' --dry-run
|
||||
```
|
||||
- Converts `2025-01_report.txt` into `Q01-2025_report.txt` in preview mode.
|
||||
- Skipped files remain untouched and are labeled in the preview table.
|
||||
|
||||
2. **Limit scope with extension and directory flags.**
|
||||
```bash
|
||||
renamer regex '^(build)_(\d+)_v(.*)$' 'release-@2-@1-v@3' --path ./artifacts --extensions .zip|.tar.gz --include-dirs --dry-run
|
||||
```
|
||||
- Applies only to archives under `./artifacts`, including subdirectories when paired with `-r`.
|
||||
- Hidden files remain excluded unless `--hidden` is added.
|
||||
|
||||
3. **Apply changes non-interactively for automation.**
|
||||
```bash
|
||||
renamer regex '^(feature)-(.*)$' '@2-@1' --yes --path ./staging
|
||||
```
|
||||
- `--yes` confirms using the preview plan and writes a ledger entry containing pattern and template metadata.
|
||||
- Exit code `0` indicates success; non-zero signals validation or conflict issues.
|
||||
|
||||
4. **Undo the last regex batch if results are unexpected.**
|
||||
```bash
|
||||
renamer undo --path ./staging
|
||||
```
|
||||
- Restores original filenames using the `.renamer` ledger captured during apply.
|
||||
21
specs/006-add-regex-command/research.md
Normal file
21
specs/006-add-regex-command/research.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Phase 0 Research – Regex Command
|
||||
|
||||
## Decision: Reuse Traversal, Preview, and Ledger Pipelines for Regex Rule
|
||||
- **Rationale**: Existing replace/remove/extension commands already walk the filesystem, apply scope filters, and feed preview + ledger writers. Plugging a regex rule into this pipeline guarantees consistent conflict detection, skipped reporting, and undo safety without reimplementing traversal safeguards.
|
||||
- **Alternatives considered**: Building a standalone regex walker was rejected because it would duplicate scope logic and risk violating Scope-Aware Traversal. Embedding regex into replace internals was rejected to keep literal and regex behaviors independent and easier to test.
|
||||
|
||||
## Decision: Compile Patterns with Go `regexp` (RE2) and Cache Group Metadata
|
||||
- **Rationale**: Go’s standard library provides RE2-backed regex compilation with deterministic performance and Unicode safety. Capturing the compiled expression once per invocation lets us pre-count capture groups, validate templates, and apply matches efficiently across many files.
|
||||
- **Alternatives considered**: Using third-party regex engines (PCRE) was rejected due to external dependencies and potential catastrophic backtracking. Recompiling the pattern per file was rejected for performance reasons.
|
||||
|
||||
## Decision: Validate and Render Templates via Placeholder Tokens (`@0`, `@1`, …, `@@`)
|
||||
- **Rationale**: Parsing the template into literal and placeholder segments ensures undefined group references surface as validation errors before preview/apply, while optional groups that fail to match substitute with empty strings. Doubling `@` (i.e., `@@`) yields a literal `@`, aligning with the clarification already captured in the specification.
|
||||
- **Alternatives considered**: Allowing implicit zero-value substitution for undefined groups was rejected because it hides mistakes. Relying on `fmt.Sprintf`-style formatting was rejected since it lacks direct mapping to numbered capture groups and complicates escaping rules.
|
||||
|
||||
## Decision: Ledger Metadata Includes Pattern, Template, and Match Snapshots
|
||||
- **Rationale**: Persisting the regex pattern, replacement template, scope flags, and per-file capture arrays alongside old/new paths enables precise undo and supports automation auditing. This mirrors expectations set for other commands and satisfies the Persistent Undo Ledger principle.
|
||||
- **Alternatives considered**: Logging only before/after filenames was rejected because undo would lose context if filenames changed again outside the tool. Capturing full file contents was rejected as unnecessary and intrusive.
|
||||
|
||||
## Decision: Block Apply When Template Yields Conflicts or Empty Targets
|
||||
- **Rationale**: Conflict detection will reuse existing duplicate/overwrite checks but extend them to treat empty or whitespace-only proposals as invalid. Apply exits non-zero when conflicts remain, protecting against accidental data loss or invalid filenames.
|
||||
- **Alternatives considered**: Auto-resolving conflicts by suffixing counters was rejected because it introduces nondeterministic results and complicates undo. Allowing empty targets was rejected for safety and compatibility reasons.
|
||||
107
specs/006-add-regex-command/spec.md
Normal file
107
specs/006-add-regex-command/spec.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Feature Specification: Regex Command for Pattern-Based Renaming
|
||||
|
||||
**Feature Branch**: `006-add-regex-command`
|
||||
**Created**: 2025-10-30
|
||||
**Status**: Draft
|
||||
**Input**: User description: "实现 regex 命令,用于使用正则获取指定位置内容后再重新命名,示例 renamer regexp <pattern> @1-@2 实现了获取正则的第一、二位的匹配数据,并进行重新命名"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Rename Files Using Captured Groups (Priority: P1)
|
||||
|
||||
As a power user organizing datasets, I want to rename files by extracting portions of their names via regular expressions so that I can normalize naming schemes without writing custom scripts.
|
||||
|
||||
**Why this priority**: Provides the core value—regex-driven renaming to rearrange captured data quickly across large batches.
|
||||
|
||||
**Independent Test**: In a directory with files named `2025-01_report.txt` and `2025-02_report.txt`, run `renamer regex "^(\\d{4})-(\\d{2})_report" "Q@2-@1" --dry-run` and verify the preview shows `Q01-2025.txt` and `Q02-2025.txt`. Re-run with `--yes` to confirm filesystem updates and ledger entry.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** files `alpha-123.log` and `beta-456.log`, **When** the user runs `renamer regex "^(\\w+)-(\\d+)" "@2_@1" --dry-run`, **Then** the preview lists `123_alpha.log` and `456_beta.log` as proposed names.
|
||||
2. **Given** files that do not match the pattern, **When** the command runs in preview mode, **Then** unmatched files are listed with a "skipped" status and no filesystem changes occur on apply.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Automation-Friendly Regex Renames (Priority: P2)
|
||||
|
||||
As a DevOps engineer automating release artifact naming, I need deterministic exit codes, ledger metadata, and undo support for regex-based renames so CI pipelines remain auditable and reversible.
|
||||
|
||||
**Why this priority**: Ensures the new command can be safely adopted in automation without risking opaque failures.
|
||||
|
||||
**Independent Test**: Execute `renamer regex "^build_(\\d+)_(.*)$" "release-@1-@2" --yes --path ./fixtures`, verify exit code `0`, inspect `.renamer` for recorded pattern, replacement template, and affected files, then run `renamer undo` to restore originals.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a non-interactive run with `--yes`, **When** all matches succeed without conflicts, **Then** exit code is `0` and the ledger entry records the regex pattern, replacement template, and matching groups per file.
|
||||
2. **Given** a ledger entry produced by `renamer regex`, **When** `renamer undo` executes, **Then** filenames revert to their previous values even if the original files contained Unicode characters or were renamed by automation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Validate Patterns, Placeholders, and Conflicts (Priority: P3)
|
||||
|
||||
As a user experimenting with regex templates, I want clear validation and preview feedback for invalid patterns, missing capture groups, or resulting conflicts so I can adjust commands before committing changes.
|
||||
|
||||
**Why this priority**: Prevents accidental data loss and reduces trial-and-error when constructing regex commands.
|
||||
|
||||
**Independent Test**: Run `renamer regex "^(.*)$" "@2" --dry-run` and confirm the command exits with a descriptive error because placeholder `@2` is undefined; run a scenario where multiple files would map to the same name and ensure apply is blocked.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a replacement template referencing an undefined capture group, **When** the command runs, **Then** it exits non-zero with a message explaining the missing group and no files change.
|
||||
2. **Given** two files whose matches produce identical targets, **When** preview executes, **Then** conflicts are listed and apply refuses to proceed until resolved.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- How does the command behave when the regex pattern is invalid or cannot compile?
|
||||
- What is the outcome when no files match the pattern (preview and apply)?
|
||||
- How are nested or optional groups handled when placeholders reference non-matching groups?
|
||||
- What happens if the replacement template results in empty filenames or removes extensions?
|
||||
- How are directories or hidden files treated when scope flags include/exclude them?
|
||||
- What feedback is provided when resulting names differ only by case on case-insensitive filesystems?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: CLI MUST provide a `regex` subcommand that accepts a required regex pattern and replacement template arguments (e.g., `renamer regex <pattern> <template>`).
|
||||
- **FR-002**: Replacement templates MUST support numbered capture placeholders (`@1`, `@2`, etc.) corresponding to the regex groups; referencing undefined groups MUST produce a validation error.
|
||||
- **FR-003**: Pattern matching MUST operate on the filename stem by default while preserving extensions unless the template explicitly alters them.
|
||||
- **FR-004**: Preview MUST display original names, proposed names, and highlight skipped entries (unmatched, invalid template) prior to apply; apply MUST be blocked when conflicts or validation errors exist.
|
||||
- **FR-005**: Execution MUST respect shared scope flags (`--path`, `--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--dry-run`, `--yes`) consistent with other commands.
|
||||
- **FR-006**: Ledger entries MUST capture the regex pattern, replacement template, and affected files so undo can restore originals deterministically.
|
||||
- **FR-007**: The command MUST emit deterministic exit codes: `0` for successful apply or no matches, non-zero for validation failures or conflicts.
|
||||
- **FR-008**: Help output MUST document pattern syntax expectations, placeholder usage, escaping rules, and examples for both files and directories.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **RegexRequest**: Working directory, regex pattern, replacement template, scope flags, dry-run/apply settings.
|
||||
- **RegexSummary**: Counts of matched files, skipped entries, conflicts, warnings, and preview entries with status (`changed`, `skipped`, `no_change`).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users rename 500 files via regex (preview + apply) in under 2 minutes end-to-end.
|
||||
- **SC-002**: 95% of beta testers correctly apply a regex rename after reading `renamer regex --help` without additional guidance.
|
||||
- **SC-003**: Automated regression tests confirm regex rename + undo cycles leave the filesystem unchanged in 100% of scripted scenarios.
|
||||
- **SC-004**: Support tickets related to custom regex renaming scripts drop by 30% within the first release cycle post-launch.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2025-10-30
|
||||
- Q: How should literal @ characters be escaped in templates? → A: Use @@ to emit a literal @ while keeping numbered placeholders intact.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Regex evaluation uses the runtime’s built-in engine with RE2-compatible syntax; no backtracking-specific constructs (e.g., look-behind) are supported.
|
||||
- Matching applies to filename stems by default; users can reconstruct extensions via placeholders if required.
|
||||
- Unmatched files are skipped gracefully and reported in preview; apply exits `0` when all files are skipped.
|
||||
- Templates treat `@0` as the entire match if referenced; placeholders are case-sensitive and must be preceded by `@`. Use `@@` to emit a literal `@` character.
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
- Requires extending existing traversal, preview, and ledger infrastructure to accommodate regex replacement logic.
|
||||
- Complex regex patterns may produce unexpected duplicates; conflict detection must guard against accidental overwrites.
|
||||
- Users may expect advanced regex features (named groups, non-ASCII classes); documentation must clarify supported syntax to prevent confusion.
|
||||
159
specs/006-add-regex-command/tasks.md
Normal file
159
specs/006-add-regex-command/tasks.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Tasks: Regex Command for Pattern-Based Renaming
|
||||
|
||||
**Input**: Design documents from `/specs/006-add-regex-command/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Include targeted contract and integration coverage where scenarios demand automated verification.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare fixtures and support tooling required across all stories.
|
||||
|
||||
- [X] T001 Create regex test fixtures (`tests/fixtures/regex/`) with sample filenames covering digits, words, and Unicode cases.
|
||||
- [X] T002 [P] Scaffold `scripts/smoke-test-regex.sh` mirroring quickstart scenarios for preview/apply/undo.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish reusable package skeletons and command registration that all stories build upon.
|
||||
|
||||
- [X] T003 Create `internal/regex` package scaffolding (request.go, summary.go, doc.go) matching data-model entities.
|
||||
- [X] T004 [P] Register a stub `regex` Cobra command in `cmd/regex.go` with flag definitions aligned to shared scope options.
|
||||
|
||||
**Checkpoint**: Foundation ready – user story implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Rename Files Using Captured Groups (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Allow users to preview regex-based renames that substitute captured groups into templates while preserving extensions.
|
||||
|
||||
**Independent Test**: Run `renamer regex "^(\w+)-(\d+)" "@2_@1" --dry-run` against fixtures and verify preview outputs `123_alpha.log`, `456_beta.log` without modifying the filesystem.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T005 [P] [US1] Add preview contract test for capture groups in `tests/contract/regex_command_test.go`.
|
||||
- [X] T006 [P] [US1] Add integration preview flow test covering dry-run confirmation in `tests/integration/regex_flow_test.go`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Implement template parser handling `@n` and `@@` tokens in `internal/regex/template.go`.
|
||||
- [X] T008 [P] [US1] Implement regex engine applying capture groups to candidate names in `internal/regex/engine.go`.
|
||||
- [X] T009 [US1] Build preview planner producing `RegexSummary` entries in `internal/regex/preview.go`.
|
||||
- [X] T010 [US1] Wire Cobra command to preview/apply planner with scope options in `cmd/regex.go`.
|
||||
|
||||
**Checkpoint**: User Story 1 preview capability ready for validation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Automation-Friendly Regex Renames (Priority: P2)
|
||||
|
||||
**Goal**: Deliver deterministic apply flows with ledger metadata and undo support suitable for CI automation.
|
||||
|
||||
**Independent Test**: Execute `renamer regex "^build_(\d+)_(.*)$" "release-@1-@2" --yes --path ./tests/fixtures/regex` and verify exit code `0`, ledger metadata, and successful `renamer undo` restoration.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T011 [P] [US2] Add ledger contract test capturing pattern/template metadata in `tests/contract/regex_ledger_test.go`.
|
||||
- [X] T012 [P] [US2] Add integration undo flow test for regex entries in `tests/integration/regex_undo_test.go`.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T013 [P] [US2] Implement apply handler persisting ledger entries in `internal/regex/apply.go`.
|
||||
- [X] T014 [US2] Ensure `cmd/regex.go` honors `--yes` automation semantics and deterministic exit codes.
|
||||
- [X] T015 [US2] Extend undo recognition for regex batches in `internal/history/history.go` and shared output messaging.
|
||||
|
||||
**Checkpoint**: Automation-focused workflows (apply + undo) validated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Validate Patterns, Placeholders, and Conflicts (Priority: P3)
|
||||
|
||||
**Goal**: Provide clear feedback for invalid patterns or template conflicts to prevent destructive applies.
|
||||
|
||||
**Independent Test**: Run `renamer regex "^(.*)$" "@2" --dry-run` and confirm an error about undefined capture groups; attempt a rename producing duplicate targets and confirm apply is blocked.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T016 [P] [US3] Add validation contract tests for invalid patterns/placeholders in `tests/contract/regex_validation_test.go`.
|
||||
- [X] T017 [P] [US3] Add integration conflict test ensuring duplicate targets block apply in `tests/integration/regex_conflict_test.go`.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T018 [P] [US3] Implement validation for undefined groups and empty results in `internal/regex/validate.go`.
|
||||
- [X] T019 [US3] Extend conflict detection to flag duplicate or empty proposals in `internal/regex/preview.go`.
|
||||
- [X] T020 [US3] Enhance CLI error messaging and help examples in `cmd/regex.go`.
|
||||
|
||||
**Checkpoint**: Validation safeguards complete; regex command safe for experimentation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final documentation, tooling, and quality passes.
|
||||
|
||||
- [X] T021 [P] Update CLI documentation with regex command details in `docs/cli-flags.md`.
|
||||
- [X] T022 [P] Finalize `scripts/smoke-test-regex.sh` to exercise quickstart scenarios and ledger undo.
|
||||
- [X] T023 Run `gofmt` and `go test ./...` to verify formatting and regression coverage.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)** → prerequisite for foundational work.
|
||||
- **Foundational (Phase 2)** → must complete before User Stories begin.
|
||||
- **User Stories (Phase 3–5)** → execute sequentially by priority or in parallel once dependencies satisfied.
|
||||
- **Polish (Phase 6)** → runs after desired user stories ship.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1** depends on Foundational package scaffolding (T003–T004).
|
||||
- **US2** depends on US1 preview/apply wiring.
|
||||
- **US3** depends on US1 preview engine and US2 apply infrastructure to validate.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Tasks marked `[P]` operate on distinct files and can proceed concurrently once their prerequisites are met.
|
||||
- Different user stories can progress in parallel after their dependencies complete, provided shared files (`cmd/regex.go`, `internal/regex/preview.go`) are coordinated sequentially.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1–2 to establish scaffolding.
|
||||
2. Implement US1 preview workflow (T005–T010) and validate independently.
|
||||
3. Ship preview-only capability if automation support can follow later.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver US1 preview/apply basics.
|
||||
2. Layer US2 automation + ledger features.
|
||||
3. Add US3 validation/conflict safeguards.
|
||||
4. Conclude with polish tasks for docs, smoke script, and regression suite.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
- Developer A focuses on template/engine internals (T007–T008) while Developer B builds tests (T005–T006).
|
||||
- After US1, split automation work: ledger implementation (T013) and undo validation tests (T012) run concurrently.
|
||||
- Validation tasks (T016–T020) can be parallelized between CLI messaging and conflict handling once US2 merges.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep task granularity small enough for independent completion while documenting file paths for each change.
|
||||
- Tests should fail before implementation to confirm coverage.
|
||||
- Mark tasks complete (`[X]`) in this document as work progresses.
|
||||
48
testdata/regex/README.md
vendored
Normal file
48
testdata/regex/README.md
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Regex Command Scenario Fixtures
|
||||
|
||||
This directory provides ready-to-run datasets for validating the `renamer regex`
|
||||
command against realistic workflows. Copy a scenario to a temporary directory
|
||||
before mutating files so the repository remains clean:
|
||||
|
||||
```bash
|
||||
TMP_DIR=$(mktemp -d)
|
||||
cp -R testdata/regex/capture-groups/* "$TMP_DIR/"
|
||||
go run ./main.go regex '^(\w+)-(\d+)$' '@2_@1' --dry-run --path "$TMP_DIR"
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
regex/
|
||||
├── capture-groups/
|
||||
│ ├── alpha-123.log
|
||||
│ ├── beta-456.log
|
||||
│ ├── gamma-789.log
|
||||
│ └── notes.txt
|
||||
├── automation/
|
||||
│ ├── build_101_release.tar.gz
|
||||
│ ├── build_102_hotfix.tar.gz
|
||||
│ ├── build_103_varchive/
|
||||
│ │ └── placeholder.txt
|
||||
│ └── feature-demo_2025-10-01.txt
|
||||
└── validation/
|
||||
├── duplicate-a-01.txt
|
||||
├── duplicate-b-01.txt
|
||||
└── group-miss.txt
|
||||
```
|
||||
|
||||
### Scenario Highlights
|
||||
|
||||
- **capture-groups** – Mirrors the quickstart preview example. Run with
|
||||
`renamer regex '^(\w+)-(\d+)$' '@2_@1' --dry-run` to verify captured groups
|
||||
swap safely while non-matching files remain untouched.
|
||||
- **automation** – Supports end-to-end `--yes` applies and undo. Use
|
||||
`renamer regex '^(feature)-(.*)$' '@2-@1' --yes` to exercise ledger writes
|
||||
and `renamer regex '^(build)_(\d+)_v(.*)$' 'release-@2-@1-v@3'` to combine
|
||||
extension filtering with directory handling.
|
||||
- **validation** – Surfaces error cases. Applying
|
||||
`renamer regex '^(duplicate)-(.*)-(\d+)$' '@1-@3' --yes` should report a
|
||||
duplicate-target conflict, while referencing `@2` with
|
||||
`renamer regex '^(.+)$' '@2' --dry-run` raises an undefined group error.
|
||||
|
||||
Extend these fixtures as new edge cases or regression scenarios arise.
|
||||
1
testdata/regex/automation/build_101_release.tar.gz
vendored
Normal file
1
testdata/regex/automation/build_101_release.tar.gz
vendored
Normal file
@@ -0,0 +1 @@
|
||||
binary placeholder
|
||||
1
testdata/regex/automation/build_102_hotfix.tar.gz
vendored
Normal file
1
testdata/regex/automation/build_102_hotfix.tar.gz
vendored
Normal file
@@ -0,0 +1 @@
|
||||
binary placeholder
|
||||
1
testdata/regex/automation/build_103_varchive/placeholder.txt
vendored
Normal file
1
testdata/regex/automation/build_103_varchive/placeholder.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
archive placeholder
|
||||
1
testdata/regex/automation/feature-demo_2025-10-01.txt
vendored
Normal file
1
testdata/regex/automation/feature-demo_2025-10-01.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
release notes
|
||||
1
testdata/regex/capture-groups/alpha-123.log
vendored
Normal file
1
testdata/regex/capture-groups/alpha-123.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
preview sample
|
||||
1
testdata/regex/capture-groups/beta-456.log
vendored
Normal file
1
testdata/regex/capture-groups/beta-456.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
preview sample
|
||||
1
testdata/regex/capture-groups/gamma-789.log
vendored
Normal file
1
testdata/regex/capture-groups/gamma-789.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
preview sample
|
||||
1
testdata/regex/capture-groups/notes.txt
vendored
Normal file
1
testdata/regex/capture-groups/notes.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
notes unaffected
|
||||
1
testdata/regex/validation/duplicate-a-01.txt
vendored
Normal file
1
testdata/regex/validation/duplicate-a-01.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
conflict sample A
|
||||
1
testdata/regex/validation/duplicate-b-01.txt
vendored
Normal file
1
testdata/regex/validation/duplicate-b-01.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
conflict sample B
|
||||
1
testdata/regex/validation/group-miss.txt
vendored
Normal file
1
testdata/regex/validation/group-miss.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
missing capture example
|
||||
116
tests/contract/regex_command_test.go
Normal file
116
tests/contract/regex_command_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/regex"
|
||||
)
|
||||
|
||||
func TestRegexPreviewUsesCaptureGroups(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
copyRegexFixture(t, "baseline", tmp)
|
||||
|
||||
req := regex.NewRequest(tmp)
|
||||
req.Pattern = "^(\\w+)-(\\d+)"
|
||||
req.Template = "@2_@1"
|
||||
req.IncludeDirectories = false
|
||||
req.Recursive = false
|
||||
|
||||
var buf bytes.Buffer
|
||||
summary, planned, err := regex.Preview(context.Background(), req, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("regex preview returned error: %v", err)
|
||||
}
|
||||
|
||||
if summary.TotalCandidates != len(summary.Entries) {
|
||||
t.Fatalf("expected summary entries to equal candidates: %d vs %d", summary.TotalCandidates, len(summary.Entries))
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"alpha-123.log": "123_alpha.log",
|
||||
"beta-456.log": "456_beta.log",
|
||||
"gamma-789.log": "789_gamma.log",
|
||||
}
|
||||
|
||||
if summary.Changed != len(planned) {
|
||||
t.Fatalf("expected changed count %d to equal plan length %d", summary.Changed, len(planned))
|
||||
}
|
||||
|
||||
for _, entry := range summary.Entries {
|
||||
target, ok := expected[filepath.Base(entry.OriginalPath)]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected candidate in preview: %s", entry.OriginalPath)
|
||||
}
|
||||
if entry.ProposedPath != filepath.Join(filepath.Dir(entry.OriginalPath), target) {
|
||||
t.Fatalf("expected proposed path %s, got %s", target, entry.ProposedPath)
|
||||
}
|
||||
if entry.Status != regex.EntryChanged {
|
||||
t.Fatalf("expected entry status 'changed', got %s", entry.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if len(planned) != len(expected) {
|
||||
t.Fatalf("expected plan length %d, got %d", len(expected), len(planned))
|
||||
}
|
||||
|
||||
for _, plan := range planned {
|
||||
base := filepath.Base(plan.SourceRelative)
|
||||
target, ok := expected[base]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected plan entry: %s", base)
|
||||
}
|
||||
if plan.TargetRelative != filepath.Join(filepath.Dir(plan.SourceRelative), target) {
|
||||
t.Fatalf("expected planned target %s, got %s", target, plan.TargetRelative)
|
||||
}
|
||||
if len(plan.MatchGroups) != 2 {
|
||||
t.Fatalf("expected 2 match groups in plan, got %d", len(plan.MatchGroups))
|
||||
}
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
for _, target := range expected {
|
||||
if !bytes.Contains([]byte(output), []byte(target)) {
|
||||
t.Fatalf("expected preview output to contain %s, got %s", target, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyRegexFixture(t *testing.T, name, dest string) {
|
||||
t.Helper()
|
||||
src := filepath.Join("..", "fixtures", "regex", name)
|
||||
if err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dest, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetPath, content, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("copy fixture: %v", err)
|
||||
}
|
||||
}
|
||||
62
tests/contract/regex_ledger_test.go
Normal file
62
tests/contract/regex_ledger_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
"github.com/rogeecn/renamer/internal/history"
|
||||
)
|
||||
|
||||
func TestRegexCommandLedgerMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
copyRegexFixture(t, "mixed", tmp)
|
||||
|
||||
cmd := renamercmd.NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"regex", "^build_(\\d+)_(.*)$", "release-@1-@2", "--yes", "--path", tmp})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("regex apply command failed: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
|
||||
ledgerPath := filepath.Join(tmp, ".renamer")
|
||||
data, err := os.ReadFile(ledgerPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ledger: %v", err)
|
||||
}
|
||||
|
||||
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
||||
if len(lines) == 0 {
|
||||
t.Fatalf("expected ledger entries written")
|
||||
}
|
||||
|
||||
var entry history.Entry
|
||||
if err := json.Unmarshal(lines[len(lines)-1], &entry); err != nil {
|
||||
t.Fatalf("decode ledger entry: %v", err)
|
||||
}
|
||||
|
||||
if entry.Command != "regex" {
|
||||
t.Fatalf("expected regex command recorded, got %q", entry.Command)
|
||||
}
|
||||
|
||||
if entry.Metadata["pattern"] != "^build_(\\d+)_(.*)$" {
|
||||
t.Fatalf("unexpected pattern metadata: %#v", entry.Metadata["pattern"])
|
||||
}
|
||||
if entry.Metadata["template"] != "release-@1-@2" {
|
||||
t.Fatalf("unexpected template metadata: %#v", entry.Metadata["template"])
|
||||
}
|
||||
|
||||
if toFloat(entry.Metadata["matched"]) != 2 || toFloat(entry.Metadata["changed"]) != 2 {
|
||||
t.Fatalf("unexpected match/change counts: %#v", entry.Metadata)
|
||||
}
|
||||
|
||||
if len(entry.Operations) != 2 {
|
||||
t.Fatalf("expected 2 operations, got %d", len(entry.Operations))
|
||||
}
|
||||
}
|
||||
45
tests/contract/regex_validation_test.go
Normal file
45
tests/contract/regex_validation_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/regex"
|
||||
)
|
||||
|
||||
func TestRegexTemplateRejectsUndefinedGroup(t *testing.T) {
|
||||
req := regex.NewRequest(t.TempDir())
|
||||
req.Pattern = "^(\\w+)-(\\d+)"
|
||||
req.Template = "@3"
|
||||
|
||||
_, _, err := regex.Preview(context.Background(), req, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for undefined capture group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexPreviewHandlesInvalidPattern(t *testing.T) {
|
||||
req := regex.NewRequest(t.TempDir())
|
||||
req.Pattern = "(([" // invalid pattern
|
||||
req.Template = "@1"
|
||||
|
||||
_, _, err := regex.Preview(context.Background(), req, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexPreviewSkipsUnmatchedOptionalGroup(t *testing.T) {
|
||||
req := regex.NewRequest(t.TempDir())
|
||||
req.Pattern = "^(\\w+)(?:-(\\d+))?"
|
||||
req.Template = "@1_@2"
|
||||
|
||||
summary, _, err := regex.Preview(context.Background(), req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if summary.TotalCandidates != 0 {
|
||||
t.Fatalf("expected no candidates without files, got %d", summary.TotalCandidates)
|
||||
}
|
||||
}
|
||||
14
tests/fixtures/regex/README.md
vendored
Normal file
14
tests/fixtures/regex/README.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Regex Command Fixtures
|
||||
|
||||
These fixtures support contract and integration testing for the `renamer regex` command. Each
|
||||
subdirectory contains representative filenames used across preview, apply, conflict, and
|
||||
validation scenarios.
|
||||
|
||||
- `baseline/` — ASCII word + digit combinations (e.g., `alpha-123.log`) used to validate basic
|
||||
capture group substitution.
|
||||
- `unicode/` — Multilingual filenames to verify RE2 Unicode handling and ledger persistence.
|
||||
- `mixed/` — Build-style artifacts with underscores/dashes for automation-style rename flows.
|
||||
- `case-fold/` — Differing only by case to simulate case-insensitive duplicate conflicts.
|
||||
|
||||
Tests should copy these directories to temporary working paths before mutation to keep fixtures
|
||||
idempotent.
|
||||
1
tests/fixtures/regex/baseline/alpha-123.log
vendored
Normal file
1
tests/fixtures/regex/baseline/alpha-123.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
baseline fixture alpha-123
|
||||
1
tests/fixtures/regex/baseline/beta-456.log
vendored
Normal file
1
tests/fixtures/regex/baseline/beta-456.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
baseline fixture beta-456
|
||||
1
tests/fixtures/regex/baseline/gamma-789.log
vendored
Normal file
1
tests/fixtures/regex/baseline/gamma-789.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
test fixture gamma-789
|
||||
1
tests/fixtures/regex/case-fold/SAMPLE.txt
vendored
Normal file
1
tests/fixtures/regex/case-fold/SAMPLE.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
case fold fixture upper sample
|
||||
1
tests/fixtures/regex/case-fold/Sample.txt
vendored
Normal file
1
tests/fixtures/regex/case-fold/Sample.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
case fold fixture upper-lower sample
|
||||
1
tests/fixtures/regex/case-fold/sample.txt
vendored
Normal file
1
tests/fixtures/regex/case-fold/sample.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
case fold fixture lower sample
|
||||
1
tests/fixtures/regex/mixed/build_101_release.tar.gz
vendored
Normal file
1
tests/fixtures/regex/mixed/build_101_release.tar.gz
vendored
Normal file
@@ -0,0 +1 @@
|
||||
placeholder archive fixture 101
|
||||
1
tests/fixtures/regex/mixed/build_102_hotfix.tar.gz
vendored
Normal file
1
tests/fixtures/regex/mixed/build_102_hotfix.tar.gz
vendored
Normal file
@@ -0,0 +1 @@
|
||||
placeholder archive fixture 102
|
||||
1
tests/fixtures/regex/mixed/feature-demo_2025-10-01.txt
vendored
Normal file
1
tests/fixtures/regex/mixed/feature-demo_2025-10-01.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
feature demo placeholder file
|
||||
1
tests/fixtures/regex/unicode/résumé-2025.md
vendored
Normal file
1
tests/fixtures/regex/unicode/résumé-2025.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Markdown résumé placeholder for Unicode accent coverage.
|
||||
1
tests/fixtures/regex/unicode/项目-报告-01.txt
vendored
Normal file
1
tests/fixtures/regex/unicode/项目-报告-01.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
示例文件,用于验证Unicode处理。
|
||||
8
tests/integration/helpers_test.go
Normal file
8
tests/integration/helpers_test.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package integration
|
||||
|
||||
import "os"
|
||||
|
||||
func fileExistsTestHelper(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
26
tests/integration/regex_conflict_test.go
Normal file
26
tests/integration/regex_conflict_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
)
|
||||
|
||||
func TestRegexApplyBlocksConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
copyRegexFixtureIntegration(t, "case-fold", tmp)
|
||||
|
||||
cmd := renamercmd.NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"regex", "^(.*)$", "conflict", "--yes", "--path", tmp})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when conflicts are present")
|
||||
}
|
||||
}
|
||||
74
tests/integration/regex_flow_test.go
Normal file
74
tests/integration/regex_flow_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
)
|
||||
|
||||
func TestRegexPreviewCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
copyRegexFixtureIntegration(t, "baseline", tmp)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := renamercmd.NewRootCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"regex", "^(\\w+)-(\\d+)", "@2_@1", "--dry-run", "--path", tmp})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("regex preview command failed: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"alpha-123.log -> 123_alpha.log",
|
||||
"beta-456.log -> 456_beta.log",
|
||||
"gamma-789.log -> 789_gamma.log",
|
||||
"Preview complete: 3 matched, 3 changed, 0 skipped.",
|
||||
"Preview complete. Re-run with --yes to apply.",
|
||||
}
|
||||
|
||||
for _, token := range expected {
|
||||
if !bytes.Contains(out.Bytes(), []byte(token)) {
|
||||
t.Fatalf("expected output to contain %q, got: %s", token, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyRegexFixtureIntegration(t *testing.T, name, dest string) {
|
||||
t.Helper()
|
||||
src := filepath.Join("..", "fixtures", "regex", name)
|
||||
if err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dest, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(targetPath, content, 0o644)
|
||||
}); err != nil {
|
||||
t.Fatalf("copy regex fixture: %v", err)
|
||||
}
|
||||
}
|
||||
42
tests/integration/regex_undo_test.go
Normal file
42
tests/integration/regex_undo_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
renamercmd "github.com/rogeecn/renamer/cmd"
|
||||
)
|
||||
|
||||
func TestRegexUndoRestoresAutomationRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
copyRegexFixtureIntegration(t, "mixed", tmp)
|
||||
|
||||
apply := renamercmd.NewRootCommand()
|
||||
var applyOut bytes.Buffer
|
||||
apply.SetOut(&applyOut)
|
||||
apply.SetErr(&applyOut)
|
||||
apply.SetArgs([]string{"regex", "^build_(\\d+)_(.*)$", "release-@1-@2", "--yes", "--path", tmp})
|
||||
if err := apply.Execute(); err != nil {
|
||||
t.Fatalf("regex apply failed: %v\noutput: %s", err, applyOut.String())
|
||||
}
|
||||
|
||||
if !fileExistsTestHelper(filepath.Join(tmp, "release-101-release.tar.gz")) || !fileExistsTestHelper(filepath.Join(tmp, "release-102-hotfix.tar.gz")) {
|
||||
t.Fatalf("expected renamed files after apply")
|
||||
}
|
||||
|
||||
undo := renamercmd.NewRootCommand()
|
||||
var undoOut bytes.Buffer
|
||||
undo.SetOut(&undoOut)
|
||||
undo.SetErr(&undoOut)
|
||||
undo.SetArgs([]string{"undo", "--path", tmp})
|
||||
if err := undo.Execute(); err != nil {
|
||||
t.Fatalf("undo failed: %v\noutput: %s", err, undoOut.String())
|
||||
}
|
||||
|
||||
if !fileExistsTestHelper(filepath.Join(tmp, "build_101_release.tar.gz")) || !fileExistsTestHelper(filepath.Join(tmp, "build_102_hotfix.tar.gz")) {
|
||||
t.Fatalf("expected originals restored after undo")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -33,7 +32,7 @@ func TestRemoveCommandAutomationUndo(t *testing.T) {
|
||||
t.Fatalf("apply failed: %v\noutput: %s", err, applyOut.String())
|
||||
}
|
||||
|
||||
if !fileExists(filepath.Join(tmp, "alpha.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta.txt")) {
|
||||
if !fileExistsTestHelper(filepath.Join(tmp, "alpha.txt")) || !fileExistsTestHelper(filepath.Join(tmp, "nested", "beta.txt")) {
|
||||
t.Fatalf("expected files renamed after apply")
|
||||
}
|
||||
|
||||
@@ -46,12 +45,7 @@ func TestRemoveCommandAutomationUndo(t *testing.T) {
|
||||
t.Fatalf("undo failed: %v\noutput: %s", err, undoOut.String())
|
||||
}
|
||||
|
||||
if !fileExists(filepath.Join(tmp, "alpha copy.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta draft.txt")) {
|
||||
if !fileExistsTestHelper(filepath.Join(tmp, "alpha copy.txt")) || !fileExistsTestHelper(filepath.Join(tmp, "nested", "beta draft.txt")) {
|
||||
t.Fatalf("expected originals restored after undo")
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user