aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/sort.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/krino/sort.go')
-rw-r--r--cmd/krino/sort.go208
1 files changed, 116 insertions, 92 deletions
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index fe88cf8..0ba82bc 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -4,6 +4,7 @@ package main
import (
"context"
+ "encoding/json"
"fmt"
"io"
"os"
@@ -12,17 +13,18 @@ import (
"unicode/utf8"
"krino/internal/engine"
+ "krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
)
// cmdSort plans and applies the included directories. Only -n (dry run) is
-// implemented; applying arrives in plan 3.
+// implemented; applying arrives in plan 4.
func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
if g.yes && g.dry {
return usageError(stderr, "-y and -n cannot be used together")
}
- if g.json {
+ if g.json && !g.dry {
fmt.Fprintln(stderr, "krino: --json is not implemented yet")
return 2
}
@@ -39,106 +41,56 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
exit := 0
printed := false
+ jsonDirs := []plan.JSONDir{} // never nil: the document's "dirs" must marshal as [], not null
+ // A3: one Claims for the whole run, shared across every directory's
+ // Plan call below, so two directories that both plan a move to the
+ // same destination resolve the collision at planning time instead of
+ // each independently believing it owns that path.
+ claims := plan.NewClaims()
for _, d := range e.Dirs {
if fi, err := os.Stat(d.Root); err != nil || !fi.IsDir() {
fmt.Fprintf(stderr, "krino: skipping %s: %s is not a directory\n", d.Name, xdg.Abbrev(d.Root))
exit = 1
continue
}
- r, err := e.Match(context.Background(), d)
+ dp, err := e.Plan(context.Background(), d, claims)
if err != nil {
fmt.Fprintf(stderr, "krino: skipping %s: %v\n", d.Name, err)
exit = 1
continue
}
- if printed {
- fmt.Fprintln(stdout)
+ if !g.json {
+ if printed {
+ fmt.Fprintln(stdout)
+ }
+ printed = true
+ fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root))
}
- printed = true
- fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root))
// C3: directory-level warnings go to stderr after the header
// line above, not before it, so on a terminal they read as
// describing the directory just named instead of floating above it.
- for _, w := range r.Warnings {
+ for _, w := range dp.Result.Warnings {
fmt.Fprintf(stderr, "krino: %s: %s\n", d.Name, w)
}
- printResult(stdout, r, g.verbose)
- }
- return exit
-}
-
-// printResult renders one directory's match result the way krino -n shows
-// it, below the header line cmdSort has already written: a summary line,
-// then the matched, warnings and skip-count sections, each present only
-// when it has something to show. Plan 3 reuses this for the outcome of an
-// actual run.
-func printResult(w io.Writer, r *engine.Result, verbose bool) {
- // C1: spec §8.2's worked example is "266 scanned" against "41 to act
- // on" and "not acted on: 3 busy · 12 ignored · 210 unmatched", and
- // 41+3+12+210 = 266 - so scanned counts matched, unmatched and skipped
- // alike, not just matched plus unmatched.
- scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
- warned := 0
- for _, fm := range r.Matched {
- if len(fm.Warnings) > 0 {
- warned++
- }
- }
- for _, fm := range r.Unmatched {
- if len(fm.Warnings) > 0 {
- warned++
- }
- }
- fmt.Fprintf(w, "%d scanned · %d matched · %d warnings · %.2fs\n", scanned, len(r.Matched), warned, r.Elapsed.Seconds())
-
- if len(r.Matched) > 0 {
- fmt.Fprintln(w)
- printMatched(w, r.Matched)
- }
-
- if lines := collectWarnings(r); len(lines) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "warnings")
- printWarnings(w, lines)
- }
-
- if line := skipSummaryLine(r, verbose); line != "" {
- fmt.Fprintln(w)
- fmt.Fprintln(w, line)
- }
-
- if verbose {
- if len(r.Unmatched) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "not matched")
- for _, fm := range r.Unmatched {
- fmt.Fprintf(w, " %s\n", fm.File.Rel)
- }
- }
- if len(r.Skipped) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "skipped")
- printSkipped(w, r.Skipped)
+ if g.json {
+ jsonDirs = append(jsonDirs, plan.NewJSONDir(d.Name, d.Root, dp.Chains, dp.Result.Warnings))
+ continue
}
+ printPlan(stdout, dp, g.verbose)
}
-}
-// printMatched lists each matched file, its Rel padded to the widest shown
-// (capped at 40), then each matching rule as "name: reasons", rules joined
-// by "; ".
-func printMatched(w io.Writer, matched []engine.FileMatch) {
- rels := make([]string, len(matched))
- for i, fm := range matched {
- rels[i] = fm.File.Rel
- }
- width := relWidth(rels)
- for _, fm := range matched {
- parts := make([]string, len(fm.Rules))
- for i, rm := range fm.Rules {
- parts[i] = rm.Rule.Name + ": " + strings.Join(rm.Reasons, ", ")
+ if g.json {
+ b, err := json.MarshalIndent(plan.NewJSON(jsonDirs), "", " ")
+ if err != nil {
+ // Unreachable in practice: every field the document carries
+ // marshals cleanly (strings, times, ints).
+ fmt.Fprintf(stderr, "krino: %v\n", err)
+ return 1
}
- fmt.Fprintf(w, " %s %s\n", padCell(fm.File.Rel, width), strings.Join(parts, "; "))
+ stdout.Write(b)
+ fmt.Fprintln(stdout)
}
+ return exit
}
// warnLine is one file's warning, for the warnings section.
@@ -152,22 +104,48 @@ type warnLine struct {
// section by file name and has no way to tell which group a file fell
// into, so grouping by match state is invisible structure that would only
// show up as an odd order. A file's own warnings (when it has more than
-// one) stay in the order they were recorded.
-func collectWarnings(r *engine.Result) []warnLine {
+// one) stay in the order they were recorded: its match warnings (if any)
+// first, then its chain warnings (B2) - match happens before planning, so
+// that is also the order they were actually produced in. chains supplies
+// the chain-level warnings (e.g. "moved more than once"), keyed by
+// Chain.File.Rel; every chain's file is necessarily also in r.Matched (only
+// matched files ever reach plan.Build), so it is visited exactly once here.
+func collectWarnings(r *engine.Result, chains []plan.Chain) []warnLine {
files := make([]engine.FileMatch, 0, len(r.Matched)+len(r.Unmatched))
files = append(files, r.Matched...)
files = append(files, r.Unmatched...)
sort.Slice(files, func(i, j int) bool { return files[i].File.Rel < files[j].File.Rel })
+ chainWarnings := make(map[string][]string, len(chains))
+ for _, c := range chains {
+ if len(c.Warnings) > 0 {
+ chainWarnings[c.File.Rel] = c.Warnings
+ }
+ }
+
var out []warnLine
for _, fm := range files {
for _, w := range fm.Warnings {
out = append(out, warnLine{fm.File.Rel, w})
}
+ for _, w := range chainWarnings[fm.File.Rel] {
+ out = append(out, warnLine{fm.File.Rel, w})
+ }
}
return out
}
+// warnedCount counts the distinct files behind lines: B2's "N warnings" in
+// the counts line must count a file once even when it carries both a match
+// warning and a chain warning, not once per warning line.
+func warnedCount(lines []warnLine) int {
+ seen := make(map[string]bool, len(lines))
+ for _, l := range lines {
+ seen[l.rel] = true
+ }
+ return len(seen)
+}
+
// printWarnings lists one line per warning, Rel padded to the widest shown
// (capped at 40).
func printWarnings(w io.Writer, lines []warnLine) {
@@ -194,37 +172,83 @@ func printSkipped(w io.Writer, skipped []scan.Skipped) {
}
}
-// skipReasonOrder is the order the last line reports skip reasons in,
-// after "not matched".
+// skipReasonOrder is plan 2's reviewed order for the skip reasons the last
+// line reports, before "unmatched".
var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.Symlink, scan.NotRegular, scan.Unreadable}
-// skipSummaryLine builds the "not matched: N · ignored: N ..." line, only
-// the non-zero counts, or "" when every count is zero.
-func skipSummaryLine(r *engine.Result, verbose bool) string {
+// skipSummaryLine builds the "not acted on: N ignored · N busy · ... · N
+// unmatched" line per spec §8.2's item format ("<count> <label>", not
+// "<label>: <count>"), only the non-zero counts, or "" when every count is
+// zero. Ordering is plan 2's reviewed skipReasonOrder, with "unmatched"
+// last: the spec's own worked example shows only three of the seven
+// categories and states no ordering rule, so its incidental order is not
+// adopted, only its item format and unmatched's trailing position.
+func skipSummaryLine(r *engine.Result, chains []plan.Chain, verbose bool) string {
counts := map[scan.Reason]int{}
for _, s := range r.Skipped {
counts[s.Reason]++
}
var parts []string
- if n := len(r.Unmatched); n > 0 {
- parts = append(parts, fmt.Sprintf("not matched: %d", n))
- }
for _, reason := range skipReasonOrder {
if n := counts[reason]; n > 0 {
- parts = append(parts, fmt.Sprintf("%s: %d", reason.String(), n))
+ parts = append(parts, fmt.Sprintf("%d %s", n, reason.String()))
}
}
+ // excluded and allSkipped (chainOutcomes) close the same arithmetic gap:
+ // without them, a file matching only an action-less rule, or one whose
+ // every step was skipped, is neither "to act on", unmatched, nor a walk
+ // skip, so it appears nowhere.
+ excluded, allSkipped := chainOutcomes(chains)
+ if excluded > 0 {
+ parts = append(parts, fmt.Sprintf("%d excluded", excluded))
+ }
+ if allSkipped > 0 {
+ parts = append(parts, fmt.Sprintf("%d all steps skipped", allSkipped))
+ }
+ if n := len(r.Unmatched); n > 0 {
+ parts = append(parts, fmt.Sprintf("%d unmatched", n))
+ }
if len(parts) == 0 {
return ""
}
- line := strings.Join(parts, " · ")
+ line := "not acted on: " + strings.Join(parts, " · ")
if !verbose {
line += " (-v lists them)"
}
return line
}
+// chainOutcomes counts two of skipSummaryLine's categories over chains:
+// excluded is chains with no steps at all (a file that matched only rules
+// carrying no actions - spec §4.5: "a rule with only (stop) is an
+// exclusion"); allSkipped is chains with at least one step, none of them
+// unskipped (every step's Skip is set). Neither is "to act on", neither is
+// unmatched, and neither is a walk skip, so without these two counts they
+// appear nowhere: the real downloads folder reported "267 scanned · 172 to
+// act on" and said nothing at all about the other 95. With them the
+// arithmetic always closes - scanned = to act on + excluded + all steps
+// skipped + unmatched + walk skips - as spec §8.2's own example does.
+func chainOutcomes(chains []plan.Chain) (excluded, allSkipped int) {
+ for _, c := range chains {
+ if len(c.Steps) == 0 {
+ excluded++
+ continue
+ }
+ acting := false
+ for _, s := range c.Steps {
+ if s.Skip == "" {
+ acting = true
+ break
+ }
+ }
+ if !acting {
+ allSkipped++
+ }
+ }
+ return excluded, allSkipped
+}
+
// relWidth returns the column width for a list of Rel names: the widest in
// runes (C4: not bytes, or a name carrying diacritics misaligns its
// column), capped at 40.