Making My Own Fortune Collection Using Go
Everyone loves a good Cowsay. I enjoy opening my terminal and being greeted by my favorite little ASCII cow, spouting some random quote.
But the quotes never make any sense! They’re not funny, they’re not insightful – honestly, they’re barely coherent.
When the cow catches the eyes of my coworkers, they usually ask,
âWhat is that?â
And I say,
âItâs a cow. Every time I open my terminal, he greets me with a random quote.â
âAwesome! Letâs read the quote!â they reply. Our reaction to the quote:
The random quotes come from something called a fortune – a classic Unix program that dates back to the 1970s. I installed it the easy way using Homebrew: brew install fortune
But I wanted custom fortunes. Ones I enjoy. Ones I collect over time. Like Pokémon.
Sure, I couldâve just downloaded the collection from JKirchartzâs repo, pointed the script to that folder, and called it a day. But whereâs the fun in that? I wanted to write my own script: something that could randomly select a file of fortunes, read its contents, split it by %, and return a random one. I got this logic from the Wiki. #credible
I couldâve done it in a language I know and love, like Python. But I didnât.
Lately, Iâve been exploring other languages like Go, and frankly, I was in desperate need of a project to help me learn. This little fortune project? It was the perfect fit.
đ” Building my own fortune using Go
My main idea is to have a fortunes directory in the same path as the script. This directory will contain a bunch of text files with all my fortunes.
The first step is to write something that ensures this folder exists. In the main() function, I do this by calling my own dirExists function:
func dirExists(path string) (bool, error) {
if _, err := os.Stat(path); err == nil {
return true, nil
} else if os.IsNotExist(err) {
return false, nil
} else {
return false, err
}
}
func main() {
// DECLARE SOME VARIABLES
FORTUNE_COLLECTION_DIR := "fortunes"
// ====> CHECK TO MAKE SURE THE FORTUNE DIRECORY EXISTS
if exists, err := dirExists(FORTUNE_COLLECTION_DIR); err != nil {
log.Fatal(err)
} else if !exists {
log.Fatal("Could not find your fortune directory")
}
}
Go has a very interesting mechanic for handling errors. Most functions return the result you want and an err. You then evaluate the error and decide whether to panic and exit the program. You can either log it with log.Fatal, or panic with panic(). Yes, that function exists. I mostly use log.Fatal since it includes a timestamp. panic() seems more verbose.
So I stuck to this pattern when creating the dirExists function.
Right. We checked if the folder exists. Next step is to list all the fortune files and to choose a random *.txt file:
fortuneFiles := listFortuneFiles(FORTUNE_COLLECTION_DIR)
fortuneFile := chooseRandomElement(fortuneFiles)
Here’s how listFortuneFiles works:
func listFortuneFiles(path string) []string {
var fortuneFiles []string
items, err := os.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, item := range items {
if !item.IsDir() && strings.HasSuffix(item.Name(), ".txt") {
fortuneFiles = append(fortuneFiles, item.Name())
}
}
return fortuneFiles
}
Here, I tried something different. Instead of returning the error, I handle it inside the function â similar to how many Python functions behave. Just playing around with different design patterns.
This function loops over the items returned by os.ReadDir, filters out anything that’s not a .txt file, and appends valid files to a fortuneFiles slice.
Oh, and one more thing: all functions in this main package are private. How can you tell? In Go, a function is private if it starts with a lowercase letter. If I had named it ListFortuneFiles, it wouldâve been public. Since I’m not exporting anything, this doesn’t matter â but itâs an interesting mechanic.
After we get our slice of strings, we need to choose a random one. In Go, there is no random.choice(list) function like in Python. Or maybe there is. I’m new to this. Anyway. Let’s make our own one called chooseRandomElement
func chooseRandomElement(slice []string) string {
return slice[rand.Intn(len(slice))]
}
It’s really simple. It takes a slice of strings and gets the index of one of the slices based on a random integer derived from the length of the slice.
Now for the juicy part. Reading in the fortunes from the fortunes file.
fortunes := readFortunes(filepath.Join(FORTUNE_COLLECTION_DIR, fortuneFile))
I like using filepath.Join to build paths – it’s the safest, most portable way to do it. I was thrilled to find that Go has this built-in.
So readFortunes looks something like this:
func readFortunes(path string) []string {
var (
fortunes []string
chunk []string
)
rawFile, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
// Damn Windows
rawFileString := strings.ReplaceAll(string(rawFile), "\r\n", "\n")
lines := strings.Split(rawFileString, "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "%" {
// To account for a % at the top with no content above it
if len(chunk) > 0 {
// Rebuild the par and add to the final slice
fortunes = append(fortunes, strings.Join(chunk, "\n"))
// reset
chunk = []string{}
}
} else {
// we're still building the chunk
chunk = append(chunk, line)
}
}
// account for if there is no % at the very end of the file
if len(chunk) > 0 {
fortunes = append(fortunes, strings.Join(chunk, "\n"))
}
return fortunes
}
Why all this logic? It has to do with an important mechanic behind fortunes. When you open a “classic” fortune file, it will look like this:
%
Fortune text goes here.
- by Happybread
%
Another fortune text goes here.
And this is the end
%
The separator in a fortune file is a lone % character. When we pick a fortune, it must preserve the newlines of the fortune:
Fortune text goes here.
- by Happybread
Some files start with %, some donât. Some end with %, some donât. So I wrote readFortunes to account for that.
The function builds fortunes by appending lines into a chunk slice until it hits a % separator. When it does, it joins the chunk with newlines and appends it to the fortunes slice, then resets the chunk. The final check for len(chunk) > 0 ensures the last fortune is included even if the file doesn’t end with %.
Once we’ve read the fortunes, we pick one at random:
fortune := chooseRandomElement(fortunes)
fmt.Println(fortune)
đ Building a GitHub Action to build the binary
I want other people to use my script as well. I want them to download it and run it on any operating system they want. Oh! Wait! Go is a compiled language. But if I compile it in WSL, can someone using Windows use my script? Yes!
To build for Windows, you can run
GOOS=windows GOARCH=amd64 go build -o bin/fortune.exe fortune.go
To build for Linux, you can run
GOOS=linux GOARCH=amd64 go build -o bin/fortune fortune.go
Great! Now I want this build to run every time I push to main. Luckily, GitHub actions can do this for us! We add an action into our .github folder and configure something like this:
on:
push:
branches:
- main # Runs on every push to main
Then we can very quickly get Go installed onto our job:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
then we can create a /bin folder and build both binaries and save the output to this directory
- name: Set a build directory
run: mkdir -p bin
- name: Build Linux binary
run: |
GOOS=linux GOARCH=amd64 go build -o bin/fortune fortune.go
- name: Build Windows binary
run: |
GOOS=windows GOARCH=amd64 go build -o bin/fortune.exe fortune.go
Lastly, we can save the output as a GitHub release:
- name: Delete existing latest tag if exists
run: |
git tag -d latest || true
git push origin :refs/tags/latest || true
- name: Create latest tag
run: |
git config user.name "github-actions"
git config user.email "[email protected]"
git tag latest
git push origin latest
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: latest
name: "Latest Release"
draft: false
prerelease: false
files: |
bin/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
đą Deploying to Production
To get the Cowsay when your terminal starts, add the fortune binary and fortunes folder to your $HOME directory. Then open your .bashrc file and add the following to the file:
talk(){
 ./fortune | cowsay
}
talk
If you’re on Windows, use ./fortune.exe instead. Then restart your terminal and now you should see:
Now to the last step… growing my collection!
If you want to have a look at my source code, go visit the repo: github.com/Johandielangman/fortunes