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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"sort"
"time"
"krino/internal/scan"
)
// jsonNote is carried in every JSON document, warning readers that the
// shape is not yet stable.
const jsonNote = "the shape of this document is unstable before krino 1.0"
// JSON is the --json document. The shape is unstable before 1.0 and says
// so in its own "note" field.
type JSON struct {
Version int `json:"version"`
Note string `json:"note"`
Dirs []JSONDir `json:"dirs"`
}
// JSONDir is one directory's plan.
type JSONDir struct {
Name string `json:"name"`
Root string `json:"root"`
Files []JSONFile `json:"files"`
// Unmatched holds the files no rule matched that still raised a
// warning (a content test that could not read them), as the text
// plan's warnings list does; other unmatched files are left out.
Unmatched []JSONFile `json:"unmatched,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// JSONFile is one file's chain.
type JSONFile struct {
Rel string `json:"rel"`
Size int64 `json:"size"`
ModTime time.Time `json:"mtime"`
Excluded string `json:"excluded,omitempty"` // the exclude form that set the file aside
Steps []JSONStep `json:"steps"`
Warnings []string `json:"warnings,omitempty"` // raised while matching, then while planning
}
// FileNotes is what matching found about one file besides its chain.
type FileNotes struct {
File scan.File
Excluded string
Warnings []string
}
// JSONStep is one step of a chain. D14: Reason is carried because the text
// plan already shows it and a machine reader should be able to see why a
// file matched too; Conflict (the rule's on-conflict policy) is deliberately
// not: it is a config detail, and its outcome is already visible through
// dst, displaces and skip.
type JSONStep struct {
Action string `json:"action"`
Rule string `json:"rule"`
Src string `json:"src"`
Dst string `json:"dst,omitempty"`
Displaces string `json:"displaces,omitempty"`
Reason string `json:"reason,omitempty"`
Skip string `json:"skip,omitempty"`
}
// actionNames maps a Kind onto its JSON action name. This is its own
// mapping, independent of Kind.String(): the display form renders "DELETE
// permanently", which must never reach a machine reader. These names match
// the log's action names in spec §9, so a later `krino log` and a --json
// plan can be grepped together.
var actionNames = map[Kind]string{
Copy: "copy",
Move: "move",
Rename: "rename",
Trash: "trash",
DeletePermanent: "delete",
}
// NewJSON builds the top-level --json document over dirs. Version and Note
// are set here, in the one place jsonNote's wording already lives: it is
// unexported, so a struct literal built outside this package would
// silently ship an empty "note" and break the document's own contract.
func NewJSON(dirs []JSONDir) JSON {
return JSON{Version: 1, Note: jsonNote, Dirs: dirs}
}
// NewJSONDir converts one directory's chains. notes are matching's findings
// by Rel, for files with a chain and for unmatched files alike (triage 28h).
func NewJSONDir(name, root string, chains []Chain, notes map[string]FileNotes, warnings []string) JSONDir {
files := make([]JSONFile, 0, len(chains))
inPlan := make(map[string]bool, len(chains))
for _, ch := range chains {
inPlan[ch.File.Rel] = true
note := notes[ch.File.Rel]
steps := make([]JSONStep, 0, len(ch.Steps))
for _, s := range ch.Steps {
steps = append(steps, JSONStep{
Action: actionNames[s.Kind],
Rule: s.Rule,
Src: s.Src,
Dst: s.Dst,
Displaces: s.Displaces,
Reason: s.Reason,
Skip: s.Skip,
})
}
files = append(files, JSONFile{
Rel: ch.File.Rel,
Size: ch.File.Size,
ModTime: ch.File.ModTime,
Excluded: note.Excluded,
Steps: steps,
Warnings: append(append([]string(nil), note.Warnings...), ch.Warnings...),
})
}
var unmatched []JSONFile
for rel, note := range notes {
if !inPlan[rel] && len(note.Warnings) > 0 {
unmatched = append(unmatched, JSONFile{Rel: rel, Size: note.File.Size, ModTime: note.File.ModTime, Steps: []JSONStep{}, Warnings: note.Warnings})
}
}
sort.Slice(unmatched, func(i, j int) bool { return unmatched[i].Rel < unmatched[j].Rel })
return JSONDir{Name: name, Root: root, Files: files, Unmatched: unmatched, Warnings: warnings}
}
|