summaryrefslogtreecommitdiff
path: root/cmd/krino
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/krino')
-rw-r--r--cmd/krino/check.go3
-rw-r--r--cmd/krino/common.go26
-rw-r--r--cmd/krino/exclude_test.go136
-rw-r--r--cmd/krino/explain.go18
-rw-r--r--cmd/krino/main.go3
-rw-r--r--cmd/krino/render.go37
-rw-r--r--cmd/krino/sort.go9
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