aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 14:47:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 15:01:57 +0200
commit42b02c47be9b285099203e44a2570636d4ca6f03 (patch)
tree82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /cmd/krino
downloadkrino-42b02c47be9b285099203e44a2570636d4ca6f03.tar.gz
krino-42b02c47be9b285099203e44a2570636d4ca6f03.zip
krino: foundation — sexp reader, config language, init/new/check
Diffstat (limited to 'cmd/krino')
-rw-r--r--cmd/krino/check.go44
-rw-r--r--cmd/krino/commands_test.go105
-rw-r--r--cmd/krino/common.go56
-rw-r--r--cmd/krino/init.go35
-rw-r--r--cmd/krino/main.go105
-rw-r--r--cmd/krino/main_test.go59
-rw-r--r--cmd/krino/new.go33
7 files changed, 437 insertions, 0 deletions
diff --git a/cmd/krino/check.go b/cmd/krino/check.go
new file mode 100644
index 0000000..fcd507d
--- /dev/null
+++ b/cmd/krino/check.go
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "krino/internal/config"
+ "krino/internal/xdg"
+)
+
+func init() { commands["check"] = cmdCheck }
+
+// cmdCheck validates the configuration and lists each directory's rules.
+func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int {
+ fs := flagSet("check", g)
+ if code, ok := parse(fs, args, stdout, stderr); !ok {
+ return code
+ }
+ cfg, errs := config.Load(mainFile(g), fs.Args()...)
+ if len(errs) > 0 {
+ printDiags(stderr, errs)
+ return 2
+ }
+ fmt.Fprintf(stdout, "config: %s\n", xdg.Abbrev(cfg.Main.File))
+ if len(cfg.Dirs) == 0 {
+ fmt.Fprintln(stdout, "no directories included; add one with: krino new NAME PATH")
+ }
+ for _, d := range cfg.Dirs {
+ fmt.Fprintf(stdout, "\n%s %s\n", d.Name, xdg.Abbrev(d.Path))
+ if fi, err := os.Stat(d.Path); err != nil || !fi.IsDir() {
+ fmt.Fprintf(stdout, " warning: %s is not a directory right now; it will be skipped\n", xdg.Abbrev(d.Path))
+ }
+ if len(d.Rules) == 0 {
+ fmt.Fprintln(stdout, " no rules yet")
+ }
+ for i, r := range d.Rules {
+ fmt.Fprintf(stdout, " %2d %-16s %s\n", i+1, r.Name, describeActions(r))
+ }
+ }
+ return 0
+}
diff --git a/cmd/krino/commands_test.go b/cmd/krino/commands_test.go
new file mode 100644
index 0000000..68a2174
--- /dev/null
+++ b/cmd/krino/commands_test.go
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// home gives each test its own HOME with an empty XDG config.
+func home(t *testing.T) string {
+ t.Helper()
+ h := t.TempDir()
+ t.Setenv("HOME", h)
+ t.Setenv("XDG_CONFIG_HOME", "")
+ return h
+}
+
+func TestInitNewCheck(t *testing.T) {
+ h := home(t)
+ if err := os.Mkdir(filepath.Join(h, "dl"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ code, out, errOut := runCLI(t, "init")
+ if code != 0 || !strings.Contains(out, "created ~/.config/krino/krino.conf") {
+ t.Fatalf("init: %d %q %q", code, out, errOut)
+ }
+ code, out, _ = runCLI(t, "check")
+ if code != 0 || !strings.Contains(out, "no directories included") {
+ t.Fatalf("check, empty: %d %q", code, out)
+ }
+ code, out, errOut = runCLI(t, "new", "dl", "~/dl")
+ if code != 0 || !strings.Contains(out, "created ~/.config/krino/dirs/dl.conf") {
+ t.Fatalf("new: %d %q %q", code, out, errOut)
+ }
+ conf := filepath.Join(h, ".config", "krino", "dirs", "dl.conf")
+ rules := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"PDF\") (stop))\n"
+ if err := os.WriteFile(conf, []byte(rules), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, out, errOut = runCLI(t, "check")
+ if code != 0 || !strings.Contains(out, "dl ~/dl") || !strings.Contains(out, "pdfs") ||
+ !strings.Contains(out, "move PDF, stop") {
+ t.Fatalf("check: %d %q %q", code, out, errOut)
+ }
+ if err := os.WriteFile(conf, []byte(`(path "~/dl") (rule "x" (move PDF))`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, _, errOut = runCLI(t, "check")
+ want := `~/.config/krino/dirs/dl.conf:1:31: rule "x": move takes a string: write (move "PDF")`
+ if code != 2 || !strings.Contains(errOut, want) || !strings.Contains(errOut, "1 problem found") {
+ t.Fatalf("check, broken: %d %q", code, errOut)
+ }
+}
+
+// TestConfigFlagExpandsTilde is item B: -c=~/... is not expanded by the
+// shell (the ~ comes after =), so krino must expand it itself.
+func TestConfigFlagExpandsTilde(t *testing.T) {
+ h := home(t)
+ cwd := t.TempDir()
+ t.Chdir(cwd)
+ code, out, errOut := runCLI(t, "-c=~/k/krino.conf", "init")
+ if code != 0 {
+ t.Fatalf("init: %d %q %q", code, out, errOut)
+ }
+ want := filepath.Join(h, "k", "krino.conf")
+ if _, err := os.Stat(want); err != nil {
+ t.Fatalf("%s not created: %v", want, err)
+ }
+ if _, err := os.Lstat(filepath.Join(cwd, "~")); !os.IsNotExist(err) {
+ t.Fatalf(`"~" created in the current directory: %v`, err)
+ }
+}
+
+func TestConfigFlag(t *testing.T) {
+ h := home(t)
+ conf := filepath.Join(h, "elsewhere", "krino.conf")
+ if code, _, errOut := runCLI(t, "init", "-c", conf); code != 0 {
+ t.Fatalf("init -c: %q", errOut)
+ }
+ for _, args := range [][]string{{"-c", conf, "check"}, {"check", "-c", conf}} {
+ if code, out, errOut := runCLI(t, args...); code != 0 || !strings.Contains(out, "config: ~/elsewhere/krino.conf") {
+ t.Errorf("%v: %d %q %q", args, code, out, errOut)
+ }
+ }
+}
+
+func TestCommandErrors(t *testing.T) {
+ home(t)
+ tests := []struct {
+ args []string
+ want string
+ }{
+ {[]string{"check"}, "not found; create it with: krino init"},
+ {[]string{"new", "onlyname"}, "usage: krino new NAME PATH"},
+ {[]string{"init", "extra"}, "init takes no arguments"},
+ }
+ for _, tt := range tests {
+ if code, _, errOut := runCLI(t, tt.args...); code != 2 || !strings.Contains(errOut, tt.want) {
+ t.Errorf("%v: %d %q, want %q", tt.args, code, errOut, tt.want)
+ }
+ }
+}
diff --git a/cmd/krino/common.go b/cmd/krino/common.go
new file mode 100644
index 0000000..ea1e83d
--- /dev/null
+++ b/cmd/krino/common.go
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "krino/internal/config"
+ "krino/internal/xdg"
+)
+
+// mainFile is -c FILE, or the default krino.conf.
+func mainFile(g *globals) string {
+ if g.conf != "" {
+ return xdg.Expand(g.conf)
+ }
+ return config.DefaultFile()
+}
+
+// usageError reports a command-line mistake and returns exit status 2.
+func usageError(stderr io.Writer, msg string) int {
+ fmt.Fprintf(stderr, "krino: %s\nrun 'krino -h' for help\n", msg)
+ return 2
+}
+
+// printDiags prints config problems with ~ for the home directory, then a count.
+func printDiags(stderr io.Writer, errs []*config.Diag) {
+ for _, e := range errs {
+ d := *e
+ d.File = xdg.Abbrev(d.File)
+ fmt.Fprintln(stderr, &d)
+ }
+ if len(errs) == 1 {
+ fmt.Fprintln(stderr, "krino: 1 problem found")
+ } else {
+ fmt.Fprintf(stderr, "krino: %d problems found\n", len(errs))
+ }
+}
+
+// describeActions renders a rule's actions briefly, as in: move PDF, stop.
+func describeActions(r *config.Rule) string {
+ var parts []string
+ for _, a := range r.Actions {
+ if a.Arg == "" {
+ parts = append(parts, a.Kind.String())
+ } else {
+ parts = append(parts, a.Kind.String()+" "+a.Arg)
+ }
+ }
+ if r.Stop {
+ parts = append(parts, "stop")
+ }
+ return strings.Join(parts, ", ")
+}
diff --git a/cmd/krino/init.go b/cmd/krino/init.go
new file mode 100644
index 0000000..2122ff8
--- /dev/null
+++ b/cmd/krino/init.go
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+
+ "krino/internal/config"
+ "krino/internal/xdg"
+)
+
+func init() { commands["init"] = cmdInit }
+
+// cmdInit creates the config directory with a commented krino.conf and
+// template.conf.
+func cmdInit(g *globals, args []string, stdout, stderr io.Writer) int {
+ fs := flagSet("init", g)
+ if code, ok := parse(fs, args, stdout, stderr); !ok {
+ return code
+ }
+ if fs.NArg() != 0 {
+ return usageError(stderr, "init takes no arguments")
+ }
+ created, err := config.Init(mainFile(g))
+ if err != nil {
+ fmt.Fprintf(stderr, "krino: %v\n", err)
+ return 2
+ }
+ for _, f := range created {
+ fmt.Fprintf(stdout, "created %s\n", xdg.Abbrev(f))
+ }
+ fmt.Fprintln(stdout, "next: krino new NAME PATH, for example: krino new downloads ~/Downloads")
+ return 0
+}
diff --git a/cmd/krino/main.go b/cmd/krino/main.go
new file mode 100644
index 0000000..0048098
--- /dev/null
+++ b/cmd/krino/main.go
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Command krino sorts files in configured directories by rules.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+)
+
+// version is stamped by the Makefile with -ldflags "-X main.version=...".
+var version = "dev"
+
+const usage = `usage: krino [-y | -n] [-v] [--json] [-c FILE] [NAME...]
+ krino init
+ krino new NAME PATH
+ krino check [NAME...]
+ krino explain FILE
+ krino log [-n N]
+ krino undo [RUN]
+
+Sort the files in the directories listed in krino.conf by their rules.
+
+ -y apply without asking
+ -n dry run: show the plan, change nothing
+ -v also list unmatched, ignored and busy files
+ --json with -n: print the plan as JSON
+ -c FILE use FILE instead of ~/.config/krino/krino.conf
+ -h, --help show this help
+ --version print the version
+`
+
+// globals holds the flags that may appear before or after a subcommand.
+type globals struct {
+ yes, dry, verbose, json bool
+ conf string
+}
+
+// command is a subcommand: it gets the parsed globals and its own arguments.
+type command func(g *globals, args []string, stdout, stderr io.Writer) int
+
+// commands maps subcommand names to their functions; each cmd file adds itself.
+var commands = map[string]command{}
+
+func main() {
+ os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+// run is main without the process exit, so tests can drive it.
+func run(args []string, stdout, stderr io.Writer) int {
+ g := &globals{}
+ fs := flagSet("krino", g)
+ fs.BoolVar(&g.yes, "y", false, "")
+ fs.BoolVar(&g.dry, "n", false, "")
+ fs.BoolVar(&g.verbose, "v", false, "")
+ fs.BoolVar(&g.json, "json", false, "")
+ showVersion := fs.Bool("version", false, "")
+ if code, ok := parse(fs, args, stdout, stderr); !ok {
+ return code
+ }
+ if *showVersion {
+ fmt.Fprintf(stdout, "krino %s\n", version)
+ return 0
+ }
+ rest := fs.Args()
+ if len(rest) > 0 {
+ if cmd, ok := commands[rest[0]]; ok {
+ return cmd(g, rest[1:], stdout, stderr)
+ }
+ }
+ return cmdSort(g, rest, stdout, stderr)
+}
+
+// flagSet returns a silent flag set with -c bound to g, shared by every command.
+func flagSet(name string, g *globals) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ fs.StringVar(&g.conf, "c", g.conf, "")
+ return fs
+}
+
+// parse parses args into fs. On -h it prints the usage and returns (0, false);
+// on a bad flag it reports it and returns (2, false).
+func parse(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) (int, bool) {
+ err := fs.Parse(args)
+ switch {
+ case err == nil:
+ return 0, true
+ case errors.Is(err, flag.ErrHelp):
+ fmt.Fprint(stdout, usage)
+ return 0, false
+ default:
+ fmt.Fprintf(stderr, "krino: %v\nrun 'krino -h' for help\n", err)
+ return 2, false
+ }
+}
+
+// cmdSort plans and applies the included directories. It arrives in plan 3.
+func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
+ fmt.Fprintln(stderr, "krino: sorting is not implemented yet; try 'krino check'")
+ return 2
+}
diff --git a/cmd/krino/main_test.go b/cmd/krino/main_test.go
new file mode 100644
index 0000000..1caab6b
--- /dev/null
+++ b/cmd/krino/main_test.go
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "krino/internal/config"
+)
+
+// runCLI drives run() and returns its exit code and output.
+func runCLI(t *testing.T, args ...string) (int, string, string) {
+ t.Helper()
+ var out, errb bytes.Buffer
+ code := run(args, &out, &errb)
+ return code, out.String(), errb.String()
+}
+
+func TestVersion(t *testing.T) {
+ code, out, _ := runCLI(t, "--version")
+ if code != 0 || out != "krino dev\n" {
+ t.Fatalf("got %d %q, want 0 %q", code, out, "krino dev\n")
+ }
+}
+
+func TestHelp(t *testing.T) {
+ for _, arg := range []string{"-h", "--help"} {
+ code, out, _ := runCLI(t, arg)
+ if code != 0 || !strings.HasPrefix(out, "usage: krino") {
+ t.Errorf("%s: got %d %q", arg, code, out)
+ }
+ }
+}
+
+func TestBadFlag(t *testing.T) {
+ code, _, errOut := runCLI(t, "--bogus")
+ if code != 2 || !strings.Contains(errOut, "flag provided but not defined: -bogus") {
+ t.Fatalf("got %d %q", code, errOut)
+ }
+}
+
+// TestCommandsAreReserved is item E: every subcommand name must also be a
+// reserved directory name, so a directory can never shadow a command.
+func TestCommandsAreReserved(t *testing.T) {
+ for name := range commands {
+ if !config.Reserved[name] {
+ t.Errorf("command %q is not in config.Reserved", name)
+ }
+ }
+}
+
+func TestSortNotYet(t *testing.T) {
+ code, _, errOut := runCLI(t)
+ if code != 2 || !strings.Contains(errOut, "not implemented yet") {
+ t.Fatalf("got %d %q", code, errOut)
+ }
+}
diff --git a/cmd/krino/new.go b/cmd/krino/new.go
new file mode 100644
index 0000000..1cd89a6
--- /dev/null
+++ b/cmd/krino/new.go
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+
+ "krino/internal/config"
+ "krino/internal/xdg"
+)
+
+func init() { commands["new"] = cmdNew }
+
+// cmdNew creates dirs/NAME.conf from the template and includes it.
+func cmdNew(g *globals, args []string, stdout, stderr io.Writer) int {
+ fs := flagSet("new", g)
+ if code, ok := parse(fs, args, stdout, stderr); !ok {
+ return code
+ }
+ if fs.NArg() != 2 {
+ return usageError(stderr, "usage: krino new NAME PATH")
+ }
+ name := fs.Arg(0)
+ file, err := config.NewDir(mainFile(g), name, fs.Arg(1))
+ if err != nil {
+ fmt.Fprintf(stderr, "krino: %v\n", err)
+ return 2
+ }
+ fmt.Fprintf(stdout, "created %s and added %q to include\n", xdg.Abbrev(file), name)
+ fmt.Fprintf(stdout, "edit its rules, then check them with: krino check %s\n", name)
+ return 0
+}