From 9528ecd8b08a522639a4aaff6ce2fca7258538c2 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 20:53:00 +0200 Subject: [PATCH] Answer architecture round 1: a custom CLI names itself by its file, --version names its module beside fejkdata's and follows a replace, the module path comes from the library's type, the end-to-end test runs main in a subprocess, the CLI tests find the shipped modules by their manifests, and Your own CLI sits under Library --- CHANGELOG.md | 7 +-- README.md | 57 ++++++++++---------- cli/cli.go | 109 ++++++++++++++++++++++---------------- cli/cli_test.go | 76 ++++++++++++++++++-------- cmd/fejkdata/main_test.go | 40 +++++++------- todo.md | 6 +-- 6 files changed, 172 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 068dde1..e56491a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,9 +138,10 @@ replacement, and each removed path, column or flag. the folders `WithDataPath` names, and fails, naming `WithDataFS`, where no such option is given. A manifest's `namespace` mounts its source under a dot path. - Package `github.com/larvit/fejkdata/cli` is the `fejkdata` command: `cli.Main(modules...)` - runs it over the modules given, and returns its exit code, so a `main` package passing - `append(data.Modules(), mydata)` builds a CLI carrying your own data. `--version` prints - the fejkdata module's version, `devel` in a build from a checkout. + runs it over the modules given, and returns its exit code, so a `main` package calling + `cli.Main(append(data.Modules(), mydata)...)` builds a CLI carrying your own data, which + names itself by its file in its help and errors. `--version` prints the fejkdata + module's version, after the custom CLI's own where the CLI is one. - A data source whose root holds a `.fejkdata.json` manifest with an `index` loads each category on the first call reaching it, as each shipped module does. Data failing to load, in `New` or on that first call, gives an error matching `ErrLoad`, and the CLI exits 1. diff --git a/README.md b/README.md index 631c8cc..fc7d711 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,9 @@ ok, err := fejkdata.IsTemplate(arg) // an inline template by its shape, el A shipped package's `Modules()` is its `FS` and every module it reads, a locale's country's places among them, which its package imports, so your program links only what you read. -`data.Modules()` is every shipped module, and links all of them. Two locales are `WithDataFS(append(svse.Modules(), enus.Modules()...)...)`. To stand in your +`data.Modules()`, from `github.com/larvit/fejkdata/data`, is every shipped module, and +links all of them. Two locales, with `enus` from `github.com/larvit/fejkdata/data/en_US`, +are `WithDataFS(append(svse.Modules(), enus.Modules()...)...)`. To stand in your own places, pass the locale's `FS` alone beside a tree providing `geo.SE.address`, a record with a `format` and the columns `sv_SE.address` reads, `street`, `street-number`, `postal-code` and `locality`: `WithDataFS(svse.FS, myPlaces)`. `New` fails where no `WithDataFS` or `WithDataPath` names the @@ -423,6 +425,33 @@ A `*Generator` is safe for concurrent use; a seeded sequence is reproducible onl when drawn from one goroutine. Changing how a value is composed shifts the seeded stream for that value and everything drawn after it. +### Your own CLI + +Package `cli` is the `fejkdata` command, and its `Main` takes the modules the command +loads, so a CLI carrying your own data is a `main` package of your own: + +```go +package main + +import ( + "embed" + "os" + + "github.com/larvit/fejkdata/cli" + "github.com/larvit/fejkdata/data" +) + +//go:embed *.json *.tsv +var mydata embed.FS + +func main() { os.Exit(cli.Main(append(data.Modules(), mydata)...)) } +``` + +It takes `fejkdata`'s flags and exit codes, and names itself by the file it runs from in +its help and errors. `--no-shipped-data` leaves out the modules passed to `Main`, and +`--version` prints your module's version beside the fejkdata version it is built with. +`fejkdata`'s own `main` is `os.Exit(cli.Main(data.Modules()...))`. + ## Data format Every value is a **node**, nestable without limit: @@ -1023,32 +1052,6 @@ Where the project is heading; the sections before Audience document what ships t 16. **Values look real by default. You can also ask for values that can never reach anything real,** such as a domain nobody can register. -### Your own CLI - -Package `cli` is the `fejkdata` command, and its `Main` takes the modules the command -loads, so a CLI carrying your own data is a `main` package of your own: - -```go -package main - -import ( - "embed" - "os" - - "github.com/larvit/fejkdata/cli" - "github.com/larvit/fejkdata/data" -) - -//go:embed *.json -var mydata embed.FS - -func main() { os.Exit(cli.Main(append(data.Modules(), mydata)...)) } -``` - -It takes `fejkdata`'s flags and exit codes, `--no-shipped-data` leaves out the modules -passed to `Main`, and `--version` prints the fejkdata version it is built with. -`fejkdata`'s own `main` is `os.Exit(cli.Main(data.Modules()...))`. - ## Layout ``` diff --git a/cli/cli.go b/cli/cli.go index 12af40c..ddc287d 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,6 +1,7 @@ // Package cli is the fejkdata command: it renders the template on its stdin with fake // values from the modules it is given and any directories layered over them. A program -// passing its own modules to Main is a fejkdata CLI carrying them. +// passing its own modules to Main is a fejkdata CLI carrying them, as +// os.Exit(cli.Main(append(data.Modules(), mydata)...)). package cli import ( @@ -11,6 +12,8 @@ import ( "io" "io/fs" "os" + "path/filepath" + "reflect" "runtime/debug" "sort" "strconv" @@ -276,41 +279,41 @@ func (in invocation) checkFlags() error { return nil } -const pipeHint = "echo '{/sv_SE.person}' | fejkdata" +func pipeHint(name string) string { return "echo '{/sv_SE.person}' | " + name } // operandError refuses an argument that is no flag, naming the spelling that reads what // it most likely means: dropping it for -, a file for a file's name, else a template // piped in. -func operandError(arg string) error { +func operandError(arg, name string) error { unexpected := "unexpected argument '" + arg + "'" if arg == "-" { return fmt.Errorf("%s; stdin is read already, so drop it", unexpected) } if fi, err := os.Stat(arg); err == nil && fi.Mode().IsRegular() { - return fmt.Errorf("%s; read a file from stdin: fejkdata < %s", unexpected, shellQuoted(arg)) + return fmt.Errorf("%s; read a file from stdin: %s < %s", unexpected, name, shellQuoted(arg)) } ref := "{/" + strings.TrimPrefix(arg, "/") + "}" _, argLone := loneReference(arg) inline, err := fejkdata.IsTemplate(arg) _, refLone := loneReference(ref) - example := pipeHint + example := pipeHint(name) switch { case argLone || inline: - example = piped(arg) + example = piped(arg, name) case err == nil && refLone: - example = piped(ref) + example = piped(ref, name) } return fmt.Errorf("%s; the template is read from stdin: %s", unexpected, example) } // check rejects a flag combination or an operand that cannot run, before any data is // read. -func (in invocation) check() error { +func (in invocation) check(name string) error { if err := in.checkFlags(); err != nil { return err } if len(in.args) > 0 { - return operandError(in.args[0]) + return operandError(in.args[0], name) } return nil } @@ -541,13 +544,13 @@ func spacedReference(template string, err error) error { return err } -// piped is the command piping template to fejkdata; some shells' echo reads a backslash -// as an escape, so a template holding one goes through printf. -func piped(template string) string { +// piped is the command piping template to the command name; some shells' echo reads a +// backslash as an escape, so a template holding one goes through printf. +func piped(template, name string) string { if strings.ContainsRune(template, '\\') { - return "printf '%s' " + shellQuoted(template) + " | fejkdata" + return "printf '%s' " + shellQuoted(template) + " | " + name } - return "echo " + shellQuoted(template) + " | fejkdata" + return "echo " + shellQuoted(template) + " | " + name } // shellQuoted is s as a shell reads it back: bare where it holds nothing a shell expands. @@ -566,30 +569,33 @@ func Main(modules ...fs.FS) int { if fi, err := os.Stdin.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 { stdin = nil } - return run(modules, os.Args[1:], stdin, os.Stdout, os.Stderr) + name := strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") + return run(name, modules, os.Args[1:], stdin, os.Stdout, os.Stderr) } -// run is Main over the given streams. A nil stdin is a terminal, which holds no template. -func run(modules []fs.FS, args []string, stdin io.Reader, stdout, stderr io.Writer) int { +// run is Main over the given streams, run as the command name. A nil stdin is a terminal, +// which holds no template. +func run(name string, modules []fs.FS, args []string, stdin io.Reader, stdout, stderr io.Writer) int { in, err := parseArgs(args) if err != nil { - return misuse(stderr, err) + return misuse(stderr, name, err) } if in.help { - io.WriteString(stdout, usage) + io.WriteString(stdout, strings.ReplaceAll(usage, "fejkdata", name)) return 0 } if in.version { - fmt.Fprintln(stdout, "fejkdata "+buildVersion()) + info, _ := debug.ReadBuildInfo() + fmt.Fprintln(stdout, versionLine(name, info)) return 0 } - if err := in.check(); err != nil { - return misuse(stderr, err) + if err := in.check(name); err != nil { + return misuse(stderr, name, err) } var src input if !in.list { - if src, err = readStdin(stdin); err != nil { - return fail(stderr, err) + if src, err = readStdin(stdin, name); err != nil { + return fail(stderr, name, err) } } f, err := fejkdata.New(in.options(modules)...) @@ -604,14 +610,14 @@ func run(modules []fs.FS, args []string, stdin io.Reader, stdout, stderr io.Writ return 0 } if err := in.write(f, src, stdout); err != nil { - return fail(stderr, err) + return fail(stderr, name, err) } return 0 } -func readStdin(stdin io.Reader) (input, error) { +func readStdin(stdin io.Reader, name string) (input, error) { if stdin == nil { - return input{}, inputError{fmt.Errorf("the template comes from stdin, and nothing is piped in: %s", pipeHint)} + return input{}, inputError{fmt.Errorf("the template comes from stdin, and nothing is piped in: %s", pipeHint(name))} } raw, err := io.ReadAll(stdin) if err != nil { @@ -628,10 +634,10 @@ func templateError(err error) error { return inputError{err} } -func fail(stderr io.Writer, err error) int { +func fail(stderr io.Writer, name string, err error) int { var te inputError if errors.As(err, &te) { - return misuse(stderr, te.error) + return misuse(stderr, name, te.error) } fmt.Fprintln(stderr, err) if errors.Is(err, fejkdata.ErrNoColumns) { @@ -640,26 +646,39 @@ func fail(stderr io.Writer, err error) int { return 1 } -func misuse(stderr io.Writer, err error) int { - // A library error already names the program, so the prefix is not doubled. - fmt.Fprintf(stderr, "fejkdata: %s\ntry 'fejkdata --help'\n", strings.TrimPrefix(err.Error(), "fejkdata: ")) +func misuse(stderr io.Writer, name string, err error) int { + // A library error opens with the library's name, so the prefix is not doubled. + fmt.Fprintf(stderr, "%s: %s\ntry '%s --help'\n", name, strings.TrimPrefix(err.Error(), "fejkdata: "), name) return 2 } -// buildVersion is the version of the fejkdata module go install stamps into the binary, -// whichever module's main package carries the CLI, or "devel". -func buildVersion() string { - info, _ := debug.ReadBuildInfo() - return moduleVersion(info) -} +// fejkdataModule is the path of the module holding the library; the root package's path is +// the module's. +var fejkdataModule = reflect.TypeOf((*fejkdata.Generator)(nil)).Elem().PkgPath() -func moduleVersion(info *debug.BuildInfo) string { - if info != nil { - for _, m := range append([]*debug.Module{&info.Main}, info.Deps...) { - if m.Path == "github.com/larvit/fejkdata" && m.Version != "" && m.Version != "(devel)" { - return m.Version - } +// versionLine names the fejkdata version the binary is built with, after the main module's +// version under the command name where another module's main package carries the CLI. +func versionLine(name string, info *debug.BuildInfo) string { + if info == nil { + return "fejkdata devel" + } + line := "fejkdata " + moduleVersion(info.Main) + for _, m := range info.Deps { + if m.Path == fejkdataModule { + line = name + " " + moduleVersion(info.Main) + ", fejkdata " + moduleVersion(*m) } } - return "devel" + return line +} + +// moduleVersion is the version m is built at: the replacement's where a replace directive +// names one, and "devel" where it carries none, as a local directory does. +func moduleVersion(m debug.Module) string { + if m.Replace != nil { + m = *m.Replace + } + if m.Version == "" || m.Version == "(devel)" { + return "devel" + } + return m.Version } diff --git a/cli/cli_test.go b/cli/cli_test.go index bf0d88f..6a2c6a0 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "encoding/json" "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -15,15 +16,22 @@ import ( "testing/fstest" ) -// shipped holds the shipped modules as read from the checkout, so these tests link no data -// package; cmd/fejkdata runs over the packages themselves. -var shipped = []fs.FS{ - os.DirFS("../data/en_US"), - os.DirFS("../data/geo/SE"), - os.DirFS("../data/geo/US"), - os.DirFS("../data/misc"), - os.DirFS("../data/sv_SE"), -} +// shipped holds the shipped modules as read from the checkout, each folder under ../data +// holding a manifest, so these tests link no data package; cmd/fejkdata runs over the +// packages themselves. +var shipped = func() []fs.FS { + var modules []fs.FS + err := filepath.WalkDir(filepath.Join("..", "data"), func(path string, d fs.DirEntry, err error) error { + if err == nil && d.Name() == ".fejkdata.json" { + modules = append(modules, os.DirFS(filepath.Dir(path))) + } + return err + }) + if err != nil || len(modules) == 0 { + panic(fmt.Sprintf("no shipped module found under ../data: %v", err)) + } + return modules +}() // svSE, enUS and misc are copies of a shipped module's files without its manifest, so a test // layers its categories unprefixed with -d. @@ -70,25 +78,25 @@ func bareCopy(tmp, module string) string { func runOut(stdin string, args ...string) (int, string, string) { var out, errb bytes.Buffer - code := run(shipped, args, strings.NewReader(stdin), &out, &errb) + code := run("fejkdata", shipped, args, strings.NewReader(stdin), &out, &errb) return code, out.String(), errb.String() } // runTerminal runs as from a shell with nothing piped in. func runTerminal(args ...string) (int, string, string) { var out, errb bytes.Buffer - code := run(shipped, args, nil, &out, &errb) + code := run("fejkdata", shipped, args, nil, &out, &errb) return code, out.String(), errb.String() } func TestRunLoadsTheModulesItIsGiven(t *testing.T) { module := fstest.MapFS{"pet.json": {Data: []byte(`{"format":"{name}","name":["Fido"]}`)}} var out, errb bytes.Buffer - if code := run([]fs.FS{module}, nil, strings.NewReader("{/pet}"), &out, &errb); code != 0 || out.String() != "Fido" { + if code := run("fejkdata", []fs.FS{module}, nil, strings.NewReader("{/pet}"), &out, &errb); code != 0 || out.String() != "Fido" { t.Errorf("run over a module holding pet = %d, %q, %q, want Fido", code, out.String(), errb.String()) } out.Reset() - if code := run([]fs.FS{module}, []string{"--list"}, nil, &out, &errb); code != 0 || out.String() != "pet\npet.name\n" { + if code := run("fejkdata", []fs.FS{module}, []string{"--list"}, nil, &out, &errb); code != 0 || out.String() != "pet\npet.name\n" { t.Errorf("--list over a module holding pet = %d, %q, want pet and pet.name only", code, out.String()) } } @@ -331,7 +339,7 @@ func (failingReader) Read([]byte) (int, error) { return 0, errors.New("bad file func TestRunUnreadableStdinIsMisuse(t *testing.T) { var out, errb bytes.Buffer - if code := run(shipped, nil, failingReader{}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "cannot be read") { + if code := run("fejkdata", shipped, nil, failingReader{}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "cannot be read") { t.Errorf("run with an unreadable stdin = %d, %q; want misuse naming it", code, errb.String()) } } @@ -434,20 +442,42 @@ func TestRunHelpOnStdout(t *testing.T) { } } -func TestModuleVersionNamesFejkdatasVersion(t *testing.T) { - custom := &debug.BuildInfo{ - Main: debug.Module{Path: "example.com/mycli", Version: "v9.0.0"}, - Deps: []*debug.Module{{Path: "github.com/larvit/fejkdata", Version: "v0.1.0"}}, +func TestVersionLineNamesFejkdatasVersion(t *testing.T) { + fejkdata := func(version string, replace *debug.Module) *debug.Module { + return &debug.Module{Path: "github.com/larvit/fejkdata", Version: version, Replace: replace} } - own := &debug.BuildInfo{Main: debug.Module{Path: "github.com/larvit/fejkdata", Version: "v0.1.0"}} - checkout := &debug.BuildInfo{Main: debug.Module{Path: "github.com/larvit/fejkdata", Version: "(devel)"}} - for info, want := range map[*debug.BuildInfo]string{custom: "v0.1.0", own: "v0.1.0", checkout: "devel", nil: "devel"} { - if got := moduleVersion(info); got != want { - t.Errorf("moduleVersion(%+v) = %q, want %q", info, got, want) + mycli := debug.Module{Path: "example.com/mycli", Version: "v9.0.0"} + for _, c := range []struct { + info *debug.BuildInfo + want string + }{ + {&debug.BuildInfo{Main: *fejkdata("v0.1.0", nil)}, "fejkdata v0.1.0"}, + {&debug.BuildInfo{Main: *fejkdata("(devel)", nil)}, "fejkdata devel"}, + {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", nil)}}, "mycli v9.0.0, fejkdata v0.1.0"}, + {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", &debug.Module{Path: "../fejkdata"})}}, "mycli v9.0.0, fejkdata devel"}, + {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", &debug.Module{Path: "example.com/fork", Version: "v0.1.1"})}}, "mycli v9.0.0, fejkdata v0.1.1"}, + {nil, "fejkdata devel"}, + } { + if got := versionLine("mycli", c.info); got != c.want { + t.Errorf("versionLine(mycli, %+v) = %q, want %q", c.info, got, c.want) } } } +func TestRunNamesItselfByTheCommandName(t *testing.T) { + var out, errb bytes.Buffer + if code := run("mycli", shipped, []string{"--nope"}, nil, &out, &errb); code != 2 || errb.String() != "mycli: unknown flag --nope\ntry 'mycli --help'\n" { + t.Errorf("mycli --nope = %d, %q", code, errb.String()) + } + errb.Reset() + if code := run("mycli", shipped, nil, nil, &out, &errb); code != 2 || !strings.Contains(errb.String(), "| mycli\n") { + t.Errorf("mycli with nothing piped = %d, %q, want a pipe into mycli", code, errb.String()) + } + if code := run("mycli", shipped, []string{"--help"}, nil, &out, &errb); code != 0 || !strings.HasPrefix(out.String(), "Usage: