// 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} }