diff --git a/README.md b/README.md index c379e88..d3c6f34 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # fakes -A Go library for generating fake data, built for **internationalization**. It -exists because the existing Go fake libraries lacked the locale coverage and -format control we needed. +A Go library and CLI for generating fake data, built for +**internationalization**. It exists because the existing Go fake libraries +lacked the locale coverage and format control we needed. - **Standards first** — formats, names and structures follow international and local standards first and foremost. @@ -17,15 +17,62 @@ format control we needed. - **Reproducible** — seed a faker and it emits the same sequence every time. - **Zero dependencies** — standard library only. -## Install +## CLI + +Install the `fakes` command, then point it at a locale directory and a category +path — it prints one value to stdout. The first path segment names a category (a +JSON file); deeper dotted segments descend into it. ```sh -go get github.com/Timewave-AB/fakes +go install github.com/Timewave-AB/fakes/cmd/fakes@latest + +fakes ./locales/sv_SE person # Sara Eriksson +fakes ./locales/sv_SE person.last # Eriksson (dotted path into a category) +fakes -seed 42 ./locales/sv_SE address ``` -Requires Go 1.22+ (for `math/rand/v2`). +Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit +codes: `0` success, `1` runtime error (bad locale, unknown path), `2` misuse. -## Usage +### Generating a file from a custom template + +A category is just a JSON file in the locale directory, so you can drop in your +own and render it — no code change. Save this as `locales/sv_SE/sql.json`: + +```json +{ + "format": "INSERT INTO users V#ALUES({sql-username});", + "sql-username": { + "format": "'{username}'", + "repeat": 3, + "separator": "),(", + "username": ["pixelfox", "snork", "turbohund", "blip", "zoom", "wahoo"] + } +} +``` + +`sql-username` renders `'{username}'` `repeat` times and joins the results with +the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list. +(`#A` escapes the literal `A`, which a format string would otherwise read as a +letter token — see [Locale data format](#locale-data-format).) + +```sh +fakes -seed 1 ./locales/sv_SE sql +# INSERT INTO users VALUES('zoom'),('wahoo'),('blip'); +``` + +Raise `repeat` for more rows per statement, or loop in the shell to build a +whole seed file: + +```sh +for _ in $(seq 100); do fakes ./locales/sv_SE sql; done > seed.sql +``` + +## Library + +```sh +go get github.com/Timewave-AB/fakes # requires Go 1.22+ (for math/rand/v2) +``` Point `New` at a locale directory, then generate values by category path with `Fake`. The first path segment names a category (a JSON file); deeper dotted @@ -78,10 +125,10 @@ av == bv // true A `*Fakes` is **not** safe for concurrent use — create one per goroutine. -### Locales +## Locales The library ships a ready-to-use set under [`locales/`](locales) (`en_US`, -`sv_SE`). Pass `New` the path to one of them, a copy, or your own directory — +`sv_SE`). Point either tool at one of them, a copy, or your own directory — anywhere on disk. Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY` @@ -220,10 +267,11 @@ docker compose run --rm test # latest ## Layout ``` -fakes.go Fakes, New, options, seeding -template.go Fake, the recursive renderer (choices, format strings, paths) -locale.go locale loading + tag parsing -locales/ shipped locale data (JSON) +fakes.go Fakes, New, options, seeding +template.go Fake, the recursive renderer (choices, format strings, paths) +locale.go locale loading + tag parsing +cmd/fakes/ the `fakes` CLI (New + Fake over stdout) +locales/ shipped locale data (JSON) ``` To add a category, drop a JSON file into a locale directory; to add a locale, diff --git a/cmd/fakes/main.go b/cmd/fakes/main.go new file mode 100644 index 0000000..e0fc4c3 --- /dev/null +++ b/cmd/fakes/main.go @@ -0,0 +1,61 @@ +// Command fakes prints one fake value from a locale directory. +// +// fakes ./locales/sv_SE person # a full person +// fakes ./locales/sv_SE person.last # just the surname (dotted path) +// fakes -seed 42 ./locales/sv_SE address +// +// It is a thin CLI over the fakes library: New(dir) then Fake(path). +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/Timewave-AB/fakes" +) + +const usage = `Usage: fakes [-seed N] + + a locale directory, e.g. ./locales/sv_SE + a category, or a dotted path into one (person, person.last) + -seed N seed for reproducible output` + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +// run is main's testable core: it returns the process exit code (0 ok, 1 +// runtime error, 2 misuse) and writes only to the given streams. +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("fakes", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprintln(stderr, usage) } + seed := fs.Uint64("seed", 0, "seed for reproducible output") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 2 { + fs.Usage() + return 2 + } + + var opts []fakes.Option + fs.Visit(func(fl *flag.Flag) { + if fl.Name == "seed" { + opts = append(opts, fakes.WithSeed(*seed)) + } + }) + + f, err := fakes.New(fs.Arg(0), opts...) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + v, err := f.Fake(fs.Arg(1)) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + fmt.Fprintln(stdout, v) + return 0 +} diff --git a/cmd/fakes/main_test.go b/cmd/fakes/main_test.go new file mode 100644 index 0000000..2a879fa --- /dev/null +++ b/cmd/fakes/main_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +const svSE = "../../locales/sv_SE" + +// runOut runs the CLI and returns exit code, stdout, stderr. +func runOut(args ...string) (int, string, string) { + var out, errb bytes.Buffer + code := run(args, &out, &errb) + return code, out.String(), errb.String() +} + +func TestRunOutputsValue(t *testing.T) { + code, out, errb := runOut(svSE, "person") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + if strings.TrimSpace(out) == "" { + t.Fatalf("empty output, stderr=%q", errb) + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("output should end with newline, got %q", out) + } +} + +func TestRunDotPath(t *testing.T) { + // person.last descends to just a surname — never the full "First Last". + code, full, _ := runOut("-seed", "7", svSE, "person") + if code != 0 { + t.Fatalf("person run = %d", code) + } + code, last, errb := runOut("-seed", "7", svSE, "person.last") + if code != 0 { + t.Fatalf("person.last run = %d, stderr=%q", code, errb) + } + if strings.TrimSpace(last) == "" { + t.Fatal("empty person.last output") + } + if last == full { + t.Errorf("person.last %q should differ from person %q", last, full) + } +} + +func TestRunSeedDeterministic(t *testing.T) { + _, a, _ := runOut("-seed", "42", svSE, "address") + _, b, _ := runOut("-seed", "42", svSE, "address") + if a != b { + t.Errorf("same seed diverged: %q != %q", a, b) + } +} + +func TestRunUsageOnWrongArgCount(t *testing.T) { + for _, args := range [][]string{{}, {svSE}, {svSE, "person", "extra"}} { + code, _, errb := runOut(args...) + if code != 2 { + t.Errorf("run(%v) = %d, want 2", args, code) + } + if !strings.Contains(errb, "Usage") { + t.Errorf("run(%v) stderr = %q, want usage", args, errb) + } + } +} + +func TestRunUnknownCategoryFails(t *testing.T) { + code, _, errb := runOut(svSE, "nope") + if code != 1 { + t.Fatalf("run = %d, want 1", code) + } + if !strings.Contains(errb, "nope") { + t.Errorf("stderr %q should name the unknown category", errb) + } +} + +func TestRunBadLocaleFails(t *testing.T) { + code, _, errb := runOut("../../locales/sv", "person") + if code != 1 { + t.Fatalf("run = %d, want 1", code) + } + if errb == "" { + t.Error("want an error message on stderr") + } +}