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
}

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 CLI that performs rot13 on text passed to it via stdin. First off, we don’t need to worry about any arguments or flags. That’s handy. Our Konf then is simple:

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 is noteworthy is that there is no reference os.Stdin, and there is no fmt.Println() call. But we still have a fully functioning utility by injecting an environment:

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

Now our functionality is fully encapsulated in a way that does not depend on the environment. Let’s write a test:

func TestRot13(t *testing.T) {

	inputText := []byte("neon penny nowhere germ, pening roof balk.")
	want := []byte("arba craal abjurer trez, cravat ebbs 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)
	}
}