commit bf46c56b36c14999c459fabc3a197e5992551ba9 Author: Glauber Ferreira Date: Mon Aug 31 15:08:33 2026 -0300 primeiro commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a02cac --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Build output +/bin/ +/aehenamer +*.exe +*.test + +# Test, coverage, and profiling artifacts +coverage.out +coverage.html +*.prof +*.pprof + +# Editor and operating-system files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4530611 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# agents.md + +## Code Style + +- Follow standard Go conventions: `gofmt`, `goimports`, and `golangci-lint`. +- Keep packages small and focused. Name them short, lowercase, no underscores. +- Avoid unnecessary abstractions — prefer simple, readable code over clever code. +- Define interfaces at the point of use, not the point of implementation. Keep them small. + +## Error Handling + +- Always handle errors explicitly — never ignore them with `_`. +- Wrap errors with context: `fmt.Errorf("doing X: %w", err)`. +- Use `errors.Is` / `errors.As` for matching, not string comparison. +- Reserve `panic` for unrecoverable programmer mistakes, not runtime errors. + +## Concurrency + +- Pass `context.Context` as the first argument to any blocking or I/O function. +- Never store a `Context` in a struct. Never use `context.Background()` deep in business logic. +- Always manage goroutine lifecycles — use `errgroup`, `sync.WaitGroup`, or channels. +- Protect shared state with mutexes or by confining it to a single goroutine. + +## Testing + +- Write table-driven tests using `t.Run` for clarity and coverage. +- Use the standard `testing` package. Reach for `testify` only when it genuinely reduces noise. +- Prefer real implementations over mocks where feasible (e.g., `httptest`, in-memory stores). +- Keep tests close to the code they test; use `_test` packages for black-box testing. +- Benchmark with `testing.B` before optimizing anything. + +## Dependencies & Modules + +- Keep `go.mod` tidy — run `go mod tidy` before committing. +- Minimize external dependencies; prefer the standard library. +- Pin versions explicitly and review dependency updates carefully. + +## General + +- Measure before optimizing. Use `pprof` for profiling. +- Log at boundaries (entry/exit of services), not inside every function. +- Prefer explicit over implicit — avoid `init()` and global state. + +## API Design + +- Propagate `context.Context` through every layer of an API — from handler to store. +- Never drop context mid-call or substitute it with `context.Background()` silently. +- Honor context cancellation and deadlines in all I/O and long-running operations. +- Design APIs so callers can always pass a context; if a function does I/O, it takes a `ctx`. + +## Documentation + +- Document every exported type, function, method, and constant — no exceptions. +- Follow Go doc conventions: start the comment with the name of the thing being documented. +- Package-level comments should explain purpose and usage, not implementation details. +- Include examples (`Example*` functions) for non-trivial exported APIs. + +```go +// UserStore retrieves and persists user records. +type UserStore interface { ... } + +// GetByID returns the user with the given ID, or ErrNotFound if absent. +func (s *store) GetByID(ctx context.Context, id string) (*User, error) { ... } +``` + +## Testing (expanded) + +- Write tests as you write code — not after, not when asked. Tests are not optional. +- Every exported function and critical internal path must have at least one test. +- Cover edge cases, error paths, and boundary conditions — not just the happy path. +- Use `t.Helper()` in shared test utilities to keep failure output pointing at the call site. +- Integration tests live in `test/` or behind a build tag (e.g., `//go:build integration`). + +## Microservice Readiness + +- Expose `/healthz` (liveness) and `/readyz` (readiness) endpoints on a dedicated port. +- Liveness indicates the process is alive; readiness indicates it can serve traffic. +- Handle `SIGTERM` and `SIGINT` with graceful shutdown: stop accepting new requests, finish in-flight work, release resources, then exit. +- Set explicit timeouts on `http.Server`: `ReadTimeout`, `WriteTimeout`, `IdleTimeout`. +- Use `errgroup` with a context to coordinate shutdown across goroutines cleanly. + +```go +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) +defer stop() +``` + +## Security + +- Never log secrets, tokens, passwords, or PII — scrub them before logging. +- Validate and sanitize all external input; never trust data from outside the process boundary. +- Use `crypto/rand` for all randomness that touches security; never `math/rand`. +- Enforce TLS for all external communication; keep cipher suites and TLS version current. +- Follow the principle of least privilege for service accounts, DB roles, and IAM policies. +- Scan dependencies for known CVEs regularly (e.g., `govulncheck`). +- Set strict timeouts on all outbound HTTP clients — never use the default zero-timeout client. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f21fe8 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +GO ?= go +BINARY ?= aehenamer +BUILD_DIR ?= bin +BUILD_FLAGS ?= -trimpath + +.PHONY: all build test clean + +all: build + +build: + mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 $(GO) build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) . + +test: + CGO_ENABLED=0 $(GO) test ./... + +clean: + rm -f $(BUILD_DIR)/$(BINARY) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b1de320 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module aehenamer + +go 1.24.4 + +require ( + github.com/gdamore/tcell/v2 v2.13.10 + github.com/rivo/tview v0.42.0 +) + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6dadf4a --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..b847dbc --- /dev/null +++ b/internal/files/files.go @@ -0,0 +1,205 @@ +// Package files lists the files a rename operates on and applies a finished +// rename plan to disk. +package files + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Entry pairs a file currently on disk with the name proposed for it. +type Entry struct { + Original string + Proposed string +} + +// pending is one file staged under a temporary name during a rename. +type pending struct { + original string + temp string + proposed string +} + +// Changed reports whether the entry would actually be renamed. +func (e Entry) Changed() bool { return e.Original != e.Proposed } + +// List returns the names of the files directly inside dir, sorted by name. +// Subdirectories are ignored: the listing is one level deep and never +// recursive. The returned names are base names, not paths. +func List(ctx context.Context, dir string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading directory %s: %w", dir, err) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return nil, err + } + if entry.IsDir() { + continue + } + names = append(names, entry.Name()) + } + return names, nil +} + +// Conflicts checks a rename plan and returns, keyed by entry index, the reason +// each problematic entry cannot be renamed. An empty result means the plan is +// safe to apply. dir is inspected for filesystem entries that are not part of +// the plan but would block a destination; a directory that cannot be read +// simply contributes no such conflicts. +func Conflicts(ctx context.Context, dir string, entries []Entry) map[int]string { + problems := make(map[int]string) + + targets := make(map[string][]int, len(entries)) + for i, e := range entries { + switch { + case strings.TrimSpace(e.Proposed) == "": + problems[i] = "new name is empty" + case e.Proposed == "." || e.Proposed == "..": + problems[i] = "new name is a directory reference" + case strings.ContainsRune(e.Proposed, os.PathSeparator), strings.ContainsRune(e.Proposed, '/'): + problems[i] = "new name contains a path separator" + } + targets[e.Proposed] = append(targets[e.Proposed], i) + } + + for _, indexes := range targets { + if len(indexes) < 2 { + continue + } + for _, i := range indexes { + if _, taken := problems[i]; !taken { + problems[i] = "several files would get this name" + } + } + } + + planned := make(map[string]bool, len(entries)) + for _, e := range entries { + planned[e.Original] = true + } + existing, err := os.ReadDir(dir) + if err != nil { + return problems + } + onDisk := make(map[string]bool, len(existing)) + for _, entry := range existing { + if err := ctx.Err(); err != nil { + return problems + } + onDisk[entry.Name()] = true + } + for i, e := range entries { + if _, taken := problems[i]; taken || !e.Changed() { + continue + } + if onDisk[e.Proposed] && !planned[e.Proposed] { + problems[i] = "a filesystem entry with this name already exists" + } + } + return problems +} + +// Rename applies the plan inside dir and returns the number of files renamed. +// Entries whose name is unchanged are skipped. Renaming happens in two passes +// through temporary names so that files can swap or rotate names, and a +// failure in the first pass is rolled back before the error is returned. +func Rename(ctx context.Context, dir string, entries []Entry) (int, error) { + problems := Conflicts(ctx, dir, entries) + for i, e := range entries { + if reason, bad := problems[i]; bad { + return 0, fmt.Errorf("cannot rename %s: %s", e.Original, reason) + } + } + + var staged []pending + + rollbackStaged := func() { + for i := len(staged) - 1; i >= 0; i-- { + _ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original)) + } + } + + for i, e := range entries { + if !e.Changed() { + continue + } + if err := ctx.Err(); err != nil { + rollbackStaged() + return 0, err + } + temp, err := tempName(dir, i, e.Original) + if err != nil { + rollbackStaged() + return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err) + } + if err := os.Rename(filepath.Join(dir, e.Original), filepath.Join(dir, temp)); err != nil { + rollbackStaged() + return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err) + } + staged = append(staged, pending{original: e.Original, temp: temp, proposed: e.Proposed}) + } + + renamed := 0 + for i, p := range staged { + if err := ctx.Err(); err != nil { + rollbackCommitted(dir, staged, i) + return renamed, err + } + if err := os.Rename(filepath.Join(dir, p.temp), filepath.Join(dir, p.proposed)); err != nil { + rollbackCommitted(dir, staged, i) + return renamed, fmt.Errorf("renaming to %s: %w", p.proposed, err) + } + renamed++ + } + return renamed, nil +} + +// maxNameLen is the longest file name accepted by common file systems. +const maxNameLen = 255 + +// rollbackCommitted moves completed destinations back to temporary names, +// then restores every staged file to its original name. The first phase is +// necessary for swaps, where an original name may currently be a destination. +func rollbackCommitted(dir string, staged []pending, committed int) { + for i := committed - 1; i >= 0; i-- { + _ = os.Rename(filepath.Join(dir, staged[i].proposed), filepath.Join(dir, staged[i].temp)) + } + for i := len(staged) - 1; i >= 0; i-- { + _ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original)) + } +} + +// tempName returns an unused, hard-to-guess intermediate name, short enough +// to stay valid on common file systems. +func tempName(dir string, i int, original string) (string, error) { + var random [8]byte + if _, err := rand.Read(random[:]); err != nil { + return "", fmt.Errorf("generating temporary name: %w", err) + } + prefix := fmt.Sprintf(".aehenamer-%d-%s-", i, hex.EncodeToString(random[:])) + remaining := maxNameLen - len(prefix) + if remaining < 0 { + remaining = 0 + } + if len(original) > remaining { + original = original[:remaining] + } + name := prefix + original + if _, err := os.Lstat(filepath.Join(dir, name)); err == nil { + return "", fmt.Errorf("temporary name %q already exists", name) + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("checking temporary name %q: %w", name, err) + } + return name, nil +} diff --git a/internal/files/files_test.go b/internal/files/files_test.go new file mode 100644 index 0000000..acc3edb --- /dev/null +++ b/internal/files/files_test.go @@ -0,0 +1,299 @@ +package files_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "aehenamer/internal/files" +) + +// makeDir creates a temporary directory containing the given files. +func makeDir(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600); err != nil { + t.Fatalf("creating %s: %v", name, err) + } + } + return dir +} + +// namesOn returns the sorted file names currently in dir. +func namesOn(t *testing.T, dir string) []string { + t.Helper() + got, err := files.List(context.Background(), dir) + if err != nil { + t.Fatalf("listing %s: %v", dir, err) + } + return got +} + +func TestList(t *testing.T) { + dir := makeDir(t, "b.txt", "a.txt", ".hidden") + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o750); err != nil { + t.Fatalf("creating subdirectory: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "deep.txt"), nil, 0o600); err != nil { + t.Fatalf("creating nested file: %v", err) + } + + got := namesOn(t, dir) + want := []string{".hidden", "a.txt", "b.txt"} + if !slices.Equal(got, want) { + t.Errorf("List() = %v, want %v (directories and nested files must be skipped)", got, want) + } +} + +func TestListErrors(t *testing.T) { + t.Run("missing directory", func(t *testing.T) { + _, err := files.List(context.Background(), filepath.Join(t.TempDir(), "nope")) + if err == nil { + t.Fatal("List() of a missing directory returned no error") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error %v does not wrap os.ErrNotExist", err) + } + }) + + t.Run("cancelled context", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := files.List(ctx, t.TempDir()); !errors.Is(err, context.Canceled) { + t.Errorf("List() = %v, want context.Canceled", err) + } + }) +} + +func TestConflicts(t *testing.T) { + tests := []struct { + name string + onDisk []string + directories []string + entries []files.Entry + want map[int]string + }{ + { + name: "clean plan", + onDisk: []string{"a.txt", "b.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "1.txt"}, + {Original: "b.txt", Proposed: "2.txt"}, + }, + want: map[int]string{}, + }, + { + name: "swapping names is allowed", + onDisk: []string{"a.txt", "b.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "b.txt"}, + {Original: "b.txt", Proposed: "a.txt"}, + }, + want: map[int]string{}, + }, + { + name: "duplicate targets", + onDisk: []string{"a.txt", "b.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "same.txt"}, + {Original: "b.txt", Proposed: "same.txt"}, + }, + want: map[int]string{0: "several files would get this name", 1: "several files would get this name"}, + }, + { + name: "empty name", + onDisk: []string{"a.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: " "}, + }, + want: map[int]string{0: "new name is empty"}, + }, + { + name: "path separator", + onDisk: []string{"a.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "sub/a.txt"}, + }, + want: map[int]string{0: "new name contains a path separator"}, + }, + { + name: "directory reference", + onDisk: []string{"a.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: ".."}, + }, + want: map[int]string{0: "new name is a directory reference"}, + }, + { + name: "would overwrite a file that is not part of the plan", + onDisk: []string{"a.txt", "keep.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "keep.txt"}, + }, + want: map[int]string{0: "a filesystem entry with this name already exists"}, + }, + { + name: "would collide with an existing directory", + onDisk: []string{"a.txt"}, + directories: []string{"archive"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "archive"}, + }, + want: map[int]string{0: "a filesystem entry with this name already exists"}, + }, + { + name: "unchanged names never conflict with themselves", + onDisk: []string{"a.txt"}, + entries: []files.Entry{ + {Original: "a.txt", Proposed: "a.txt"}, + }, + want: map[int]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := makeDir(t, tt.onDisk...) + for _, name := range tt.directories { + if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil { + t.Fatalf("creating directory %s: %v", name, err) + } + } + got := files.Conflicts(context.Background(), dir, tt.entries) + if len(got) != len(tt.want) { + t.Fatalf("Conflicts() = %v, want %v", got, tt.want) + } + for index, reason := range tt.want { + if got[index] != reason { + t.Errorf("entry %d: got %q, want %q", index, got[index], reason) + } + } + }) + } +} + +func TestRename(t *testing.T) { + t.Run("renames and skips unchanged files", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt") + renamed, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "1.txt"}, + {Original: "b.txt", Proposed: "b.txt"}, + }) + if err != nil { + t.Fatalf("Rename() error: %v", err) + } + if renamed != 1 { + t.Errorf("Rename() = %d, want 1", renamed) + } + if got, want := namesOn(t, dir), []string{"1.txt", "b.txt"}; !slices.Equal(got, want) { + t.Errorf("directory holds %v, want %v", got, want) + } + }) + + t.Run("swaps names", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt") + if _, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "b.txt"}, + {Original: "b.txt", Proposed: "a.txt"}, + }); err != nil { + t.Fatalf("Rename() error: %v", err) + } + content, err := os.ReadFile(filepath.Join(dir, "a.txt")) + if err != nil { + t.Fatalf("reading a.txt: %v", err) + } + if string(content) != "b.txt" { + t.Errorf("a.txt holds %q, want the contents of the old b.txt", content) + } + }) + + t.Run("rotates names", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt", "c.txt") + if _, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "b.txt"}, + {Original: "b.txt", Proposed: "c.txt"}, + {Original: "c.txt", Proposed: "a.txt"}, + }); err != nil { + t.Fatalf("Rename() error: %v", err) + } + for name, want := range map[string]string{"b.txt": "a.txt", "c.txt": "b.txt", "a.txt": "c.txt"} { + content, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + if string(content) != want { + t.Errorf("%s holds %q, want %q", name, content, want) + } + } + }) + + t.Run("refuses a conflicting plan", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt") + _, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "same.txt"}, + {Original: "b.txt", Proposed: "same.txt"}, + }) + if err == nil { + t.Fatal("Rename() accepted a conflicting plan") + } + if !strings.Contains(err.Error(), "a.txt") { + t.Errorf("error %v does not name the offending file", err) + } + if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) { + t.Errorf("directory holds %v, want the untouched %v", got, want) + } + }) + + t.Run("rolls back when a file disappears", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt") + if err := os.Remove(filepath.Join(dir, "b.txt")); err != nil { + t.Fatalf("removing b.txt: %v", err) + } + _, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "1.txt"}, + {Original: "b.txt", Proposed: "2.txt"}, + }) + if err == nil { + t.Fatal("Rename() succeeded although a file was missing") + } + if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) { + t.Errorf("directory holds %v, want the rolled back %v", got, want) + } + }) + + t.Run("rolls back when the final pass fails", func(t *testing.T) { + dir := makeDir(t, "a.txt", "b.txt") + tooLong := strings.Repeat("x", 256) + + renamed, err := files.Rename(context.Background(), dir, []files.Entry{ + {Original: "a.txt", Proposed: "1.txt"}, + {Original: "b.txt", Proposed: tooLong}, + }) + if err == nil { + t.Fatal("Rename() succeeded with an overlong destination name") + } + if renamed != 1 { + t.Errorf("Rename() = %d completed renames before rollback, want 1", renamed) + } + if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) { + t.Errorf("directory holds files %v, want the rolled back %v", got, want) + } + }) + + t.Run("cancelled context stops before touching anything", func(t *testing.T) { + dir := makeDir(t, "a.txt") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := files.Rename(ctx, dir, []files.Entry{{Original: "a.txt", Proposed: "1.txt"}}); !errors.Is(err, context.Canceled) { + t.Errorf("Rename() = %v, want context.Canceled", err) + } + if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) { + t.Errorf("directory holds %v, want %v", got, want) + } + }) +} diff --git a/internal/ops/ops.go b/internal/ops/ops.go new file mode 100644 index 0000000..1a319fb --- /dev/null +++ b/internal/ops/ops.go @@ -0,0 +1,451 @@ +// Package ops implements the file name transformations that a bulk rename is +// built from. Operations are pure functions of a name and its position in the +// file list, which makes a rename easy to preview before anything is renamed +// on disk. +package ops + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// Kind identifies a type of rename operation. +type Kind string + +// The supported operation kinds. +const ( + KindReplace Kind = "Replace" + KindRemove Kind = "Remove" + KindInsert Kind = "Insert" + KindIncrement Kind = "Increment" + KindLower Kind = "To lowercase" + KindUpper Kind = "To uppercase" + KindTruncate Kind = "Truncate" +) + +// Kinds lists every supported operation kind, in menu order. +var Kinds = []Kind{ + KindReplace, + KindRemove, + KindInsert, + KindIncrement, + KindLower, + KindUpper, + KindTruncate, +} + +// ToLast is the sentinel accepted by index fields to address the last +// character of a name instead of a fixed position. +const ToLast = "to-last" + +// Config holds the raw, user-entered settings of a single operation. Values +// are kept as typed by the user so that an operation can be re-opened for +// editing exactly as it was created. Fields not used by Config.Kind are +// ignored by [New]. +type Config struct { + Kind Kind + + Target string // Replace, Remove: the text or pattern to look for. + Replacement string // Replace: the text to put in its place. + Limit string // Replace, Remove: max number of matches, empty or 0 for all. + Text string // Insert: the text to insert. + Index string // Insert: character position, or ToLast for the end. + Prefix string // Increment: text placed before the counter. + Start string // Increment: first counter value. + Step string // Increment: counter increment per file. + First string // Truncate: first character index, or ToLast. + Last string // Truncate: last character index, or ToLast. + + FromStart bool // Replace, Remove: apply Limit to the first matches instead of the last. + CaseSensitive bool // Replace, Remove: match letter case exactly. + Regex bool // Replace, Remove: treat Target as a regular expression. + KeepBetween bool // Truncate: keep the selected range instead of removing it. + PreserveExt bool // All kinds: leave the file extension untouched. +} + +// DefaultConfig returns the configuration a new operation of the given kind +// starts with, including the documented default values. +func DefaultConfig(kind Kind) Config { + cfg := Config{Kind: kind, FromStart: true, PreserveExt: true} + switch kind { + case KindInsert: + cfg.Index = "0" + case KindIncrement: + cfg.Start = "0" + cfg.Step = "1" + case KindTruncate: + cfg.First = "0" + cfg.Last = "0" + } + return cfg +} + +// Operation transforms file names as one step of a rename pipeline. +type Operation interface { + // Config returns the settings the operation was built from. + Config() Config + // Summary returns a short one-line description for the operation list. + Summary() string + // Apply returns name transformed. index is the zero-based position of the + // file within the list being renamed; only counter-based operations use it. + Apply(name string, index int) string +} + +// New builds an operation from cfg. It returns an error if a numeric field +// cannot be parsed or a regular expression does not compile. +func New(cfg Config) (Operation, error) { + switch cfg.Kind { + case KindReplace, KindRemove: + return newSubstitute(cfg) + case KindInsert: + return newInsert(cfg) + case KindIncrement: + return newIncrement(cfg) + case KindLower, KindUpper: + return &caseOp{cfg: cfg}, nil + case KindTruncate: + return newTruncate(cfg) + default: + return nil, fmt.Errorf("unknown operation kind %q", cfg.Kind) + } +} + +// Preview applies every operation, in order, to each of names and returns the +// resulting names. The input slice is not modified. +func Preview(names []string, list []Operation) []string { + out := make([]string, len(names)) + for i, name := range names { + for _, op := range list { + name = op.Apply(name, i) + } + out[i] = name + } + return out +} + +// splitExt splits name into a stem and an extension. When preserve is false, +// or when name has no extension to speak of (such as ".bashrc"), the whole +// name is returned as the stem. +func splitExt(name string, preserve bool) (stem, ext string) { + if !preserve { + return name, "" + } + ext = filepath.Ext(name) + if ext == "" || ext == name { + return name, "" + } + return name[:len(name)-len(ext)], ext +} + +// parseInt parses a decimal integer, returning def for an empty string. The +// field name is used to give the error context. +func parseInt(field, s string, def int) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return def, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a number", field, s) + } + return n, nil +} + +// parseIndex parses a character index that may also be the [ToLast] sentinel, +// in which case toLast is true and n is meaningless. +func parseIndex(field, s string, def int) (n int, toLast bool, err error) { + if strings.EqualFold(strings.TrimSpace(s), ToLast) { + return 0, true, nil + } + n, err = parseInt(field, s, def) + return n, false, err +} + +// compileTarget turns the user's search text into a regular expression, +// quoting it unless cfg asks for regex mode. It returns a nil expression for +// an empty target, which makes the operation a no-op. +func compileTarget(cfg Config) (*regexp.Regexp, error) { + if cfg.Target == "" { + return nil, nil + } + pattern := cfg.Target + if !cfg.Regex { + pattern = regexp.QuoteMeta(pattern) + } + if !cfg.CaseSensitive { + pattern = "(?i)" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regular expression: %w", err) + } + return re, nil +} + +// substituteOp implements both [KindReplace] and [KindRemove]; a removal is a +// replacement with empty text. +type substituteOp struct { + cfg Config + re *regexp.Regexp + repl string + limit int +} + +func newSubstitute(cfg Config) (Operation, error) { + re, err := compileTarget(cfg) + if err != nil { + return nil, err + } + limit, err := parseInt("limit", cfg.Limit, 0) + if err != nil { + return nil, err + } + if limit < 0 { + return nil, fmt.Errorf("limit: must not be negative") + } + op := &substituteOp{cfg: cfg, re: re, limit: limit} + if cfg.Kind == KindReplace { + op.repl = cfg.Replacement + } + return op, nil +} + +func (o *substituteOp) Config() Config { return o.cfg } + +func (o *substituteOp) Apply(name string, _ int) string { + if o.re == nil { + return name + } + stem, ext := splitExt(name, o.cfg.PreserveExt) + return o.substitute(stem) + ext +} + +// substitute replaces the selected matches in s. In regex mode the +// replacement may refer to capture groups with $1 or ${name}. +func (o *substituteOp) substitute(s string) string { + matches := o.re.FindAllStringSubmatchIndex(s, -1) + if len(matches) == 0 { + return s + } + if o.limit > 0 && o.limit < len(matches) { + if o.cfg.FromStart { + matches = matches[:o.limit] + } else { + matches = matches[len(matches)-o.limit:] + } + } + var b []byte + last := 0 + for _, m := range matches { + b = append(b, s[last:m[0]]...) + if o.cfg.Regex { + b = o.re.ExpandString(b, o.repl, s, m) + } else { + b = append(b, o.repl...) + } + last = m[1] + } + return string(append(b, s[last:]...)) +} + +func (o *substituteOp) Summary() string { + var b strings.Builder + if o.cfg.Kind == KindRemove { + fmt.Fprintf(&b, "Remove %q", o.cfg.Target) + } else { + fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement) + } + flags := []string{} + if o.limit > 0 { + where := "last" + if o.cfg.FromStart { + where = "first" + } + flags = append(flags, fmt.Sprintf("%s %d", where, o.limit)) + } + if o.cfg.CaseSensitive { + flags = append(flags, "case sensitive") + } + if o.cfg.Regex { + flags = append(flags, "regex") + } + return b.String() + flagSuffix(flags, o.cfg.PreserveExt) +} + +// insertOp implements [KindInsert]. +type insertOp struct { + cfg Config + at int + toLast bool +} + +func newInsert(cfg Config) (Operation, error) { + at, toLast, err := parseIndex("insert index", cfg.Index, 0) + if err != nil { + return nil, err + } + return &insertOp{cfg: cfg, at: at, toLast: toLast}, nil +} + +func (o *insertOp) Config() Config { return o.cfg } + +func (o *insertOp) Apply(name string, _ int) string { + stem, ext := splitExt(name, o.cfg.PreserveExt) + r := []rune(stem) + at := o.at + if o.toLast || at > len(r) { + at = len(r) + } + if at < 0 { + at = 0 + } + return string(r[:at]) + o.cfg.Text + string(r[at:]) + ext +} + +func (o *insertOp) Summary() string { + where := strconv.Itoa(o.at) + if o.toLast { + where = "the end" + } + return fmt.Sprintf("Insert %q at %s", o.cfg.Text, where) + flagSuffix(nil, o.cfg.PreserveExt) +} + +// incrementOp implements [KindIncrement]. The counter is appended to the name, +// preceded by the configured prefix. +type incrementOp struct { + cfg Config + start int + step int +} + +func newIncrement(cfg Config) (Operation, error) { + start, err := parseInt("number to start", cfg.Start, 0) + if err != nil { + return nil, err + } + step, err := parseInt("incremental step", cfg.Step, 1) + if err != nil { + return nil, err + } + return &incrementOp{cfg: cfg, start: start, step: step}, nil +} + +func (o *incrementOp) Config() Config { return o.cfg } + +func (o *incrementOp) Apply(name string, index int) string { + stem, ext := splitExt(name, o.cfg.PreserveExt) + return stem + o.cfg.Prefix + strconv.Itoa(o.start+index*o.step) + ext +} + +func (o *incrementOp) Summary() string { + return fmt.Sprintf("Append %q + counter from %d step %d", o.cfg.Prefix, o.start, o.step) + + flagSuffix(nil, o.cfg.PreserveExt) +} + +// caseOp implements [KindLower] and [KindUpper]. +type caseOp struct { + cfg Config +} + +func (o *caseOp) Config() Config { return o.cfg } + +func (o *caseOp) Apply(name string, _ int) string { + stem, ext := splitExt(name, o.cfg.PreserveExt) + if o.cfg.Kind == KindUpper { + return strings.ToUpper(stem) + ext + } + return strings.ToLower(stem) + ext +} + +func (o *caseOp) Summary() string { + return string(o.cfg.Kind) + flagSuffix(nil, o.cfg.PreserveExt) +} + +// truncateOp implements [KindTruncate]. +type truncateOp struct { + cfg Config + first int + last int + firstLast bool + lastIsLast bool +} + +func newTruncate(cfg Config) (Operation, error) { + first, firstLast, err := parseIndex("first character index", cfg.First, 0) + if err != nil { + return nil, err + } + last, lastIsLast, err := parseIndex("last character index", cfg.Last, 0) + if err != nil { + return nil, err + } + return &truncateOp{cfg: cfg, first: first, last: last, firstLast: firstLast, lastIsLast: lastIsLast}, nil +} + +func (o *truncateOp) Config() Config { return o.cfg } + +func (o *truncateOp) Apply(name string, _ int) string { + stem, ext := splitExt(name, o.cfg.PreserveExt) + r := []rune(stem) + if len(r) == 0 { + return name + } + first, last := o.first, o.last + if o.firstLast { + first = len(r) - 1 + } + if o.lastIsLast { + last = len(r) - 1 + } + first = clamp(first, 0, len(r)-1) + last = clamp(last, 0, len(r)-1) + if first > last { + first, last = last, first + } + if o.cfg.KeepBetween { + return string(r[first:last+1]) + ext + } + return string(r[:first]) + string(r[last+1:]) + ext +} + +func (o *truncateOp) Summary() string { + verb := "Remove" + if o.cfg.KeepBetween { + verb = "Keep" + } + return fmt.Sprintf("%s characters %s..%s", verb, indexLabel(o.first, o.firstLast), indexLabel(o.last, o.lastIsLast)) + + flagSuffix(nil, o.cfg.PreserveExt) +} + +// indexLabel renders a character index for display. +func indexLabel(n int, toLast bool) string { + if toLast { + return ToLast + } + return strconv.Itoa(n) +} + +// clamp confines n to the inclusive range [lo, hi]. +func clamp(n, lo, hi int) int { + if n < lo { + return lo + } + if n > hi { + return hi + } + return n +} + +// flagSuffix renders the parenthesised flag list shown after a summary. +func flagSuffix(flags []string, preserveExt bool) string { + if preserveExt { + flags = append(flags, "keep extension") + } + if len(flags) == 0 { + return "" + } + return " (" + strings.Join(flags, ", ") + ")" +} diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go new file mode 100644 index 0000000..9061265 --- /dev/null +++ b/internal/ops/ops_test.go @@ -0,0 +1,401 @@ +package ops_test + +import ( + "strings" + "testing" + + "aehenamer/internal/ops" +) + +// apply builds the operation and applies it to a single name. +func apply(t *testing.T, cfg ops.Config, name string, index int) string { + t.Helper() + op, err := ops.New(cfg) + if err != nil { + t.Fatalf("ops.New(%+v): %v", cfg, err) + } + return op.Apply(name, index) +} + +func TestReplace(t *testing.T) { + base := func() ops.Config { + cfg := ops.DefaultConfig(ops.KindReplace) + cfg.Target = "foo" + cfg.Replacement = "baz" + return cfg + } + + tests := []struct { + name string + cfg func(c *ops.Config) + in string + want string + }{ + {name: "all occurrences", in: "foo bar foo.txt", want: "baz bar baz.txt"}, + { + name: "limit from start", + cfg: func(c *ops.Config) { c.Limit = "1" }, + in: "foo bar foo.txt", want: "baz bar foo.txt", + }, + { + name: "limit from end", + cfg: func(c *ops.Config) { c.Limit = "1"; c.FromStart = false }, + in: "foo bar foo.txt", want: "foo bar baz.txt", + }, + { + name: "limit larger than matches", + cfg: func(c *ops.Config) { c.Limit = "9" }, + in: "foo.txt", want: "baz.txt", + }, + {name: "case insensitive by default", in: "FoO.txt", want: "baz.txt"}, + { + name: "case sensitive", + cfg: func(c *ops.Config) { c.CaseSensitive = true }, + in: "FoO.txt", want: "FoO.txt", + }, + { + name: "extension preserved", + cfg: func(c *ops.Config) { c.Target = "txt"; c.Replacement = "md" }, + in: "txt.txt", want: "md.txt", + }, + { + name: "extension included when not preserved", + cfg: func(c *ops.Config) { c.Target = "txt"; c.Replacement = "md"; c.PreserveExt = false }, + in: "txt.txt", want: "md.md", + }, + { + name: "regex with capture group", + cfg: func(c *ops.Config) { c.Regex = true; c.Target = `(\d+)`; c.Replacement = "[$1]" }, + in: "episode 12.mkv", want: "episode [12].mkv", + }, + { + name: "regex is quoted in literal mode", + cfg: func(c *ops.Config) { c.Target = `a.c`; c.Replacement = "x" }, + in: "abc a.c.txt", want: "abc x.txt", + }, + { + name: "empty target is a no-op", + cfg: func(c *ops.Config) { c.Target = "" }, + in: "foo.txt", want: "foo.txt", + }, + { + name: "no match leaves the name alone", + cfg: func(c *ops.Config) { c.Target = "zzz" }, + in: "foo.txt", want: "foo.txt", + }, + { + name: "dotfile has no extension to preserve", + cfg: func(c *ops.Config) { c.Target = "bashrc"; c.Replacement = "zshrc" }, + in: ".bashrc", want: ".zshrc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := base() + if tt.cfg != nil { + tt.cfg(&cfg) + } + if got := apply(t, cfg, tt.in, 0); got != tt.want { + t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestRemove(t *testing.T) { + tests := []struct { + name string + cfg func(c *ops.Config) + in string + want string + }{ + { + name: "literal text", + cfg: func(c *ops.Config) { c.Target = " - copy" }, + in: "report - copy.pdf", want: "report.pdf", + }, + { + name: "regular expression", + cfg: func(c *ops.Config) { c.Target = `\s*\(\d+\)`; c.Regex = true }, + in: "photo (1).jpg", want: "photo.jpg", + }, + { + name: "last match only", + cfg: func(c *ops.Config) { c.Target = "x"; c.Limit = "1"; c.FromStart = false }, + in: "xaxbx.txt", want: "xaxb.txt", + }, + { + name: "extension untouched", + cfg: func(c *ops.Config) { c.Target = "a" }, + in: "banana.aac", want: "bnn.aac", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindRemove) + tt.cfg(&cfg) + if got := apply(t, cfg, tt.in, 0); got != tt.want { + t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestInsert(t *testing.T) { + tests := []struct { + name string + text string + index string + ext bool + in string + want string + }{ + {name: "at the beginning", text: "2024-", index: "0", ext: true, in: "trip.jpg", want: "2024-trip.jpg"}, + {name: "in the middle", text: "-", index: "2", ext: true, in: "abcd.jpg", want: "ab-cd.jpg"}, + {name: "to-last", text: "-end", index: ops.ToLast, ext: true, in: "abcd.jpg", want: "abcd-end.jpg"}, + {name: "to-last without preserving extension", text: "!", index: ops.ToLast, ext: false, in: "abcd.jpg", want: "abcd.jpg!"}, + {name: "index beyond the name is clamped", text: "!", index: "99", ext: true, in: "ab.jpg", want: "ab!.jpg"}, + {name: "negative index is clamped", text: "!", index: "-3", ext: true, in: "ab.jpg", want: "!ab.jpg"}, + {name: "empty index defaults to the front", text: "!", index: "", ext: true, in: "ab.jpg", want: "!ab.jpg"}, + {name: "counts characters not bytes", text: "-", index: "2", ext: true, in: "héllo.txt", want: "hé-llo.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindInsert) + cfg.Text, cfg.Index, cfg.PreserveExt = tt.text, tt.index, tt.ext + if got := apply(t, cfg, tt.in, 0); got != tt.want { + t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestIncrement(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindIncrement) + cfg.Prefix = "_" + cfg.Start = "1" + cfg.Step = "2" + + names := []string{"a.txt", "b.txt", "c.txt"} + op, err := ops.New(cfg) + if err != nil { + t.Fatalf("ops.New: %v", err) + } + got := ops.Preview(names, []ops.Operation{op}) + want := []string{"a_1.txt", "b_3.txt", "c_5.txt"} + for i := range want { + if got[i] != want[i] { + t.Errorf("file %d = %q, want %q", i, got[i], want[i]) + } + } + + t.Run("defaults", func(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindIncrement) + if got := apply(t, cfg, "a.txt", 3); got != "a3.txt" { + t.Errorf("got %q, want %q", got, "a3.txt") + } + }) +} + +func TestCase(t *testing.T) { + tests := []struct { + name string + kind ops.Kind + ext bool + in string + want string + }{ + {name: "lower keeps extension", kind: ops.KindLower, ext: true, in: "MyFile.TXT", want: "myfile.TXT"}, + {name: "lower whole name", kind: ops.KindLower, ext: false, in: "MyFile.TXT", want: "myfile.txt"}, + {name: "upper keeps extension", kind: ops.KindUpper, ext: true, in: "MyFile.txt", want: "MYFILE.txt"}, + {name: "upper whole name", kind: ops.KindUpper, ext: false, in: "MyFile.txt", want: "MYFILE.TXT"}, + {name: "dotfile", kind: ops.KindUpper, ext: true, in: ".bashrc", want: ".BASHRC"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ops.DefaultConfig(tt.kind) + cfg.PreserveExt = tt.ext + if got := apply(t, cfg, tt.in, 0); got != tt.want { + t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + first, last string + keepBetween bool + ext bool + in string + want string + }{ + {name: "remove range", first: "0", last: "2", ext: true, in: "abcdef.txt", want: "def.txt"}, + {name: "keep range", first: "0", last: "2", keepBetween: true, ext: true, in: "abcdef.txt", want: "abc.txt"}, + {name: "remove to the end", first: "2", last: ops.ToLast, ext: true, in: "abcdef.txt", want: "ab.txt"}, + {name: "keep to the end", first: "2", last: ops.ToLast, keepBetween: true, ext: true, in: "abcdef.txt", want: "cdef.txt"}, + {name: "single character", first: "1", last: "1", ext: true, in: "abc.txt", want: "ac.txt"}, + {name: "swapped indexes", first: "4", last: "1", ext: true, in: "abcdef.txt", want: "af.txt"}, + {name: "indexes beyond the name are clamped", first: "0", last: "99", ext: true, in: "abc.txt", want: ".txt"}, + {name: "extension included", first: "0", last: "0", ext: false, in: "abc.txt", want: "bc.txt"}, + {name: "counts characters not bytes", first: "0", last: "1", ext: true, in: "héllo.txt", want: "llo.txt"}, + {name: "dotfile is truncated as a whole", first: "0", last: "0", ext: true, in: ".txt", want: "txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindTruncate) + cfg.First, cfg.Last = tt.first, tt.last + cfg.KeepBetween, cfg.PreserveExt = tt.keepBetween, tt.ext + if got := apply(t, cfg, tt.in, 0); got != tt.want { + t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNewRejectsBadInput(t *testing.T) { + tests := []struct { + name string + cfg ops.Config + want string + }{ + { + name: "unknown kind", + cfg: ops.Config{Kind: "Frobnicate"}, + want: "unknown operation kind", + }, + { + name: "invalid regular expression", + cfg: ops.Config{Kind: ops.KindReplace, Target: "([a-z", Regex: true}, + want: "invalid regular expression", + }, + { + name: "limit is not a number", + cfg: ops.Config{Kind: ops.KindReplace, Target: "a", Limit: "many"}, + want: "limit", + }, + { + name: "negative limit", + cfg: ops.Config{Kind: ops.KindRemove, Target: "a", Limit: "-2"}, + want: "must not be negative", + }, + { + name: "insert index is not a number", + cfg: ops.Config{Kind: ops.KindInsert, Index: "somewhere"}, + want: "insert index", + }, + { + name: "start is not a number", + cfg: ops.Config{Kind: ops.KindIncrement, Start: "one"}, + want: "number to start", + }, + { + name: "step is not a number", + cfg: ops.Config{Kind: ops.KindIncrement, Step: "two"}, + want: "incremental step", + }, + { + name: "truncate index is not a number", + cfg: ops.Config{Kind: ops.KindTruncate, First: "x"}, + want: "first character index", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op, err := ops.New(tt.cfg) + if err == nil { + t.Fatalf("ops.New(%+v) = %v, want an error", tt.cfg, op) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error %q does not mention %q", err, tt.want) + } + }) + } +} + +func TestPreviewAppliesOperationsInOrder(t *testing.T) { + remove := ops.DefaultConfig(ops.KindRemove) + remove.Target = "IMG_" + lower := ops.DefaultConfig(ops.KindUpper) + increment := ops.DefaultConfig(ops.KindIncrement) + increment.Prefix = "-" + increment.Start = "10" + + list := make([]ops.Operation, 0, 3) + for _, cfg := range []ops.Config{remove, lower, increment} { + op, err := ops.New(cfg) + if err != nil { + t.Fatalf("ops.New(%v): %v", cfg.Kind, err) + } + list = append(list, op) + } + + names := []string{"IMG_one.jpg", "IMG_two.jpg"} + got := ops.Preview(names, list) + want := []string{"ONE-10.jpg", "TWO-11.jpg"} + for i := range want { + if got[i] != want[i] { + t.Errorf("file %d = %q, want %q", i, got[i], want[i]) + } + } + if names[0] != "IMG_one.jpg" { + t.Errorf("Preview modified its input: %q", names[0]) + } +} + +func TestPreviewWithoutOperations(t *testing.T) { + names := []string{"a.txt", "b.txt"} + got := ops.Preview(names, nil) + for i := range names { + if got[i] != names[i] { + t.Errorf("file %d = %q, want %q", i, got[i], names[i]) + } + } +} + +func TestSummaryMentionsTheSettings(t *testing.T) { + cfg := ops.DefaultConfig(ops.KindReplace) + cfg.Target = "a" + cfg.Replacement = "b" + cfg.Limit = "2" + cfg.Regex = true + + op, err := ops.New(cfg) + if err != nil { + t.Fatalf("ops.New: %v", err) + } + summary := op.Summary() + for _, want := range []string{`"a"`, `"b"`, "first 2", "regex", "keep extension"} { + if !strings.Contains(summary, want) { + t.Errorf("summary %q does not mention %q", summary, want) + } + } + if op.Config().Target != "a" { + t.Errorf("Config() lost the entered values: %+v", op.Config()) + } +} + +func TestDefaultConfig(t *testing.T) { + tests := []struct { + kind ops.Kind + want ops.Config + }{ + {kind: ops.KindIncrement, want: ops.Config{Kind: ops.KindIncrement, Start: "0", Step: "1", FromStart: true, PreserveExt: true}}, + {kind: ops.KindTruncate, want: ops.Config{Kind: ops.KindTruncate, First: "0", Last: "0", FromStart: true, PreserveExt: true}}, + {kind: ops.KindInsert, want: ops.Config{Kind: ops.KindInsert, Index: "0", FromStart: true, PreserveExt: true}}, + } + for _, tt := range tests { + t.Run(string(tt.kind), func(t *testing.T) { + if got := ops.DefaultConfig(tt.kind); got != tt.want { + t.Errorf("DefaultConfig(%q) = %+v, want %+v", tt.kind, got, tt.want) + } + }) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..ab4314d --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,563 @@ +// Package ui implements the terminal interface of the bulk file renamer. +package ui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "aehenamer/internal/files" + "aehenamer/internal/ops" +) + +// pageMain is the name of the page holding the main layout. Dialogs are added +// as further pages on top of it. +const pageMain = "main" + +// App is the interactive bulk file renamer. +type App struct { + app *tview.Application + pages *tview.Pages + + dirInput *tview.InputField + filePane *tview.Table + opPane *tview.Table + addOp *tview.DropDown + applyBtn *tview.Button + status *tview.TextView + + // focusRing is the order in which Tab moves between the main widgets. + focusRing []tview.Primitive + + dir string + loadErr string + names []string + entries []files.Entry + conflicts map[int]string + + operations []ops.Operation + + // opRows is how many operations the right pane showed when it was last + // drawn. An empty pane has no meaningful selection to preserve. + opRows int + + // screen, when set, replaces the terminal the interface draws on, and + // ready is closed once the widgets exist. Only tests use them. + screen tcell.Screen + ready chan struct{} + + // dialogFocus remembers which widget had the focus before a floating + // window was opened, keyed by page name. + dialogFocus map[string]tview.Primitive + + // pending is the operation currently being edited in a dialog. It is shown + // in the preview before the user confirms it; pendingIndex is the position + // it will take in the list, or -1 when it is a new operation. + pending ops.Operation + pendingIndex int +} + +// New returns an application that starts out browsing dir. +func New(dir string) *App { + return &App{ + dir: dir, + pendingIndex: -1, + dialogFocus: make(map[string]tview.Primitive), + ready: make(chan struct{}), + } +} + +// Run builds the interface and runs the event loop until the user quits or ctx +// is cancelled. +func (a *App) Run(ctx context.Context) error { + a.build(ctx) + if a.screen != nil { + a.app.SetScreen(a.screen) + } + + stopped := make(chan struct{}) + defer close(stopped) + go func() { + select { + case <-ctx.Done(): + a.app.Stop() + case <-stopped: + } + }() + + a.reload(ctx) + a.refresh(ctx) + close(a.ready) + + if err := a.app.Run(); err != nil { + return fmt.Errorf("running the interface: %w", err) + } + return ctx.Err() +} + +// build creates every widget and wires the handlers together. +func (a *App) build(ctx context.Context) { + a.app = tview.NewApplication().EnableMouse(true).EnablePaste(true) + a.pages = tview.NewPages() + + a.buildDirInput(ctx) + a.buildFilePane(ctx) + a.buildOpPane(ctx) + a.buildControls(ctx) + a.status = tview.NewTextView().SetDynamicColors(true) + + right := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(a.opPane, 0, 1, false). + AddItem(a.controls(), 3, 0, false) + + main := tview.NewFlex(). + AddItem(a.filePane, 0, 2, false). + AddItem(right, 0, 1, false) + + layout := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(a.dirInput, 3, 0, true). + AddItem(main, 0, 1, false). + AddItem(a.status, 1, 0, false) + + a.pages.AddPage(pageMain, layout, true, true) + a.focusRing = []tview.Primitive{a.dirInput, a.filePane, a.opPane, a.addOp, a.applyBtn} + + a.app.SetInputCapture(a.globalKeys(ctx)) + a.app.SetRoot(a.pages, true).SetFocus(a.dirInput) +} + +// controls returns the row holding the "add operation" drop-down and the +// apply button, below the operation list. +func (a *App) controls() tview.Primitive { + row := tview.NewFlex(). + AddItem(a.addOp, 0, 1, false). + AddItem(a.applyBtn, 14, 0, false) + row.SetBorder(true) + return row +} + +// buildDirInput creates the folder picker at the top of the screen. The file +// list follows along as the path is typed, as soon as it names a directory. +func (a *App) buildDirInput(ctx context.Context) { + a.dirInput = tview.NewInputField(). + SetLabel(" Folder: "). + SetText(a.dir). + SetFieldWidth(0) + a.dirInput.SetBorder(true).SetTitle(" Working folder ") + + a.dirInput.SetAutocompleteFunc(func(current string) []string { + return subdirectories(current) + }) + a.dirInput.SetChangedFunc(func(text string) { + if info, err := os.Stat(text); err == nil && info.IsDir() { + a.dir = text + a.reload(ctx) + a.refresh(ctx) + } + }) + a.dirInput.SetDoneFunc(func(key tcell.Key) { + if key != tcell.KeyEnter { + return + } + a.dir = a.dirInput.GetText() + a.reload(ctx) + a.refresh(ctx) + if a.loadErr == "" { + a.app.SetFocus(a.filePane) + } + }) + a.dirInput.SetFocusFunc(func() { + a.setStatus("[white]Type a path (Tab completes) · [yellow]Enter[white] loads it · [yellow]Tab[white] next pane · [yellow]F1[white] help") + }) +} + +// buildFilePane creates the left pane listing current and proposed names. +func (a *App) buildFilePane(ctx context.Context) { + a.filePane = tview.NewTable(). + SetFixed(1, 0). + SetSelectable(true, false) + a.filePane.SetBorder(true).SetTitle(" Files ") + + a.filePane.SetFocusFunc(func() { + a.setStatus("[white]↑↓ select · [yellow]x[white]/[yellow]Del[white] drop file from the list · [yellow]Ctrl-R[white] reload folder · [yellow]Tab[white] next pane") + a.showRowProblem() + }) + a.filePane.SetSelectionChangedFunc(func(row, column int) { + if a.filePane.HasFocus() { + a.showRowProblem() + } + }) + a.filePane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch { + case event.Key() == tcell.KeyDelete, event.Rune() == 'x': + a.removeSelectedFile(ctx) + return nil + case event.Key() == tcell.KeyCtrlR: + a.reload(ctx) + a.refresh(ctx) + return nil + } + return event + }) +} + +// buildOpPane creates the right pane listing the operations to apply. +func (a *App) buildOpPane(ctx context.Context) { + a.opPane = tview.NewTable().SetSelectable(true, false) + a.opPane.SetBorder(true).SetTitle(" Operations ") + + a.opPane.SetFocusFunc(func() { + a.setStatus("[white]↑↓ select · [yellow]Enter[white]/[yellow]e[white] edit · [yellow]x[white]/[yellow]Del[white] remove · [yellow]u[white]/[yellow]d[white] move up/down · [yellow]a[white] add") + }) + a.opPane.SetSelectedFunc(func(row, column int) { + a.editOperation(ctx, row) + }) + a.opPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + row, _ := a.opPane.GetSelection() + switch { + case event.Key() == tcell.KeyDelete, event.Rune() == 'x': + a.removeOperation(ctx, row) + case event.Rune() == 'e': + a.editOperation(ctx, row) + case event.Rune() == 'u': + a.moveOperation(ctx, row, -1) + case event.Rune() == 'd': + a.moveOperation(ctx, row, 1) + case event.Rune() == 'a': + a.app.SetFocus(a.addOp) + default: + return event + } + return nil + }) +} + +// buildControls creates the "add operation" drop-down and the apply button. +func (a *App) buildControls(ctx context.Context) { + labels := make([]string, len(ops.Kinds)) + for i, kind := range ops.Kinds { + labels[i] = string(kind) + } + + a.addOp = tview.NewDropDown(). + SetLabel(" Add operation "). + SetOptions(labels, nil) + a.addOp.SetSelectedFunc(func(text string, index int) { + if index < 0 || index >= len(ops.Kinds) { + return + } + kind := ops.Kinds[index] + // Clear the choice so that the same operation can be picked again. + a.addOp.SetCurrentOption(-1) + a.openOperationDialog(ctx, kind, ops.DefaultConfig(kind), -1) + }) + a.addOp.SetFocusFunc(func() { + a.setStatus("[white]Press [yellow]Enter[white] or click to open the list of operations · [yellow]↑↓[white] choose · [yellow]Esc[white] cancel") + }) + + a.applyBtn = tview.NewButton("Apply rename").SetSelectedFunc(func() { + a.confirmApply(ctx) + }) + a.applyBtn.SetFocusFunc(func() { + a.setStatus("[white]Press [yellow]Enter[white] to rename the listed files on disk · [yellow]Tab[white] next pane") + }) +} + +// globalKeys returns the application wide key handler. +func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey { + return func(event *tcell.EventKey) *tcell.EventKey { + if name, _ := a.pages.GetFrontPage(); name != pageMain { + // A dialog is open; it handles its own keys. + return event + } + switch event.Key() { + case tcell.KeyCtrlQ: + a.app.Stop() + return nil + case tcell.KeyF1: + a.showHelp() + return nil + case tcell.KeyCtrlR: + a.reload(ctx) + a.refresh(ctx) + return nil + case tcell.KeyTab: + a.closeOperationMenu() + a.cycleFocus(1) + return nil + case tcell.KeyBacktab: + a.closeOperationMenu() + a.cycleFocus(-1) + return nil + } + return event + } +} + +// closeOperationMenu closes the drop-down's embedded list before focus moves +// elsewhere. Otherwise the list remains internally open after a global Tab +// shortcut pulls focus away from it. +func (a *App) closeOperationMenu() { + if !a.addOp.IsOpen() { + return + } + handler := a.addOp.InputHandler() + if handler == nil { + return + } + handler(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone), func(p tview.Primitive) { + a.app.SetFocus(p) + }) +} + +// cycleFocus moves the focus by delta positions around the focus ring. +func (a *App) cycleFocus(delta int) { + current := a.app.GetFocus() + for i, p := range a.focusRing { + if p != current { + continue + } + next := (i + delta + len(a.focusRing)) % len(a.focusRing) + a.app.SetFocus(a.focusRing[next]) + return + } + a.app.SetFocus(a.focusRing[0]) +} + +// reload reads the working folder again, dropping any files the user had +// removed from the list. +func (a *App) reload(ctx context.Context) { + names, err := files.List(ctx, a.dir) + if err != nil { + a.loadErr = err.Error() + a.names = nil + return + } + a.loadErr = "" + a.names = names +} + +// effectiveOps returns the operation list including the operation currently +// being edited, so that a dialog can be previewed before it is confirmed. +func (a *App) effectiveOps() []ops.Operation { + if a.pending == nil { + return a.operations + } + list := make([]ops.Operation, len(a.operations)) + copy(list, a.operations) + if a.pendingIndex >= 0 && a.pendingIndex < len(list) { + list[a.pendingIndex] = a.pending + return list + } + return append(list, a.pending) +} + +// refresh recomputes the proposed names and redraws both panes. +func (a *App) refresh(ctx context.Context) { + proposed := ops.Preview(a.names, a.effectiveOps()) + a.entries = make([]files.Entry, len(a.names)) + for i, name := range a.names { + a.entries[i] = files.Entry{Original: name, Proposed: proposed[i]} + } + a.conflicts = files.Conflicts(ctx, a.dir, a.entries) + + a.drawFiles(ctx) + a.drawOperations(ctx) +} + +// drawFiles fills the left pane with the current and proposed names. +func (a *App) drawFiles(ctx context.Context) { + selected, _ := a.filePane.GetSelection() + a.filePane.Clear() + a.filePane.SetSelectable(len(a.entries) > 0, false) + + header := func(column int, text string, expansion int) { + a.filePane.SetCell(0, column, tview.NewTableCell(text). + SetTextColor(tcell.ColorYellow). + SetSelectable(false). + SetExpansion(expansion)) + } + header(0, " Current name", 1) + header(1, " New name", 1) + header(2, "", 0) + + for i, entry := range a.entries { + row := i + 1 + index := i + + a.filePane.SetCell(row, 0, tview.NewTableCell(" "+tview.Escape(entry.Original)).SetExpansion(1)) + + proposed := tview.NewTableCell(" " + tview.Escape(entry.Proposed)).SetExpansion(1) + switch { + case a.conflicts[index] != "": + proposed.SetTextColor(tcell.ColorRed) + case entry.Changed(): + proposed.SetTextColor(tcell.ColorGreen) + default: + proposed.SetTextColor(tcell.ColorGray) + } + a.filePane.SetCell(row, 1, proposed) + + remove := tview.NewTableCell(" ✕ "). + SetTextColor(tcell.ColorRed). + SetAlign(tview.AlignCenter) + remove.Clicked = func() bool { + a.removeFile(ctx, index) + return true + } + a.filePane.SetCell(row, 2, remove) + } + + title := fmt.Sprintf(" Files (%d) — %s ", len(a.entries), tview.Escape(a.dir)) + if a.loadErr != "" { + title = " Files — folder cannot be read " + } + a.filePane.SetTitle(title) + + last := len(a.entries) + if last == 0 { + a.filePane.Select(0, 0) + return + } + a.filePane.Select(clamp(selected, 1, last), 0) +} + +// drawOperations fills the right pane with the operation list. +func (a *App) drawOperations(ctx context.Context) { + selected := 0 + if a.opRows > 0 { + selected, _ = a.opPane.GetSelection() + } + a.opRows = len(a.operations) + a.opPane.Clear() + a.opPane.SetSelectable(len(a.operations) > 0, false) + + if len(a.operations) == 0 { + a.opPane.SetCell(0, 0, tview.NewTableCell(" No operations yet — use “Add operation” below "). + SetTextColor(tcell.ColorGray). + SetSelectable(false). + SetExpansion(1)) + a.opPane.Select(0, 0) + return + } + + for i, op := range a.operations { + index := i + a.opPane.SetCell(i, 0, tview.NewTableCell(fmt.Sprintf(" %d.", i+1)). + SetTextColor(tcell.ColorYellow)) + a.opPane.SetCell(i, 1, tview.NewTableCell(tview.Escape(op.Summary())).SetExpansion(1)) + + remove := tview.NewTableCell(" [Remove] ").SetTextColor(tcell.ColorRed) + remove.Clicked = func() bool { + a.removeOperation(ctx, index) + return true + } + a.opPane.SetCell(i, 2, remove) + } + + a.opPane.Select(clamp(selected, 0, len(a.operations)-1), 0) +} + +// clamp confines n to the inclusive range [low, high]. +func clamp(n, low, high int) int { + if n < low { + return low + } + if n > high { + return high + } + return n +} + +// removeSelectedFile drops the file under the cursor from the list. +func (a *App) removeSelectedFile(ctx context.Context) { + row, _ := a.filePane.GetSelection() + a.removeFile(ctx, row-1) +} + +// removeFile drops the file at index from the list. The file itself is left +// untouched on disk. +func (a *App) removeFile(ctx context.Context, index int) { + if index < 0 || index >= len(a.names) { + return + } + name := a.names[index] + a.names = append(a.names[:index:index], a.names[index+1:]...) + a.refresh(ctx) + a.setStatus("[white]Removed [yellow]%s[white] from the list · [yellow]Ctrl-R[white] brings it back", tview.Escape(name)) +} + +// removeOperation deletes the operation at index. +func (a *App) removeOperation(ctx context.Context, index int) { + if index < 0 || index >= len(a.operations) { + return + } + summary := a.operations[index].Summary() + a.operations = append(a.operations[:index:index], a.operations[index+1:]...) + a.refresh(ctx) + a.setStatus("[white]Removed operation: %s", tview.Escape(summary)) +} + +// moveOperation moves the operation at index by delta places, changing the +// order in which operations are applied. +func (a *App) moveOperation(ctx context.Context, index, delta int) { + target := index + delta + if index < 0 || index >= len(a.operations) || target < 0 || target >= len(a.operations) { + return + } + a.operations[index], a.operations[target] = a.operations[target], a.operations[index] + a.refresh(ctx) + a.opPane.Select(target, 0) +} + +// showRowProblem explains in the status bar why the selected file cannot be +// renamed, if it cannot. +func (a *App) showRowProblem() { + row, _ := a.filePane.GetSelection() + if reason, bad := a.conflicts[row-1]; bad { + a.setStatus("[red]%s[white] — fix the operations or drop the file with [yellow]x", reason) + } +} + +// setStatus writes a message to the status bar. The text may contain tview +// colour tags. +func (a *App) setStatus(format string, args ...any) { + a.status.SetText(" " + fmt.Sprintf(format, args...)) +} + +// subdirectories returns directory paths that continue the partially typed +// path, for the folder input's autocompletion. +func subdirectories(current string) []string { + if current == "" { + return nil + } + dir, prefix := filepath.Split(current) + search := dir + if search == "" { + search = "." + } + entries, err := os.ReadDir(search) + if err != nil { + return nil + } + const maxEntries = 25 + var out []string + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { + continue + } + out = append(out, dir+entry.Name()) + if len(out) == maxEntries { + break + } + } + if len(out) == 1 && out[0] == current { + return nil + } + return out +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..dea39c9 --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,444 @@ +package ui + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/gdamore/tcell/v2" + + "aehenamer/internal/files" + "aehenamer/internal/ops" +) + +// waitTimeout bounds how long a test waits for the interface to catch up. +const waitTimeout = 5 * time.Second + +// startApp runs the application on a simulation screen and returns helpers to +// drive it. The application is stopped when the test ends. +func startApp(t *testing.T, dir string) (*App, tcell.SimulationScreen, func(func())) { + t.Helper() + + screen := tcell.NewSimulationScreen("UTF-8") + screen.SetSize(140, 30) + + app := New(dir) + app.screen = screen + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- app.Run(ctx) }() + <-app.ready + + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil && !errors.Is(err, context.Canceled) { + t.Errorf("Run() = %v, want nil or context.Canceled", err) + } + case <-time.After(waitTimeout): + t.Error("Run() did not return after the context was cancelled") + } + }) + + // onLoop runs fn on the event loop goroutine, so that tests can touch the + // application state without racing the interface. + onLoop := func(fn func()) { + t.Helper() + finished := make(chan struct{}) + app.app.QueueUpdateDraw(func() { + defer close(finished) + fn() + }) + select { + case <-finished: + case <-time.After(waitTimeout): + t.Fatal("the event loop did not run the queued function") + } + } + return app, screen, onLoop +} + +// waitForState polls cond on the event loop until it holds, so that tests can +// wait for injected keys to be processed without racing the interface. +func waitForState(t *testing.T, onLoop func(func()), what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(waitTimeout) + for time.Now().Before(deadline) { + held := false + onLoop(func() { held = cond() }) + if held { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +// screenText returns everything currently drawn on the screen, one line per +// screen row. +func screenText(screen tcell.SimulationScreen) string { + cells, width, height := screen.GetContents() + var b strings.Builder + for row := range height { + for column := range width { + runes := cells[row*width+column].Runes + if len(runes) == 0 { + b.WriteRune(' ') + continue + } + b.WriteRune(runes[0]) + } + b.WriteRune('\n') + } + return b.String() +} + +// waitForText fails the test unless want shows up on the screen. +func waitForText(t *testing.T, screen tcell.SimulationScreen, onLoop func(func()), want string) { + t.Helper() + deadline := time.Now().Add(waitTimeout) + for time.Now().Before(deadline) { + var contents string + onLoop(func() { contents = screenText(screen) }) + if strings.Contains(contents, want) { + return + } + time.Sleep(5 * time.Millisecond) + } + var contents string + onLoop(func() { contents = screenText(screen) }) + t.Fatalf("%q never appeared on screen. Screen was:\n%s", want, contents) +} + +// makeDir creates a temporary directory holding the given files. +func makeDir(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600); err != nil { + t.Fatalf("creating %s: %v", name, err) + } + } + return dir +} + +func TestAppListsTheFolderOnStart(t *testing.T) { + dir := makeDir(t, "one.txt", "two.txt") + _, screen, onLoop := startApp(t, dir) + + waitForText(t, screen, onLoop, "one.txt") + waitForText(t, screen, onLoop, "two.txt") + waitForText(t, screen, onLoop, "No operations yet") +} + +func TestAppDisplaysColorTagsInUserTextLiterally(t *testing.T) { + dir := makeDir(t, "report[red].txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + waitForText(t, screen, onLoop, "report[red].txt") + + onLoop(func() { + cfg := ops.DefaultConfig(ops.KindReplace) + cfg.Target = "[red]" + cfg.Replacement = "[blue]" + op, err := ops.New(cfg) + if err != nil { + t.Fatalf("ops.New: %v", err) + } + app.operations = append(app.operations, op) + app.refresh(ctx) + }) + + waitForText(t, screen, onLoop, `Replace "[red]" with`) + waitForText(t, screen, onLoop, "report[blue].txt") +} + +func TestAppPreviewsAndAppliesAnOperation(t *testing.T) { + dir := makeDir(t, "one.txt", "two.txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + // Open the dialog the way the "Add operation" drop-down does. + onLoop(func() { + app.openOperationDialog(ctx, ops.KindUpper, ops.DefaultConfig(ops.KindUpper), -1) + }) + waitForText(t, screen, onLoop, "Add operation") + + // The preview is live: the new names show up before the dialog is closed. + waitForText(t, screen, onLoop, "ONE.txt") + + // Tab from the checkbox to the OK button and confirm. + screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone) + screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone) + waitForState(t, onLoop, "the operation to be added", func() bool { + return len(app.operations) == 1 + }) + + onLoop(func() { + if app.pending != nil { + t.Error("the preview operation outlived the dialog") + } + if got := app.entries[0].Proposed; got != "ONE.txt" { + t.Errorf("proposed name = %q, want %q", got, "ONE.txt") + } + }) + + // Apply the rename and confirm the dialog. + onLoop(func() { app.confirmApply(ctx) }) + waitForText(t, screen, onLoop, "Rename 2 file(s)") + screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone) + waitForState(t, onLoop, "the rename to finish", func() bool { + return len(app.names) == 2 && app.names[0] == "ONE.txt" + }) + + got, err := files.List(ctx, dir) + if err != nil { + t.Fatalf("listing the folder: %v", err) + } + if want := []string{"ONE.txt", "TWO.txt"}; !slices.Equal(got, want) { + t.Errorf("folder holds %v, want %v", got, want) + } + + onLoop(func() { + if len(app.operations) != 0 { + t.Errorf("operations = %d, want the list to be cleared after a rename", len(app.operations)) + } + }) +} + +func TestAppCancelledDialogLeavesNoOperation(t *testing.T) { + dir := makeDir(t, "one.txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + onLoop(func() { + app.openOperationDialog(ctx, ops.KindUpper, ops.DefaultConfig(ops.KindUpper), -1) + }) + waitForText(t, screen, onLoop, "ONE.txt") + + screen.InjectKey(tcell.KeyEscape, 0, tcell.ModNone) + waitForState(t, onLoop, "the dialog to close", func() bool { + name, _ := app.pages.GetFrontPage() + return name == pageMain + }) + + onLoop(func() { + if len(app.operations) != 0 { + t.Errorf("operations = %d, want 0", len(app.operations)) + } + if got := app.entries[0].Proposed; got != "one.txt" { + t.Errorf("proposed name = %q, want the preview to be undone", got) + } + }) +} + +func TestAppRemovesFilesAndOperations(t *testing.T) { + dir := makeDir(t, "one.txt", "two.txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + // Drop the first file with the keyboard. + onLoop(func() { app.app.SetFocus(app.filePane) }) + screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone) + waitForState(t, onLoop, "the file to leave the list", func() bool { + return slices.Equal(app.names, []string{"two.txt"}) + }) + + // Reloading brings it back. + screen.InjectKey(tcell.KeyCtrlR, 0, tcell.ModNone) + waitForState(t, onLoop, "the folder to be reloaded", func() bool { + return slices.Equal(app.names, []string{"one.txt", "two.txt"}) + }) + + // Add two operations, then remove the first one with the keyboard. + onLoop(func() { + for _, kind := range []ops.Kind{ops.KindUpper, ops.KindLower} { + op, err := ops.New(ops.DefaultConfig(kind)) + if err != nil { + t.Fatalf("ops.New(%q): %v", kind, err) + } + app.operations = append(app.operations, op) + } + app.refresh(ctx) + app.app.SetFocus(app.opPane) + }) + waitForText(t, screen, onLoop, "To lowercase") + + screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone) + waitForState(t, onLoop, "the operation to be removed", func() bool { + return len(app.operations) == 1 + }) + onLoop(func() { + if got := app.operations[0].Config().Kind; got != ops.KindLower { + t.Errorf("remaining operation = %q, want %q", got, ops.KindLower) + } + }) +} + +func TestEmptyPanesRemainResponsiveAfterNavigation(t *testing.T) { + dir := makeDir(t, "only.txt") + app, screen, onLoop := startApp(t, dir) + + onLoop(func() { app.app.SetFocus(app.filePane) }) + screen.InjectKey(tcell.KeyDelete, 0, tcell.ModNone) + waitForState(t, onLoop, "the last file to leave the list", func() bool { + return len(app.names) == 0 + }) + + // tview searches for the next selectable cell on navigation. A table made + // only of non-selectable header/placeholder cells must itself be marked + // non-selectable, or this search can loop forever. + screen.InjectKey(tcell.KeyUp, 0, tcell.ModNone) + screen.InjectKey(tcell.KeyDown, 0, tcell.ModNone) + screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone) + waitForState(t, onLoop, "the event loop to remain responsive", func() bool { + return app.app.GetFocus() == app.opPane + }) + + // The empty operation pane has the same protection. + screen.InjectKey(tcell.KeyUp, 0, tcell.ModNone) + screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone) + waitForState(t, onLoop, "focus to leave the empty operation pane", func() bool { + return app.app.GetFocus() == app.addOp + }) +} + +func TestTabClosesTheOperationMenuBeforeChangingPanes(t *testing.T) { + dir := makeDir(t, "one.txt") + app, screen, onLoop := startApp(t, dir) + + onLoop(func() { app.app.SetFocus(app.addOp) }) + screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone) + waitForState(t, onLoop, "the operation menu to open", func() bool { + return app.addOp.IsOpen() + }) + + screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone) + waitForState(t, onLoop, "the operation menu to close and focus to move", func() bool { + return !app.addOp.IsOpen() && app.app.GetFocus() == app.applyBtn + }) +} + +func TestAppMovesOperations(t *testing.T) { + dir := makeDir(t, "one.txt") + app, _, onLoop := startApp(t, dir) + ctx := context.Background() + + onLoop(func() { + for _, kind := range []ops.Kind{ops.KindUpper, ops.KindLower} { + op, err := ops.New(ops.DefaultConfig(kind)) + if err != nil { + t.Fatalf("ops.New(%q): %v", kind, err) + } + app.operations = append(app.operations, op) + } + app.moveOperation(ctx, 1, -1) + + if got := app.operations[0].Config().Kind; got != ops.KindLower { + t.Errorf("first operation = %q, want %q", got, ops.KindLower) + } + // Moving past either end does nothing. + app.moveOperation(ctx, 0, -1) + app.moveOperation(ctx, 1, 1) + if got := app.operations[0].Config().Kind; got != ops.KindLower { + t.Errorf("first operation = %q, want %q", got, ops.KindLower) + } + }) +} + +func TestAppReportsConflicts(t *testing.T) { + dir := makeDir(t, "one.txt", "ONE.txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + onLoop(func() { + op, err := ops.New(ops.DefaultConfig(ops.KindUpper)) + if err != nil { + t.Fatalf("ops.New: %v", err) + } + app.operations = append(app.operations, op) + app.refresh(ctx) + + if len(app.conflicts) != 2 { + t.Errorf("conflicts = %v, want both files flagged", app.conflicts) + } + app.confirmApply(ctx) + }) + waitForText(t, screen, onLoop, "Cannot rename") + + got, err := files.List(ctx, dir) + if err != nil { + t.Fatalf("listing the folder: %v", err) + } + if want := []string{"ONE.txt", "one.txt"}; !slices.Equal(got, want) { + t.Errorf("folder holds %v, want the untouched %v", got, want) + } +} + +func TestAppFollowsTheFolderInput(t *testing.T) { + dir := makeDir(t, "one.txt") + other := makeDir(t, "elsewhere.txt") + app, screen, onLoop := startApp(t, dir) + + onLoop(func() { app.dirInput.SetText(other) }) + waitForText(t, screen, onLoop, "elsewhere.txt") + waitForState(t, onLoop, "the folder to change", func() bool { + return app.dir == other && slices.Equal(app.names, []string{"elsewhere.txt"}) + }) +} + +func TestSubdirectories(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"alpha", "beta"} { + if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil { + t.Fatalf("creating %s: %v", name, err) + } + } + if err := os.WriteFile(filepath.Join(dir, "afile"), nil, 0o600); err != nil { + t.Fatalf("creating afile: %v", err) + } + + got := subdirectories(filepath.Join(dir, "a")) + if want := []string{filepath.Join(dir, "alpha")}; !slices.Equal(got, want) { + t.Errorf("subdirectories() = %v, want %v (files must be skipped)", got, want) + } + if got := subdirectories(""); got != nil { + t.Errorf("subdirectories(\"\") = %v, want nil", got) + } + if got := subdirectories(filepath.Join(dir, "nothing", "here")); got != nil { + t.Errorf("subdirectories() of a missing folder = %v, want nil", got) + } +} + +func TestAcceptIndex(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {text: "", want: true}, + {text: "-", want: true}, + {text: "12", want: true}, + {text: "-3", want: true}, + {text: "t", want: true}, + {text: "to-l", want: true}, + {text: ops.ToLast, want: true}, + {text: "to-lastly", want: false}, + {text: "x", want: false}, + {text: "1a", want: false}, + } + for _, tt := range tests { + t.Run(tt.text, func(t *testing.T) { + if got := acceptIndex(tt.text, 0); got != tt.want { + t.Errorf("acceptIndex(%q) = %v, want %v", tt.text, got, tt.want) + } + }) + } +} diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go new file mode 100644 index 0000000..24d2acb --- /dev/null +++ b/internal/ui/dialogs.go @@ -0,0 +1,340 @@ +package ui + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "aehenamer/internal/files" + "aehenamer/internal/ops" +) + +// Names of the floating windows shown on top of the main layout. +const ( + pageOperation = "operation" + pageConfirm = "confirm" + pageMessage = "message" + pageHelp = "help" +) + +// fieldWidth values used by the operation dialogs. +const ( + textWidth = 28 + numberWidth = 10 +) + +// editOperation opens the editor for the operation shown on the given row of +// the operation pane. +func (a *App) editOperation(ctx context.Context, row int) { + if row < 0 || row >= len(a.operations) { + return + } + cfg := a.operations[row].Config() + a.openOperationDialog(ctx, cfg.Kind, cfg, row) +} + +// openOperationDialog shows the floating window in which the values of an +// operation are entered. index is the position of the operation being edited, +// or -1 when a new one is being added. The file list previews the operation +// while the dialog is open. +func (a *App) openOperationDialog(ctx context.Context, kind ops.Kind, cfg ops.Config, index int) { + cfg.Kind = kind + form := tview.NewForm() + + preview := func() { a.setPending(ctx, cfg, index) } + + text := func(label, value string, set func(string)) { + form.AddInputField(label, value, textWidth, nil, func(entered string) { + set(entered) + preview() + }) + } + number := func(label, value string, accept func(string, rune) bool, set func(string)) { + form.AddInputField(label, value, numberWidth, accept, func(entered string) { + set(entered) + preview() + }) + } + check := func(label string, value bool, set func(bool)) { + form.AddCheckbox(label, value, func(checked bool) { + set(checked) + preview() + }) + } + + switch kind { + case ops.KindReplace, ops.KindRemove: + target := "Replace target" + if kind == ops.KindRemove { + target = "Remove target" + } + text(target, cfg.Target, func(v string) { cfg.Target = v }) + if kind == ops.KindReplace { + text("Replacement", cfg.Replacement, func(v string) { cfg.Replacement = v }) + } + number("Limit (0 = all)", cfg.Limit, acceptInt, func(v string) { cfg.Limit = v }) + check("From start", cfg.FromStart, func(v bool) { cfg.FromStart = v }) + check("Case sensitive", cfg.CaseSensitive, func(v bool) { cfg.CaseSensitive = v }) + check(strings.ToLower(string(kind))+" target is regex", cfg.Regex, func(v bool) { cfg.Regex = v }) + check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) + + case ops.KindInsert: + text("Text to insert", cfg.Text, func(v string) { cfg.Text = v }) + number("Insert index ("+ops.ToLast+")", cfg.Index, acceptIndex, func(v string) { cfg.Index = v }) + check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) + + case ops.KindIncrement: + text("Prefix", cfg.Prefix, func(v string) { cfg.Prefix = v }) + number("Number to start", cfg.Start, acceptInt, func(v string) { cfg.Start = v }) + number("Incremental step", cfg.Step, acceptInt, func(v string) { cfg.Step = v }) + check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) + + case ops.KindLower, ops.KindUpper: + check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) + + case ops.KindTruncate: + number("First character index", cfg.First, acceptIndex, func(v string) { cfg.First = v }) + number("Last character index", cfg.Last, acceptIndex, func(v string) { cfg.Last = v }) + check("Keep characters between mode", cfg.KeepBetween, func(v bool) { cfg.KeepBetween = v }) + check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) + } + + cancel := func() { + a.clearPending(ctx) + a.closeDialog(pageOperation) + } + form.AddButton("OK", func() { + op, err := ops.New(cfg) + if err != nil { + a.showMessage("Invalid value", err.Error()) + return + } + if index >= 0 && index < len(a.operations) { + a.operations[index] = op + } else { + a.operations = append(a.operations, op) + } + a.clearPending(ctx) + a.closeDialog(pageOperation) + a.app.SetFocus(a.opPane) + a.setStatus("[white]%s: %s", verb(index), tview.Escape(op.Summary())) + }) + form.AddButton("Cancel", cancel) + form.SetCancelFunc(cancel) + + form.SetBorder(true). + SetTitle(fmt.Sprintf(" %s — %s ", verb(index), kind)). + SetTitleAlign(tview.AlignLeft) + form.SetFocusFunc(func() { + a.setStatus("[white]↑↓/[yellow]Tab[white] move between fields · [yellow]Space[white] toggles a checkbox · [yellow]Enter[white] on OK confirms · [yellow]Esc[white] cancels") + }) + + height := form.GetFormItemCount()*2 + 5 + a.showDialog(pageOperation, form, 62, height) + preview() +} + +// verb describes whether an operation is being added or edited. +func verb(index int) string { + if index >= 0 { + return "Edit operation" + } + return "Add operation" +} + +// setPending previews cfg in the file list without adding it to the operation +// list. Invalid values are reported in the status bar as they are typed. +func (a *App) setPending(ctx context.Context, cfg ops.Config, index int) { + op, err := ops.New(cfg) + if err != nil { + a.pending = nil + a.refresh(ctx) + a.setStatus("[red]%s", tview.Escape(err.Error())) + return + } + a.pending = op + a.pendingIndex = index + a.refresh(ctx) + a.setStatus("[white]Previewing: %s", tview.Escape(op.Summary())) +} + +// clearPending drops the previewed operation and restores the file list. +func (a *App) clearPending(ctx context.Context) { + a.pending = nil + a.pendingIndex = -1 + a.refresh(ctx) +} + +// confirmApply checks the plan and asks the user before touching the disk. +func (a *App) confirmApply(ctx context.Context) { + if name, _ := a.pages.GetFrontPage(); name != pageMain { + // A dialog is open and the file list only shows a preview of it. + return + } + if len(a.conflicts) > 0 { + a.showMessage("Cannot rename", fmt.Sprintf("%d file(s) have a name conflict.\nThe offending names are shown in red.", len(a.conflicts))) + return + } + changed := 0 + for _, entry := range a.entries { + if entry.Changed() { + changed++ + } + } + if changed == 0 { + a.setStatus("[yellow]Nothing to rename[white] — add an operation that changes the names") + return + } + + modal := tview.NewModal(). + SetText(fmt.Sprintf("Rename %d file(s) in\n%s?\n\nThe operation list is cleared afterwards.", changed, a.dir)). + AddButtons([]string{"Rename", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + a.closeDialog(pageConfirm) + if buttonLabel != "Rename" { + return + } + a.applyRename(ctx) + }) + modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageConfirm) })) + + a.dialogFocus[pageConfirm] = a.app.GetFocus() + a.pages.AddPage(pageConfirm, modal, true, true) + a.app.SetFocus(modal) +} + +// applyRename performs the rename on disk and reloads the folder. +func (a *App) applyRename(ctx context.Context) { + renamed, err := files.Rename(ctx, a.dir, a.entries) + if err != nil { + a.reload(ctx) + a.refresh(ctx) + a.showMessage("Rename failed", err.Error()) + return + } + a.operations = nil + a.reload(ctx) + a.refresh(ctx) + a.setStatus("[green]Renamed %d file(s)[white] in %s", renamed, a.dir) +} + +// showMessage reports an error or a notice in a floating window. +func (a *App) showMessage(title, body string) { + modal := tview.NewModal(). + SetText(title + "\n\n" + body). + AddButtons([]string{"OK"}). + SetDoneFunc(func(int, string) { a.closeDialog(pageMessage) }) + modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageMessage) })) + + a.dialogFocus[pageMessage] = a.app.GetFocus() + a.pages.AddPage(pageMessage, modal, true, true) + a.app.SetFocus(modal) +} + +// helpText lists every key binding of the interface. +const helpText = `[yellow]Anywhere[white] + Tab / Shift-Tab move between folder, files, operations, buttons + F1 this help + Ctrl-R reload the folder (restores removed files) + Ctrl-Q / Ctrl-C quit + +[yellow]Folder input[white] + Tab complete the typed path + Enter load the folder + +[yellow]File list[white] + Up / Down select a file + x or Delete drop the selected file from the list + click on ✕ drop that file from the list + +[yellow]Operation list[white] + Enter or e edit the selected operation + x or Delete remove the selected operation + u / d move the operation up or down + a jump to the "Add operation" drop-down + +[yellow]Dialogs[white] + Tab / Up / Down move between fields + Space toggle a checkbox + Enter activate the focused button + Esc cancel + +[yellow]Note[white] + Renaming is applied only when you press "Apply rename". + Every file is renamed through a temporary name, so names can be swapped.` + +// showHelp displays the key bindings. +func (a *App) showHelp() { + view := tview.NewTextView(). + SetDynamicColors(true). + SetText(helpText) + view.SetBorder(true).SetTitle(" Keyboard and mouse ").SetTitleAlign(tview.AlignLeft) + view.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageHelp) })) + a.showDialog(pageHelp, view, 70, 30) +} + +// dismissOnEscape returns a key handler that runs close on Escape, Enter or +// F1 and passes every other key through. +func dismissOnEscape(close func()) func(*tcell.EventKey) *tcell.EventKey { + return func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape, tcell.KeyF1: + close() + return nil + } + return event + } +} + +// showDialog puts p on top of the main layout as a centred floating window. +func (a *App) showDialog(name string, p tview.Primitive, width, height int) { + a.dialogFocus[name] = a.app.GetFocus() + a.pages.AddPage(name, center(p, width, height), true, true) + a.app.SetFocus(p) +} + +// closeDialog removes a floating window and restores the previous focus. +func (a *App) closeDialog(name string) { + a.pages.RemovePage(name) + if previous, ok := a.dialogFocus[name]; ok { + delete(a.dialogFocus, name) + if previous != nil { + a.app.SetFocus(previous) + } + } +} + +// center wraps p in a layout that keeps it at the given size in the middle of +// the screen. +func center(p tview.Primitive, width, height int) tview.Primitive { + column := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 0, true). + AddItem(nil, 0, 1, false) + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(column, width, 0, true). + AddItem(nil, 0, 1, false) +} + +// acceptInt accepts a possibly negative decimal number, or an empty field. +func acceptInt(text string, _ rune) bool { + if text == "" || text == "-" { + return true + } + _, err := strconv.Atoi(text) + return err == nil +} + +// acceptIndex accepts a character index: a number, an empty field, or the +// [ops.ToLast] keyword as it is being typed. +func acceptIndex(text string, _ rune) bool { + if acceptInt(text, 0) { + return true + } + return strings.HasPrefix(ops.ToLast, strings.ToLower(text)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bd129b4 --- /dev/null +++ b/main.go @@ -0,0 +1,47 @@ +// Command aehenamer renames the files of a folder in bulk, from a terminal +// interface: pick a folder, stack up rename operations, preview the result and +// apply it. +// +// Usage: +// +// aehenamer [folder] +// +// The folder defaults to the current working directory. Only the files +// directly inside it are listed; subdirectories are never entered. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "aehenamer/internal/ui" +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [folder]\n\nBulk file renamer. The folder defaults to the current directory.\n", filepath.Base(os.Args[0])) + } + flag.Parse() + + dir := "." + if flag.NArg() > 0 { + dir = flag.Arg(0) + } + if abs, err := filepath.Abs(dir); err == nil { + dir = abs + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := ui.New(dir).Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintf(os.Stderr, "aehenamer: %v\n", err) + os.Exit(1) + } +}