Flargs
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:
-
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. -
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.
-
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)
}
}