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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestJSONDir(t *testing.T) {
chains := []Chain{{
File: file("/r", "x.pdf"),
Steps: []Step{
{Kind: Copy, Rule: "b", Src: "/r/x.pdf", Dst: "/backup/x.pdf", Reason: `content "acme ltd"`},
{Kind: Move, Rule: "a", Src: "/r/x.pdf", Dst: "/r/W/x.pdf", Displaces: "/r/W/x.pdf"},
{Kind: Rename, Rule: "r", Src: "/r/W/x.pdf", Dst: "/r/W/2026-x.pdf"},
{Kind: Trash, Rule: "t", Src: "/r/W/2026-x.pdf"},
{Kind: DeletePermanent, Rule: "z", Src: "/r/W/x.pdf", Skip: "deleted by rule a"},
},
Warnings: []string{"moved more than once; a (stop) is probably missing"},
}}
b, err := json.MarshalIndent(JSON{Version: 1, Note: jsonNote, Dirs: []JSONDir{NewJSONDir("dl", "/r", chains, nil, nil)}}, "", " ")
if err != nil {
t.Fatal(err)
}
out := string(b)
for _, want := range []string{
`"version": 1`,
`"note": "the shape of this document is unstable before krino 1.0"`,
`"name": "dl"`,
`"rel": "x.pdf"`,
`"action": "copy"`,
// D14: the reason a rule matched is carried into the JSON document too.
`"reason": "content \"acme ltd\""`,
// D7: rename and trash were previously covered only by inspection, so
// an edit garbling either name would have passed silently.
`"action": "rename"`,
`"action": "trash"`,
`"action": "move"`,
`"displaces": "/r/W/x.pdf"`,
`"action": "delete"`,
`"skip": "deleted by rule a"`,
} {
if !strings.Contains(out, want) {
t.Errorf("json lacks %s:\n%s", want, out)
}
}
if strings.Contains(out, `"dst": ""`) {
t.Error("empty dst must be omitted")
}
var round JSON
if err := json.Unmarshal(b, &round); err != nil {
t.Fatalf("does not round-trip: %v", err)
}
if !round.Dirs[0].Files[0].ModTime.Equal(time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)) {
t.Errorf("mtime did not survive: %v", round.Dirs[0].Files[0].ModTime)
}
}
func TestJSONDirEmptyStepsAndFilesAreArraysNotNull(t *testing.T) {
stepless := []Chain{{File: file("/r", "y.pdf")}}
dir := NewJSONDir("dl", "/r", stepless, nil, nil)
b, err := json.Marshal(dir)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), `"steps":null`) {
t.Errorf("stepless file's steps must be [], not null: %s", b)
}
if !strings.Contains(string(b), `"steps":[]`) {
t.Errorf("stepless file's steps should marshal as []: %s", b)
}
empty := NewJSONDir("dl", "/r", nil, nil, nil)
b, err = json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), `"files":null`) {
t.Errorf("empty dir's files must be [], not null: %s", b)
}
if !strings.Contains(string(b), `"files":[]`) {
t.Errorf("empty dir's files should marshal as []: %s", b)
}
}
|