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)
|
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
|
||||||
- Local filesystem + `.renamer` ledger files (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`, 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
|
## Project Structure
|
||||||
|
|
||||||
@@ -41,9 +43,9 @@ tests/
|
|||||||
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
||||||
|
|
||||||
## Recent Changes
|
## 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
|
- 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
|
- 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 START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- 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(NewRemoveCommand())
|
||||||
cmd.AddCommand(NewExtensionCommand())
|
cmd.AddCommand(NewExtensionCommand())
|
||||||
cmd.AddCommand(newInsertCommand())
|
cmd.AddCommand(newInsertCommand())
|
||||||
|
cmd.AddCommand(newRegexCommand())
|
||||||
cmd.AddCommand(newUndoCommand())
|
cmd.AddCommand(newUndoCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ func newUndoCommand() *cobra.Command {
|
|||||||
fmt.Fprintf(out, "Inserted text %q removed\n", insertText)
|
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. |
|
| `--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`. |
|
| `--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
|
## Insert Command Quick Reference
|
||||||
|
|
||||||
```bash
|
```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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ func TestRemoveCommandAutomationUndo(t *testing.T) {
|
|||||||
t.Fatalf("apply failed: %v\noutput: %s", err, applyOut.String())
|
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")
|
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())
|
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")
|
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