summaryrefslogtreecommitdiff
path: root/cmd/krino/log.go
blob: bfe61a2c830e99e5e71c855bae67152584c8eb19 (plain) (blame)
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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// SPDX-License-Identifier: GPL-3.0-or-later

package main

import (
	"errors"
	"fmt"
	"io"
	"os"
	"strings"

	"krino/internal/engine"
	"krino/internal/journal"
)

func init() { commands["log"] = cmdLog }

// pastTense renders one of the log's own action words (spec §9's wire
// format - journal.Run.Counts is keyed by these exact strings) as the past
// tense krino log displays. Those strings are never renamed to suit the
// display: krino undo parses them back out of the log, so this map is a
// display-only translation, not a second source of truth. Two families of
// action are deliberately absent, and so never appear in a run's counts
// line at all: run-start/run-end (bookkeeping, not something done to a
// file) and mkdir/undo-mkdir (a side effect of another step, not an
// outcome the user asked for).
var pastTense = map[string]string{
	"copy":          "copied",
	"move":          "moved",
	"rename":        "renamed",
	"trash":         "trashed",
	"delete":        "deleted",
	"displace":      "displaced",
	"undo-copy":     "undo-copied",
	"undo-move":     "undo-moved",
	"undo-rename":   "undo-renamed",
	"undo-trash":    "undo-trashed",
	"undo-displace": "undo-displaced",
}

// countOrder is the fixed order krino log renders a run's counts in.
// journal.Run.Counts is a map, whose iteration order is random; a listing
// that reshuffled its own columns between two invocations of the same
// command would be unreadable.
var countOrder = []string{
	"copy", "move", "rename", "trash", "delete", "displace",
	"undo-copy", "undo-move", "undo-rename", "undo-trash", "undo-displace",
}

// cmdLog lists recent runs, newest first (spec §11: "krino log [-n N]").
func cmdLog(g *globals, args []string, stdout, stderr io.Writer) int {
	fs := flagSet("log", g)
	n := fs.Int("n", 10, "")
	if code, ok := parse(fs, args, stdout, stderr); !ok {
		return code
	}
	if g.minAgeSet {
		return usageError(stderr, "--min-age applies only to sorting and explain")
	}
	if code, refused := refuseUnusedFlags(g, stderr, "log", false); refused {
		return code
	}
	if fs.NArg() > 0 {
		return usageError(stderr, "usage: krino log [-n N]")
	}

	e, errs := engine.Load(mainFile(g))
	if len(errs) > 0 {
		printDiags(stderr, errs)
		return 2
	}

	runs, err := e.Runs(*n)
	if err != nil {
		// journal.Runs (via Engine.Runs) fails closed on a genuine read
		// error - permissions, a corrupt file - and that must stay
		// distinguishable from the ordinary "no log yet" case below rather
		// than collapsing into the same message (dispatch notes).
		if errors.Is(err, os.ErrNotExist) {
			fmt.Fprintln(stdout, "nothing logged yet")
			return 0
		}
		fmt.Fprintf(stderr, "krino: %v\n", err)
		return 1
	}
	// A log file can exist and still hold no runs (a real run with nothing
	// actionable still opens the journal - dispatch notes). That is just as
	// ordinary as no log file at all: same message, same exit 0, and no
	// table header is printed over zero rows.
	if len(runs) == 0 {
		fmt.Fprintln(stdout, "nothing logged yet")
		return 0
	}

	p := palette{on: colourOn(g, stdout)}
	for _, r := range runs {
		fmt.Fprintln(stdout, styleUndone(formatRun(r), p))
	}
	return 0
}

// styleUndone styles the "(undone)" formatRun appends, faint (spec §8.2);
// a line without it is returned as is.
func styleUndone(line string, p palette) string {
	if !p.on {
		return line
	}
	for _, mark := range []string{"(partly undone)", "(undone)"} {
		if strings.HasSuffix(line, "  "+mark) {
			return strings.TrimSuffix(line, "  "+mark) + "  " + p.faint(mark)
		}
	}
	return line
}

// formatRun renders one journal.Run as krino log lists it: id, start time,
// the directories it touched, and its counts (see countsText), with
// "(undone)" appended when a later run has reversed it.
func formatRun(r journal.Run) string {
	line := fmt.Sprintf("%s  %s  %s  %s", display(r.ID), r.Start.Format("2006-01-02 15:04"), display(strings.Join(r.Dirs, ", ")), countsText(r.Counts))
	switch {
	case r.PartlyUndone:
		line += "  (partly undone)"
	case r.Undone:
		line += "  (undone)"
	}
	return line
}

// countsText renders a run's counts in countOrder, past tense, skipping any
// action with no "ok" entries. "nothing applied" covers a run every one of
// whose files was declined or failed before anything ran - Counts only
// tallies "ok" entries, so such a run's map holds nothing this function
// would otherwise print.
func countsText(counts map[string]int) string {
	var parts []string
	for _, action := range countOrder {
		if n := counts[action]; n > 0 {
			parts = append(parts, fmt.Sprintf("%d %s", n, pastTense[action]))
		}
	}
	if len(parts) == 0 {
		return "nothing applied"
	}
	return strings.Join(parts, " · ")
}