primeiro commit
This commit is contained in:
@@ -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, ", ") + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user