From 24a84671ace373ae331fa83a1ff484990f4dff0e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 12:58:14 +0200 Subject: krino: planning — chains, placeholders, conflicts, JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/krino/sort.go | 208 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 116 insertions(+), 92 deletions(-) (limited to 'cmd/krino/sort.go') 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 ("