// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "context" "fmt" "io" "os" "sort" "strings" "unicode/utf8" "krino/internal/engine" "krino/internal/scan" "krino/internal/xdg" ) // cmdSort plans and applies the included directories. Only -n (dry run) is // implemented; applying arrives in plan 3. 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 { 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 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) if err != nil { fmt.Fprintf(stderr, "krino: skipping %s: %v\n", d.Name, err) exit = 1 continue } 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 r.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) } } } // 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, ", ") } fmt.Fprintf(w, " %s %s\n", padCell(fm.File.Rel, width), strings.Join(parts, "; ")) } } // 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. func collectWarnings(r *engine.Result) []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 }) var out []warnLine for _, fm := range files { for _, w := range fm.Warnings { out = append(out, warnLine{fm.File.Rel, w}) } } return out } // 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 the order the last line reports skip reasons in, // after "not matched". 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 { 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)) } } if len(parts) == 0 { return "" } line := strings.Join(parts, " · ") if !verbose { line += " (-v lists them)" } return line } // 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. func relWidth(rels []string) int { w := 0 for _, s := range rels { if n := utf8.RuneCountInString(s); n > w { w = n } } if w > 40 { w = 40 } return w } // padCell pads s to width w (runes, not bytes) with trailing spaces; s // already at or beyond w is left unpadded. func padCell(s string, w int) string { n := utf8.RuneCountInString(s) if n >= w { return s } return s + strings.Repeat(" ", w-n) }