aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/main.go
blob: a8fab2ddcab8715c684f0997e7fb7fffff263f0c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// 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"
	"runtime/debug"
	"strings"
)

// version is stamped by the Makefile with -ldflags "-X main.version=...".
var version = "dev"

// displayVersion returns the version to print for --version. The Makefile
// stamps a git tag, which carries a leading "v" (required for
// go install ...@v0.0.1, per design.md §14); that "v" belongs on the tag,
// not in the output, so it is stripped here. A "go install" build carries no
// ldflags, leaving version at its built-in "dev"; in that case fall back to
// the build info Go embeds automatically.
func displayVersion() string {
	v := version
	if v == "dev" {
		if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
			v = info.Main.Version
		}
	}
	if len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9' {
		v = v[1:]
	}
	return v
}

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
  --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
`

// globals holds the flags that may appear before or after a subcommand.
type globals struct {
	yes, dry, verbose, json bool
	noColor, noPager        bool
	minAge                  string // --min-age as given; "" when not
	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", displayVersion())
		return 0
	}
	rest := fs.Args()
	if len(rest) > 0 {
		if cmd, ok := commands[rest[0]]; ok {
			return cmd(g, rest[1:], stdout, stderr)
		}
	}
	// Ruling 2026-09-12/4: Go's flag package stops parsing at the first
	// non-flag argument, so "krino dl -n" leaves "-n" in rest as a second
	// directory name instead of a flag - the dry run is silently never
	// honoured. A leftover argument that still looks like a flag is a
	// usage error rather than a guess; there is deliberately no second
	// pass that re-parses trailing flags, which would make "krino --
	// -weird-dir" ambiguous between the two syntaxes.
	for _, a := range rest {
		if strings.HasPrefix(a, "-") {
			fmt.Fprintf(stderr, "krino: %s: flags must come before directory names\nrun 'krino -h' for help\n", a)
			return 2
		}
	}
	return cmdSort(g, rest, stdout, stderr)
}

// flagSet returns a silent flag set with -c, --no-color and -P/--no-pager
// bound to g, shared by every command. Each defaults to the value already
// parsed, so a flag given before a subcommand survives the subcommand's own
// flag set.
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, "")
	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
}

// 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
	}
}