diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 14:47:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 15:01:57 +0200 |
| commit | 42b02c47be9b285099203e44a2570636d4ca6f03 (patch) | |
| tree | 82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /cmd/krino | |
| download | krino-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.go | 44 | ||||
| -rw-r--r-- | cmd/krino/commands_test.go | 105 | ||||
| -rw-r--r-- | cmd/krino/common.go | 56 | ||||
| -rw-r--r-- | cmd/krino/init.go | 35 | ||||
| -rw-r--r-- | cmd/krino/main.go | 105 | ||||
| -rw-r--r-- | cmd/krino/main_test.go | 59 | ||||
| -rw-r--r-- | cmd/krino/new.go | 33 |
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 +} |
