Add fakes CLI (cmd/fakes); document it README-first with an SQL template example

This commit is contained in:
lilleman
2026-06-08 22:04:18 +02:00
parent 3e1cd3ee2a
commit a765230d3b
3 changed files with 209 additions and 13 deletions
+57 -9
View File
@@ -1,8 +1,8 @@
# fakes # fakes
A Go library for generating fake data, built for **internationalization**. It A Go library and CLI for generating fake data, built for
exists because the existing Go fake libraries lacked the locale coverage and **internationalization**. It exists because the existing Go fake libraries
format control we needed. lacked the locale coverage and format control we needed.
- **Standards first** — formats, names and structures follow international and - **Standards first** — formats, names and structures follow international and
local standards first and foremost. 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. - **Reproducible** — seed a faker and it emits the same sequence every time.
- **Zero dependencies** — standard library only. - **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 ```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 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 `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. 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`, 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. anywhere on disk.
Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY` Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY`
@@ -223,6 +270,7 @@ docker compose run --rm test # latest
fakes.go Fakes, New, options, seeding fakes.go Fakes, New, options, seeding
template.go Fake, the recursive renderer (choices, format strings, paths) template.go Fake, the recursive renderer (choices, format strings, paths)
locale.go locale loading + tag parsing locale.go locale loading + tag parsing
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
locales/ shipped locale data (JSON) locales/ shipped locale data (JSON)
``` ```
+61
View File
@@ -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] <locale-dir> <path>
<locale-dir> a locale directory, e.g. ./locales/sv_SE
<path> 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
}
+87
View File
@@ -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")
}
}