// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "context" "encoding/json" "fmt" "io" "os" "sort" "strings" "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 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 && !g.dry { fmt.Fprintln(stderr, "krino: --json is not implemented yet") return 2 } if !g.dry { fmt.Fprintln(stderr, "krino: applying files is not implemented yet; use -n to see what would happen") return 2 } e, errs := engine.Load(mainFile(g), names...) if len(errs) > 0 { printDiags(stderr, errs) return 2 } 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 } 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 !g.json { if printed { fmt.Fprintln(stdout) } 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 dp.Result.Warnings { fmt.Fprintf(stderr, "krino: %s: %s\n", d.Name, w) } if g.json { jsonDirs = append(jsonDirs, plan.NewJSONDir(d.Name, d.Root, dp.Chains, dp.Result.Warnings)) continue } printPlan(stdout, dp, g.verbose) } 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 } stdout.Write(b) fmt.Fprintln(stdout) } return exit } // warnLine is one file's warning, for the warnings section. type warnLine struct { rel string text string } // collectWarnings gathers every file's warnings into a single list sorted // by Rel across matched and unmatched files alike: a reader scans this // 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: 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) { rels := make([]string, len(lines)) for i, l := range lines { rels[i] = l.rel } width := relWidth(rels) for _, l := range lines { fmt.Fprintf(w, " %s %s\n", padCell(l.rel, width), l.text) } } // printSkipped lists each skipped file, Rel padded to the widest shown // (capped at 40), then its reason. func printSkipped(w io.Writer, skipped []scan.Skipped) { rels := make([]string, len(skipped)) for i, s := range skipped { rels[i] = s.Rel } width := relWidth(rels) for _, s := range skipped { fmt.Fprintf(w, " %s %s\n", padCell(s.Rel, width), s.Reason.String()) } } // 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 acted on: N ignored · N busy · ... · N // unmatched" line per spec §8.2's item format ("