diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 14:08:36 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 14:08:36 +0200 |
| commit | 1d3f2d1e4c59867024470d3444e12698b7ebb22e (patch) | |
| tree | 4fa14f455c72f9b206a680dcc1b0f4c1f3708158 /cmd | |
| parent | 07c24054cab965800983ef40f53a05c2db131ede (diff) | |
| download | krino-1d3f2d1e4c59867024470d3444e12698b7ebb22e.tar.gz krino-1d3f2d1e4c59867024470d3444e12698b7ebb22e.zip | |
0.0.4: max-size, exclude forms, --min-agev0.0.4
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/krino/check.go | 3 | ||||
| -rw-r--r-- | cmd/krino/common.go | 26 | ||||
| -rw-r--r-- | cmd/krino/exclude_test.go | 136 | ||||
| -rw-r--r-- | cmd/krino/explain.go | 18 | ||||
| -rw-r--r-- | cmd/krino/main.go | 3 | ||||
| -rw-r--r-- | cmd/krino/render.go | 37 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 9 |
7 files changed, 231 insertions, 1 deletions
diff --git a/cmd/krino/check.go b/cmd/krino/check.go index 1820bbe..dfe3a1a 100644 --- a/cmd/krino/check.go +++ b/cmd/krino/check.go @@ -36,6 +36,9 @@ func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int { if dr.Missing { fmt.Fprintf(stdout, " warning: %s is not a directory right now; it will be skipped\n", xdg.Abbrev(dr.Dir.Root)) } + for _, x := range dr.Dir.Excludes { + fmt.Fprintf(stdout, " %s\n", x.Text) + } if len(dr.Dir.Rules) == 0 { fmt.Fprintln(stdout, " no rules yet") } diff --git a/cmd/krino/common.go b/cmd/krino/common.go index ea1e83d..4552441 100644 --- a/cmd/krino/common.go +++ b/cmd/krino/common.go @@ -6,11 +6,37 @@ import ( "fmt" "io" "strings" + "time" "krino/internal/config" + "krino/internal/engine" "krino/internal/xdg" ) +// minAgeOverride parses --min-age: a duration in krino's own units (30s, +// 2m, 1h, 1d, 1w), or a bare 0. set is false when the flag was not given. +// Checked before any config is read, as every flag is. +func minAgeOverride(g *globals) (d time.Duration, set bool, err error) { + switch g.minAge { + case "": + return 0, false, nil + case "0": + return 0, true, nil + } + d, err = config.ParseDuration(g.minAge) + if err != nil { + return 0, false, fmt.Errorf("--min-age: %v", err) + } + return d, true, nil +} + +// applyMinAge sets every loaded directory's min-age to d, for this run only. +func applyMinAge(e *engine.Engine, d time.Duration) { + for _, dir := range e.Dirs { + dir.Settings.MinAge = d + } +} + // mainFile is -c FILE, or the default krino.conf. func mainFile(g *globals) string { if g.conf != "" { diff --git a/cmd/krino/exclude_test.go b/cmd/krino/exclude_test.go new file mode 100644 index 0000000..2e9ff2a --- /dev/null +++ b/cmd/krino/exclude_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "krino/internal/engine" + "krino/internal/scan" +) + +// excludeFixture makes ~/dl with an old a.iso, an old keep.txt and a fresh +// new.txt, a krino.conf excluding iso files everywhere, and dl's own rules: +// exclude names starting with "draft", move everything else to Out. +func excludeFixture(t *testing.T) string { + t.Helper() + h := home(t) + dl := filepath.Join(h, "dl") + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for name, mt := range map[string]time.Time{"a.iso": old, "keep.txt": old, "draft.txt": old, "new.txt": time.Now()} { + p := filepath.Join(dl, name) + if err := os.MkdirAll(dl, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + cfg := filepath.Join(h, ".config", "krino") + mainConf, err := os.ReadFile(filepath.Join(cfg, "krino.conf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfg, "krino.conf"), append(mainConf, []byte("\n(exclude (type iso))\n")...), 0o644); err != nil { + t.Fatal(err) + } + rules := "(path \"~/dl\")\n(exclude (name \"^draft\"))\n(rule \"all\" (move \"Out\"))\n" + if err := os.WriteFile(filepath.Join(cfg, "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { + t.Fatal(err) + } + return h +} + +// TestMinAgeFlagOverridesTheSetting: --min-age changes "too new" for one +// run, in either position, and a bad value is a usage error before anything +// is read. +func TestMinAgeFlagOverridesTheSetting(t *testing.T) { + excludeFixture(t) + _, out, _ := runCLI(t, "-n") + if strings.Contains(out, "new.txt") || !strings.Contains(out, "1 too new") { + t.Errorf("without the flag new.txt should be too new:\n%s", out) + } + for _, args := range [][]string{{"--min-age", "0", "-n"}, {"-n", "--min-age", "0s"}} { + code, out, errOut := runCLI(t, args...) + if code != 0 || !strings.Contains(out, " new.txt\n") || strings.Contains(out, "too new") { + t.Errorf("%v: exit %d, new.txt should be planned:\n%s\n%s", args, code, out, errOut) + } + } + code, _, errOut := runCLI(t, "--min-age", "soon", "-n") + if code != 2 || !strings.Contains(errOut, "--min-age") { + t.Errorf("--min-age soon: exit %d, stderr %q; want 2 naming --min-age", code, errOut) + } +} + +// TestVerboseListsExcludedFiles: with -v, the plan lists each excluded file +// with the exclude that set it aside; the counts line counts them. +func TestVerboseListsExcludedFiles(t *testing.T) { + excludeFixture(t) + code, out, errOut := runCLI(t, "-n", "-v") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{ + "2 excluded", + "\nexcluded\n a.iso (exclude (type iso))\n draft.txt (exclude (name \"^draft\"))\n", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +// TestCheckListsExclusions: check shows each directory's exclusions, the +// krino.conf ones first, before its rules. +func TestCheckListsExclusions(t *testing.T) { + excludeFixture(t) + code, out, errOut := runCLI(t, "check") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + want := " (exclude (type iso))\n (exclude (name \"^draft\"))\n 1 all" + if !strings.Contains(out, want) { + t.Errorf("check output lacks %q:\n%s", want, out) + } +} + +// TestExplainShowsExclusion: explain says which exclude sets the file +// aside and traces every exclude. +func TestExplainShowsExclusion(t *testing.T) { + h := excludeFixture(t) + code, out, errOut := runCLI(t, "explain", filepath.Join(h, "dl", "a.iso")) + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{ + "krino would set this file aside: (exclude (type iso))\n", + "(exclude (type iso)): MATCH\n", + "(exclude (name \"^draft\")): no\n", + } { + if !strings.Contains(out, want) { + t.Errorf("explain output lacks %q:\n%s", want, out) + } + } +} + +// TestSkipSummaryCountsTooBig: a file skipped for max-size is counted as +// too big in the "not acted on" line. +func TestSkipSummaryCountsTooBig(t *testing.T) { + r := &engine.Result{Skipped: []scan.Skipped{{Rel: "big.iso", Reason: scan.TooBig}}} + if got := skipSummaryLine(r, nil, false); !strings.Contains(got, "1 too big") { + t.Errorf("line = %q, want 1 too big", got) + } +} diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go index a4d0253..6f8fa26 100644 --- a/cmd/krino/explain.go +++ b/cmd/krino/explain.go @@ -26,12 +26,19 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { if fs.NArg() != 1 { return usageError(stderr, "usage: krino explain FILE") } + minAge, setMinAge, err := minAgeOverride(g) + if err != nil { + return usageError(stderr, err.Error()) + } e, errs := engine.Load(mainFile(g)) if len(errs) > 0 { printDiags(stderr, errs) return 2 } + if setMinAge { + applyMinAge(e, minAge) + } x, err := e.Explain(context.Background(), xdg.Expand(fs.Arg(0))) if err != nil { @@ -43,7 +50,18 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { if x.Skip != "" { fmt.Fprintf(stdout, "krino would not look at this file: %s\n", x.Skip) } + if x.Excluded != "" { + fmt.Fprintf(stdout, "krino would set this file aside: %s\n", x.Excluded) + } fmt.Fprintln(stdout) + for _, xt := range x.Excludes { + status := "no" + if xt.Match { + status = "MATCH" + } + fmt.Fprintf(stdout, "%s: %s\n", xt.Text, status) + printTrace(stdout, xt.Trace) + } for _, rt := range x.Rules { if rt.Stopped != "" { fmt.Fprintf(stdout, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped) diff --git a/cmd/krino/main.go b/cmd/krino/main.go index 6d1ef96..a8fab2d 100644 --- a/cmd/krino/main.go +++ b/cmd/krino/main.go @@ -52,6 +52,7 @@ Sort the files in the directories listed in krino.conf by their rules. -c FILE use FILE instead of ~/.config/krino/krino.conf --no-color never colour the output, as when NO_COLOR is set -P, --no-pager print the plan straight out, never through the pager + --min-age D for this run, skip files modified less than D ago (0, 30m, 1d) -h, --help show this help --version print the version ` @@ -60,6 +61,7 @@ Sort the files in the directories listed in krino.conf by their rules. type globals struct { yes, dry, verbose, json bool noColor, noPager bool + minAge string // --min-age as given; "" when not conf string } @@ -122,6 +124,7 @@ func flagSet(name string, g *globals) *flag.FlagSet { fs.BoolVar(&g.noColor, "no-color", g.noColor, "") fs.BoolVar(&g.noPager, "no-pager", g.noPager, "") fs.BoolVar(&g.noPager, "P", g.noPager, "") + fs.StringVar(&g.minAge, "min-age", g.minAge, "") return fs } diff --git a/cmd/krino/render.go b/cmd/krino/render.go index 8031767..f94b15a 100644 --- a/cmd/krino/render.go +++ b/cmd/krino/render.go @@ -67,6 +67,13 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width i } if verbose { + if lines := excludedLines(r, dp.Chains); len(lines) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "excluded") + for _, l := range lines { + fmt.Fprintln(w, l) + } + } if len(r.Unmatched) > 0 { fmt.Fprintln(w) fmt.Fprintln(w, "not matched") @@ -82,6 +89,36 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width i } } +// excludedLines lists, for -v, every file that was set aside: each chain +// with no steps, with what set it aside - the (exclude ...) form, or the +// action-less rule it matched. Names are padded to the widest shown +// (capped at 40), as in the warnings section. +func excludedLines(r *engine.Result, chains []plan.Chain) []string { + byRel := make(map[string]engine.FileMatch, len(r.Matched)) + for _, fm := range r.Matched { + byRel[fm.File.Rel] = fm + } + var rels, why []string + for _, c := range chains { + if len(c.Steps) > 0 { + continue + } + fm := byRel[c.File.Rel] + reason := fm.Excluded + if reason == "" && len(fm.Rules) > 0 { + reason = "rule " + fm.Rules[len(fm.Rules)-1].Rule.Name + } + rels = append(rels, c.File.Rel) + why = append(why, reason) + } + width := relWidth(rels) + out := make([]string, len(rels)) + for i := range rels { + out[i] = " " + padCell(rels[i], width) + " " + why[i] + } + return out +} + // chainActing reports whether c has at least one step that will actually // run - the single definition of "actionable" that countActing, // actionableChains (sort.go) and chainOutcomes (sort.go) all share (fix diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go index 6d11238..9e714ba 100644 --- a/cmd/krino/sort.go +++ b/cmd/krino/sort.go @@ -56,12 +56,19 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { if g.json && !g.dry { return usageError(stderr, "--json is only valid with -n") } + minAge, setMinAge, err := minAgeOverride(g) + if err != nil { + return usageError(stderr, err.Error()) + } e, errs := engine.Load(mainFile(g), names...) if len(errs) > 0 { printDiags(stderr, errs) return 2 } + if setMinAge { + applyMinAge(e, minAge) + } p := palette{on: colourOn(g, stdout)} // Spec §8.4: with neither -y nor -n, krino asks; asking a non-terminal @@ -461,7 +468,7 @@ func printSkipped(w io.Writer, skipped []scan.Skipped) { // 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} +var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.TooBig, 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 ("<count> <label>", not |
