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
This commit is contained in:
+64
-45
@@ -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
|
||||
}
|
||||
|
||||
+53
-23
@@ -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: <template> | mycli [flags]\n") || strings.Contains(out.String(), "fejkdata") {
|
||||
t.Errorf("mycli --help = %d, %q, want mycli named throughout", code, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersion(t *testing.T) {
|
||||
code, out, errb := runOut("", "--version")
|
||||
if code != 0 {
|
||||
|
||||
Reference in New Issue
Block a user