Add regex command implementation

This commit is contained in:
Rogee
2025-10-31 10:12:02 +08:00
parent a0d7084c28
commit d436970848
55 changed files with 2115 additions and 9 deletions

View File

@@ -9,6 +9,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-29
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
- Local filesystem + `.renamer` ledger files (004-extension-rename)
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages (005-add-insert-command)
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages (006-add-regex-command)
- Local filesystem and `.renamer` ledger files (006-add-regex-command)
## Project Structure
@@ -41,9 +43,9 @@ tests/
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
## Recent Changes
- 006-add-regex-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages
- 005-add-insert-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages
- 004-extension-rename: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages
- 003-add-remove-command: Added sequential `renamer remove` subcommand, automation-friendly ledger metadata, and CLI warnings for duplicates/empty results
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

102
cmd/regex.go Normal file
View 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())
}

View File

@@ -49,6 +49,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewRemoveCommand())
cmd.AddCommand(NewExtensionCommand())
cmd.AddCommand(newInsertCommand())
cmd.AddCommand(newRegexCommand())
cmd.AddCommand(newUndoCommand())
return cmd

View File

@@ -48,6 +48,13 @@ func newUndoCommand() *cobra.Command {
fmt.Fprintf(out, "Inserted text %q removed\n", insertText)
}
}
case "regex":
if pattern, ok := entry.Metadata["pattern"].(string); ok && pattern != "" {
fmt.Fprintf(out, "Reverted regex pattern %q\n", pattern)
}
if template, ok := entry.Metadata["template"].(string); ok && template != "" {
fmt.Fprintf(out, "Template restored to %q\n", template)
}
}
}

View File

@@ -15,6 +15,23 @@ filesystem. Use these options at the root command level so they apply to all sub
| `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. |
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
## Regex Command Quick Reference
```bash
renamer regex <pattern> <template> [flags]
```
- Patterns compile with Gos RE2 engine and are matched against filename stems; invalid expressions fail fast with helpful errors.
- Templates support numbered placeholders (`@0`, `@1`, …) along with escaped `@@` for literal at-signs; undefined captures block the run.
- Preview mode (`--dry-run`, default) renders the rename plan with change/skipped/conflict statuses; apply with `--yes` writes a ledger entry for undo.
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) control candidate discovery just like other commands, and conflicts or empty targets exit non-zero.
### Usage Examples
- Preview captured group swapping: `renamer regex "^(\w+)-(\d+)" "@2_@1" --dry-run --path ./samples`
- Limit by extensions and directories: `renamer regex '^(build)_(\d+)_v(.*)$' 'release-@2-@1-v@3' --extensions .zip|.tar.gz --include-dirs --recursive`
- Automation-friendly apply with undo: `renamer regex '^(feature)-(.*)$' '@2-@1' --yes --path ./staging && renamer undo --path ./staging`
## Insert Command Quick Reference
```bash

92
internal/regex/apply.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

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

View 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
View 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."

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

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

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

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

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

View 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**: Gos 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.

View 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 runtimes 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.

View 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 35)** → 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 (T003T004).
- **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 12 to establish scaffolding.
2. Implement US1 preview workflow (T005T010) 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 (T007T008) while Developer B builds tests (T005T006).
- After US1, split automation work: ledger implementation (T013) and undo validation tests (T012) run concurrently.
- Validation tasks (T016T020) 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
View 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.

View File

@@ -0,0 +1 @@
binary placeholder

View File

@@ -0,0 +1 @@
binary placeholder

View File

@@ -0,0 +1 @@
archive placeholder

View File

@@ -0,0 +1 @@
release notes

View File

@@ -0,0 +1 @@
preview sample

View File

@@ -0,0 +1 @@
preview sample

View File

@@ -0,0 +1 @@
preview sample

View File

@@ -0,0 +1 @@
notes unaffected

View File

@@ -0,0 +1 @@
conflict sample A

View File

@@ -0,0 +1 @@
conflict sample B

View File

@@ -0,0 +1 @@
missing capture example

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

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

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

View File

@@ -0,0 +1 @@
baseline fixture alpha-123

View File

@@ -0,0 +1 @@
baseline fixture beta-456

View File

@@ -0,0 +1 @@
test fixture gamma-789

View File

@@ -0,0 +1 @@
case fold fixture upper sample

View File

@@ -0,0 +1 @@
case fold fixture upper-lower sample

View File

@@ -0,0 +1 @@
case fold fixture lower sample

View File

@@ -0,0 +1 @@
placeholder archive fixture 101

View File

@@ -0,0 +1 @@
placeholder archive fixture 102

View File

@@ -0,0 +1 @@
feature demo placeholder file

View File

@@ -0,0 +1 @@
Markdown résumé placeholder for Unicode accent coverage.

View File

@@ -0,0 +1 @@
示例文件用于验证Unicode处理。

View File

@@ -0,0 +1,8 @@
package integration
import "os"
func fileExistsTestHelper(path string) bool {
_, err := os.Stat(path)
return err == nil
}

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

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

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

View File

@@ -2,7 +2,6 @@ package integration
import (
"bytes"
"os"
"path/filepath"
"testing"
@@ -33,7 +32,7 @@ func TestRemoveCommandAutomationUndo(t *testing.T) {
t.Fatalf("apply failed: %v\noutput: %s", err, applyOut.String())
}
if !fileExists(filepath.Join(tmp, "alpha.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta.txt")) {
if !fileExistsTestHelper(filepath.Join(tmp, "alpha.txt")) || !fileExistsTestHelper(filepath.Join(tmp, "nested", "beta.txt")) {
t.Fatalf("expected files renamed after apply")
}
@@ -46,12 +45,7 @@ func TestRemoveCommandAutomationUndo(t *testing.T) {
t.Fatalf("undo failed: %v\noutput: %s", err, undoOut.String())
}
if !fileExists(filepath.Join(tmp, "alpha copy.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta draft.txt")) {
if !fileExistsTestHelper(filepath.Join(tmp, "alpha copy.txt")) || !fileExistsTestHelper(filepath.Join(tmp, "nested", "beta draft.txt")) {
t.Fatalf("expected originals restored after undo")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}