1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"strings"
"testing"
"time"
)
func facts() Facts {
return Facts{
Name: "Invoice_2026-08.pdf",
Captures: []string{"2026-08", "2026", "a}}b"},
ModTime: time.Date(2026, 8, 15, 14, 30, 45, 0, time.UTC),
Now: time.Date(2026, 9, 12, 9, 0, 0, 0, time.UTC),
}
}
func TestExpand(t *testing.T) {
tests := []struct{ in, want string }{
{"Work/Acme", "Work/Acme"},
{"{name}", "Invoice_2026-08.pdf"},
{"{stem}", "Invoice_2026-08"},
{"{ext}", ".pdf"},
{"{stem}{ext}", "Invoice_2026-08.pdf"},
{"Work/{mtime:%Y}", "Work/2026"},
{"{mtime:%Y-%m-%d}", "2026-08-15"},
{"{mtime:%H%M%S}", "143045"},
{"{mtime:%j}", "227"},
{"{now:%Y-%m-%d}", "2026-09-12"},
{"{1}", "2026"},
// D2: an expanded value that itself contains "}}" must reach the
// output unchanged. Expand's single-pass scanner jumps past a
// placeholder's closing brace, so written bytes are never re-scanned;
// a refactor to scan-then-replace would silently re-collapse them.
{"{2}", "a}}b"},
{"{{literal}}", "{literal}"},
{"100{{%}}", "100{%}"},
{"{mtime:%Y%%}", "2026%"},
{"Photos/{mtime:%Y}/{stem}-{1}{ext}", "Photos/2026/Invoice_2026-08-2026.pdf"},
}
for _, tt := range tests {
got, err := Expand(tt.in, facts())
if err != nil || got != tt.want {
t.Errorf("Expand(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
}
}
}
func TestExpandNoExtension(t *testing.T) {
f := facts()
f.Name = "README"
for in, want := range map[string]string{"{stem}": "README", "{ext}": ""} {
if got, err := Expand(in, f); err != nil || got != want {
t.Errorf("Expand(%q) on README = %q, %v; want %q", in, got, err, want)
}
}
f.Name = ".bashrc"
if got, _ := Expand("{stem}", f); got != ".bashrc" {
t.Errorf("dotfile stem = %q, want .bashrc", got)
}
if got, _ := Expand("{ext}", f); got != "" {
t.Errorf("dotfile ext = %q, want empty", got)
}
f.Name = "archive.tar.gz"
if got, _ := Expand("{stem}|{ext}", f); got != "archive.tar|.gz" {
t.Errorf("double extension = %q, want archive.tar|.gz", got)
}
}
func TestExpandErrors(t *testing.T) {
tests := []struct{ in, want string }{
{"{7}", "no capture group 7"},
{"{0}", "capture groups are numbered from 1"},
{"{whatever}", "unknown placeholder {whatever}"},
{"{name", "unclosed placeholder"},
{"{mtime:%Q}", "unknown time format %Q in {mtime:...}"},
{"{mtime}", "unknown placeholder {mtime}"},
// B3: the error must name the placeholder actually written ("now"),
// not hardcode "mtime" - the {mtime:%Q} case above passes either
// way, which is why that defect survived.
{"{now:%Q}", "unknown time format %Q in {now:...}"},
// D3: {1}...{9} is the syntax (spec §7.3); {10} and up must be
// rejected the same way an unknown placeholder is.
{"{10}", "unknown placeholder {10}"},
}
for _, tt := range tests {
_, err := Expand(tt.in, facts())
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("Expand(%q) error = %v; want %q", tt.in, err, tt.want)
}
}
}
|