Mojo CLI Tool Project

Building a complete command-line tool ties together everything from this course — argument parsing, file I/O, error handling, structs, functions, string formatting, and output. This topic walks through constructing a real-world CSV statistics tool called mojostat that reads a numeric CSV column and reports count, sum, mean, min, max, and standard deviation.

What We Are Building

  $ magic run mojo mojostat.mojo scores.csv --column 1 --verbose

  Output:
  ─────────────────────────────────
   mojostat — Column Statistics
  ─────────────────────────────────
   File   : scores.csv
   Column : 1
   Count  : 5
   Sum    : 447.00
   Mean   : 89.40
   Min    : 78.00
   Max    : 97.00
   Std Dev: 7.09
  ─────────────────────────────────

  Tool structure:
  ├── parse_args()   — read CLI arguments
  ├── read_column()  — parse CSV and extract one column
  ├── Statistics     — struct holding computed stats
  ├── compute()      — fill Statistics from raw values
  └── print_report() — format and display the result

Step 1: Argument Parsing

from sys import argv

struct Config:
    var filepath:  String
    var column:    Int
    var verbose:   Bool
    var separator: String

    fn __init__(inout self):
        self.filepath  = ""
        self.column    = 0
        self.verbose   = False
        self.separator = ","

fn parse_args() raises -> Config:
    var args = argv()
    var cfg  = Config()

    if len(args) < 2:
        raise Error(
            "Usage: mojostat <file.csv> [--column N] [--sep CHAR] [--verbose]"
        )

    cfg.filepath = args[1]

    var i = 2
    while i < len(args):
        if args[i] == "--column" and i + 1 < len(args):
            cfg.column = Int(args[i + 1])
            i += 2
        elif args[i] == "--sep" and i + 1 < len(args):
            cfg.separator = args[i + 1]
            i += 2
        elif args[i] == "--verbose":
            cfg.verbose = True
            i += 1
        else:
            i += 1

    return cfg

Step 2: Reading the CSV Column

fn read_column(filepath: String, col_index: Int, sep: String) raises -> List[Float64]:
    var values = List[Float64]()

    with open(filepath, "r") as f:
        var header = f.readline()   # skip header row
        if header == "":
            raise Error("File is empty: " + filepath)

        var line = f.readline()
        var row_num = 1

        while line != "":
            var trimmed = line.strip()
            if trimmed != "":
                var parts = trimmed.split(sep)

                if col_index >= len(parts):
                    raise Error(
                        "Row " + String(row_num) +
                        " has only " + String(len(parts)) +
                        " columns — cannot read column " + String(col_index)
                    )

                try:
                    var val = Float64(parts[col_index].strip())
                    values.append(val)
                except:
                    raise Error(
                        "Non-numeric value '" + parts[col_index] +
                        "' at row " + String(row_num) +
                        ", column " + String(col_index)
                    )

                row_num += 1

            line = f.readline()

    if len(values) == 0:
        raise Error("No numeric data found in column " + String(col_index))

    return values

Step 3: Statistics Struct

from math import sqrt

struct Statistics:
    var count:   Int
    var sum:     Float64
    var mean:    Float64
    var minimum: Float64
    var maximum: Float64
    var std_dev: Float64

    fn __init__(inout self):
        self.count   = 0
        self.sum     = 0.0
        self.mean    = 0.0
        self.minimum = 0.0
        self.maximum = 0.0
        self.std_dev = 0.0

fn compute(values: List[Float64]) -> Statistics:
    var stats = Statistics()
    stats.count = len(values)

    if stats.count == 0:
        return stats

    stats.minimum = values[0]
    stats.maximum = values[0]

    for i in range(stats.count):
        var v = values[i]
        stats.sum += v
        if v < stats.minimum: stats.minimum = v
        if v > stats.maximum: stats.maximum = v

    stats.mean = stats.sum / Float64(stats.count)

    var variance: Float64 = 0.0
    for i in range(stats.count):
        var diff = values[i] - stats.mean
        variance += diff * diff
    stats.std_dev = sqrt(variance / Float64(stats.count))

    return stats

Step 4: Formatted Report

from python import Python

fn fmt2(value: Float64) raises -> String:
    var py = Python.import_module("builtins")
    return str(py.str("{:.2f}").format(value))

fn print_report(cfg: Config, stats: Statistics) raises:
    var border = "─" * 34
    print(border)
    print(" mojostat — Column Statistics")
    print(border)
    print(" File   : " + cfg.filepath)
    print(" Column : " + String(cfg.column))
    print(" Count  : " + String(stats.count))
    print(" Sum    : " + fmt2(stats.sum))
    print(" Mean   : " + fmt2(stats.mean))
    print(" Min    : " + fmt2(stats.minimum))
    print(" Max    : " + fmt2(stats.maximum))
    print(" Std Dev: " + fmt2(stats.std_dev))
    print(border)

Step 5: Main Entry Point

fn main() raises:
    try:
        var cfg    = parse_args()

        if cfg.verbose:
            print("[verbose] Reading:", cfg.filepath,
                  "| column:", cfg.column,
                  "| sep: '" + cfg.separator + "'")

        var values = read_column(cfg.filepath, cfg.column, cfg.separator)

        if cfg.verbose:
            print("[verbose] Loaded", len(values), "values")

        var stats  = compute(values)
        print_report(cfg, stats)

    except e:
        print("Error:", str(e))
        return

Sample Data File

# scores.csv
name,score,attempts
Alice,97,3
Bob,82,5
Carol,91,2
David,78,4
Elena,99,1

Running the Tool

# Basic run (column 0 = name — will fail on non-numeric, shows error handling)
magic run mojo mojostat.mojo scores.csv --column 0

# Correct: column 1 = scores
magic run mojo mojostat.mojo scores.csv --column 1

# With verbose output
magic run mojo mojostat.mojo scores.csv --column 1 --verbose

# Custom separator (semicolon-separated file)
magic run mojo mojostat.mojo data.csv --column 2 --sep ";"

Output for --column 1:

──────────────────────────────────
 mojostat — Column Statistics
──────────────────────────────────
 File   : scores.csv
 Column : 1
 Count  : 5
 Sum    : 447.00
 Mean   : 89.40
 Min    : 78.00
 Max    : 99.00
 Std Dev: 7.81
──────────────────────────────────

Project Architecture Review

  main()
    │
    ├── parse_args()    → Config struct
    │     └── argv(), Int(), string parsing
    │
    ├── read_column()   → List[Float64]
    │     └── open(), readline(), split(), Float64()
    │         error handling for bad rows
    │
    ├── compute()       → Statistics struct
    │     └── single-pass min/max/sum, two-pass std dev
    │
    └── print_report()  → terminal output
          └── fmt2() for 2-decimal formatting, Python interop

Extending the Tool

Ideas for extension exercises:
  1. Add --output flag to write report to a file
  2. Add --skip-header flag for files without a header row
  3. Support multiple columns in one run: --columns 1,2,3
  4. Add median computation (requires sorting the values)
  5. Add histogram output using asterisks in the terminal
  6. Add --json flag to output results as JSON for piping to other tools

Key Takeaways

A real CLI tool combines argument parsing, file I/O, data validation, computation, and formatted output into a single coherent program. Separating each concern into its own function or struct makes the code testable, readable, and easy to extend. A Config struct carries all parsed settings through the program cleanly. Error handling at every I/O boundary — argument parsing, file reading, type conversion — turns a fragile script into a robust tool. This project structure (parse → load → compute → report) applies to data pipelines of any size.

Leave a Comment

Your email address will not be published. Required fields are marked *