// 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) } } // TestVerboseListsDirectoriesLeftOutOfTheWalk: a rule's destination inside // the directory is not walked, so its files never show in any count; -v // says so, and only for directories that exist. func TestVerboseListsDirectoriesLeftOutOfTheWalk(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) p := filepath.Join(dl, "Work", "Acme", "old-acme.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("x"), 0o644) os.Chtimes(p, old, old) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } rules := "(path \"~/dl\")\n(recursive yes)\n(rule \"acme\" (when (name \"acme\")) (move \"Work/Acme\"))\n(rule \"later\" (move \"Later/{mtime:%Y}\"))\n" os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644) _, out, errOut := runCLI(t, "-n", "-v") if !strings.Contains(out, "\nnot scanned (a rule's destination)\n Work/Acme/\n") { t.Errorf("-v lacks the destination left out of the walk:\n%s\n%s", out, errOut) } if strings.Contains(out, "Later/") { t.Errorf("a destination that does not exist is listed:\n%s", out) } if _, out, _ = runCLI(t, "-n"); strings.Contains(out, "not scanned") { t.Errorf("listed without -v:\n%s", out) } } // TestExplainShowsAnUndecidedRule: explain names a rule it could not decide // "undecided", not "no". func TestExplainShowsAnUndecidedRule(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") os.MkdirAll(dl, 0o755) p := filepath.Join(dl, "big.txt") os.WriteFile(p, []byte("confidential "+strings.Repeat("x", 2048)), 0o644) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) os.Chtimes(p, old, old) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte("(path \"~/dl\")\n(max-read 1K)\n(rule \"keep\" (when (content \"confidential\")) (stop))\n(rule \"all\" (move \"Out\"))\n"), 0o644) _, out, errOut := runCLI(t, "explain", p) if !strings.Contains(out, "rule keep: undecided") || !strings.Contains(out, "rule all: not evaluated, stopped by rule keep, which could not be decided") { t.Errorf("explain output:\n%s\n%s", out, errOut) } }