Thoth While working on a small CLI for encrypting and decrypting files, I found myself unstatisfied with the practice of writing, and testing, and reasoning about a CLI in Go. The flag package was not pleasant to work with. Testing was hard and inelegant, and the whole concept of a CLI is leaky. So I pulled out my hair, and cursed at the sky.

Then, I distilled the problem down its constiuent parts, and wrapped them in Flargs. At it’s heart, Flargs is a very simple framework that forces you to do a few things, and in return gives you ease, power, and reproducibility. This is the Hermetic promise: If you never touch the world, you will become divine.

Flargs has three run-time phases:

  1. Parsing. This execution phase gives you no access to your command or the environment it will run against. All you have is a “Konf”, a []string{}, and the logic to turn the latter into the former. If you return an error here, the program stops.

  2. Loading. In this phase you do have access to the environment. Here you can decide if you Konf makes sense in this environment. For instance, you can check to see that a file actually exists. If you return an error, execution stops.

  3. Running. This is actually what makes your CLI useful. The run phase has no access to the original []string{}. It has a Konf and an Environment. Flargs has 3 basic concepts:

Konf

The custom structure that represents inputs transformed by Parsing and Loading. You control what it looks like. The sky’s the limit. But you need to embed flargs.StateMachine like so:

type MyKonf struct {
	host string
	port int
	flargs.StateMachine
}

State Machine

This is a struct with a few properties, allowing for chainability. It also contains all the methods required to satisy this interface:

type Flarger interface {
	Parse([]string) error
	Load(*Environment) error
	Run(*Environment) error
}

And here is the struct that implements Flarger:

type StateMachine struct {
	RemainingArgs []string
	Phase         Phase
}
//	no op
func (s *StateMachine) Parse(a []string) error {
	...
	return nil
}
//	no op
func (s *StateMachine) Load(_ *Environment) error {
	...
	return nil
}
//	no op
func (s *StateMachine) Run(_ *Environment) error {
	...
	return nil
}

Since you embdedded this struct into your struct, you get these methods for free. But they are all no-ops. So to get interesting functionality you’ll want to write your own Run() method at least. To accept paramers, you’ll want Parse() at least. You may decide that Load() is not needed. You can simply ignore it.

Environment

It looks like this:

type Environment struct {
	InputStream  io.ReadWriter
	OutputStream io.ReadWriter
	ErrorStream  io.ReadWriter
	Randomness   io.Reader
	Filesystem   fs.FS
	Variables    map[string]string
}

Command

A command is a Konf with and Environment, and contains enough information to run and be useful.

Getting Started

Get the package:

go get github.com/sean9999/go-flargs

Let’s say we want to create a CLI that performs rot13 on text passed to it via stdin, ignoring any arguments or flags. Our Konf becomes simple, because it doesn’t need to hold and data:

import (
	"github.com/sean9999/go-flargs"
)

type RotKonf struct {
	flargs.StateMachine
}

We don’t need to define a Parse() or Load() method. But we do want Run():

func (r *RotKonf) Run(env *flargs.Environment) error {

	//	rotate a rune
	rot13 := func(r rune) rune {
		switch {
		case r >= 'A' && r <= 'Z':
			return 'A' + (((r - 'A') + 13) % 26)
		case r >= 'a' && r <= 'z':
			return 'a' + (((r - 'a') + 13) % 26)
		default:
			return r
		}
	}

	//	read in input rune by rune
	runeStream := bufio.NewReader(env.InputStream)
	result := []byte{}
	for {
		if c, _, err := runeStream.ReadRune(); err != nil {
			break
		} else {
			result = append(result, byte(rot13(c)))
		}
	}

	//	write result
	env.OutputStream.Write(result)
	return nil
}

The alogorithm isn’t very interesting. What’s noteworthy is that there is no reference os.Stdin, and there is no fmt.Println() call. This is important. Every flarg app needs to be hermetic. No matter, we still have a fully functional CLI:

k := new(RotKonf)
env := flargs.NewCLIEnvironment("") // no filesytem needed
cmd := flargs.NewCommand(env, k)
cmd.ParseAndLoad(nil) // no arguments needed
cmd.Run()

That’s our CLI in it’s “real” form. A utility that can be built and run in a terminal. Let’s build and run:

go build -o rot13
cat /etc/passwd | ./rot13

That worked! Or at least it did for me. But we want more. Let’s write a test:

func TestRot13(t *testing.T) {

	inputText := []byte("neon craal nowhere germ, pening ebbs balk.")
	want := []byte("arba penny abjurer trez, cravat roof onyx.")

	k := new(rot13.RotKonf)
	env := flargs.NewTestingEnvironment(nil) //  no randomness needed
	cmd := flargs.NewCommand(k, env)
	cmd.ParseAndLoad(nil)

	//	send some text to "stdin"
	env.InputStream.Write(inputText)

	err := cmd.Run()
	if err != nil {
		t.Error(err)
	}

	got := env.GetOutput()

	if !bytes.Equal(got, want) {
		t.Errorf("wanted %s but got %s", want, got)
	}
}

Here we used a convenience method to get at the values in env.OutputStream. This pattern has served me well and has resulted in less hair-pulling.

In conclusion, flargs provides pleasant, fragrant, parsimonious parsing of flags and arguments, resulting in clean, testable, hermetic apps that provide feasible breeze and are easy to reason about.