RizTech Academy logo
RizTech Academy
Capstone: Tiffin Service TrackerLesson 4 of 535 min

The command line, and failing usefully

The domain refuses invalid data. The store reads and writes safely. What is left is the part the owner of the tiffin service actually touches — and the part where most command-line tools are unpleasant to use.

A good one does three things: it says what it can do, it fails in a way you can act on, and it tells the shell whether it worked.

main, and why it is two methods

public static void main(String[] args) {
    try {
        System.exit(run(args));
    } catch (IllegalArgumentException e) {
        System.err.println("Error: " + e.getMessage());
        System.exit(2);
    } catch (UncheckedIOException e) {
        System.err.println("Error: " + e.getMessage());
        System.exit(3);
    }
}

main calls System.exit, so it cannot be tested — a test calling it would terminate the JVM and take the test runner with it. run returns an int and can be called from a test all day.

The two catch blocks are the boundary module 7 kept pointing at. Everything below throws whatever is natural; exactly one place turns an exception into a message and an exit code.

Errors go to System.err. A user running tiffin-tracker report > bill.txt gets the bill in the file and the problems on screen, which is what they want and what a single println cannot give them.

Note what is not here: no stack trace. Error: tiffins must be between 0 and 4, got 9 is what a user should see. The stack trace is for a developer, and a --verbose flag is the usual way to offer it.

Dispatch

static int run(String[] args) {
    if (args.length == 0 || args[0].equals("help")) {
        usage();
        return args.length == 0 ? 1 : 0;
    }

    Path dataFile = Path.of(System.getProperty("tiffin.data", "deliveries.csv"));
    DeliveryStore store = new DeliveryStore(dataFile);

    return switch (args[0]) {
        case "add" -> add(store, args);
        case "list" -> list(store);
        case "report" -> report(store, args);
        default -> {
            System.err.println("Unknown command [" + args[0] + "]");
            usage();
            yield 1;
        }
    };
}

A switch expression returning the exit code, so every branch must produce one — the compiler will not let you forget.

No arguments is an error; help is not. Running the tool with nothing is a mistake and exits 1; asking for help is a success and exits 0. That distinction matters to a script.

The data file is configuration, not a constant. System.getProperty with a default means the tests and the demo can point it somewhere harmless, and the owner can keep her file anywhere:

java -Dtiffin.data=/Users/priya/tiffin.csv -jar tiffin-tracker.jar report

The commands

private static int add(DeliveryStore store, String[] args) {
    if (args.length != 4) {
        System.err.println("Usage: add <yyyy-mm-dd> <customer> <tiffins>");
        return 1;
    }
    Parsed<Delivery> parsed = store.load();
    List<Delivery> all = new ArrayList<>(parsed.rows());
    all.add(new Delivery(LocalDate.parse(args[1]), args[2], Integer.parseInt(args[3])));
    store.save(all);
    System.out.println("Added. " + all.size() + " deliveries on file.");
    return 0;
}

Argument count checked first, with the usage line for this command rather than the whole help text. Then the Delivery constructor does the validating — add contains no rules of its own, because they belong on the record.

private static int list(DeliveryStore store) {
    Parsed<Delivery> parsed = store.load();
    parsed.rows().forEach(d ->
            System.out.printf("%s  %-20s %d%n", d.date(), d.customer(), d.tiffins()));
    reportProblems(parsed);
    return parsed.hasProblems() ? 4 : 0;
}

private static void reportProblems(Parsed<Delivery> parsed) {
    if (parsed.hasProblems()) {
        System.err.println(parsed.problems().size() + " row(s) could not be read:");
        parsed.problems().forEach(p -> System.err.println("  " + p));
    }
}

Exit code 4: it worked, and something was wrong. The rows that could be read were printed; the ones that could not were reported. A single success-or-failure code cannot express that, and this is the case that justifies having five.

private static int report(DeliveryStore store, String[] args) {
    YearMonth month = args.length > 1 ? YearMonth.parse(args[1]) : YearMonth.now();
    Parsed<Delivery> parsed = store.load();
    reportProblems(parsed);

    BillingService billing = new BillingService(List.of(
            new Subscriber("Priya", "411207", Plan.VEG, LocalDate.of(2026, 9, 1)),
            new Subscriber("Arjun", "411014", Plan.JAIN, LocalDate.of(2026, 9, 1)),
            new Subscriber("Kavita", "411207", Plan.STUDENT, LocalDate.of(2026, 9, 1))));

    System.out.print(billing.renderReport(parsed.rows(), month));
    billing.busiestDay(parsed.rows())
            .ifPresent(d -> System.out.println("Busiest day: " + d));
    return parsed.hasProblems() ? 4 : 0;
}

The subscriber list is hard-coded, and that is a deliberate scope decision: the plan said add, list and report, with no subscriber management. The right place for a shortcut is one you could name, and this one is "subscribers are seeded in code until there is a command to manage them".

ifPresent means an empty month prints no busiest-day line rather than Busiest day: null.

The help text

private static void usage() {
    System.out.println("""
            tiffin-tracker

              add <yyyy-mm-dd> <customer> <tiffins>   record a delivery
              list                                    show every delivery
              report [yyyy-mm]                        bill for a month
              help                                    this message

            Data file: -Dtiffin.data=<path> (default deliveries.csv)""");
}

A text block, aligned, with square brackets marking the optional argument. Help text is the user interface of a command-line tool — it is worth as much care as a screen would get.

Running it

java -jar target/tiffin-tracker.jar add 2026-09-01 Priya 2
java -jar target/tiffin-tracker.jar add 2026-09-01 Arjun 1
java -jar target/tiffin-tracker.jar add 2026-09-02 Kavita 3
Added. 1 deliveries on file.
Added. 2 deliveries on file.
Added. 3 deliveries on file.

The file it wrote:

date,customer,tiffins
2026-09-01,Priya,2
2026-09-01,Arjun,1
2026-09-02,Kavita,3

Readable, editable, and something the owner could open in a spreadsheet — which was the reason for choosing CSV in the plan.

java -jar target/tiffin-tracker.jar report 2026-09
Tiffin bill - 2026-09
=====================
Customer               Tiffins        Amount
Arjun                        1      Rs 91.00
Kavita                       3     Rs 222.36
Priya                        2     Rs 164.70
Total                              Rs 478.06
Busiest day: 2026-09-01

Alphabetical, aligned, plan prices applied — Kavita is on the student plan at Rs 74.12, Arjun on Jain at Rs 91.

Failing usefully

Two bad rows appended to the file by hand:

2026-09-03,Amit,99
2026-09-04,Rahul
2 row(s) could not be read:
  line 5: tiffins must be between 0 and 4, got 99
  line 6: expected 3 fields, found 2
Tiffin bill - 2026-09
=====================
...
(exit code 4)

The report still ran. Both problems named, with line numbers the owner can find in her spreadsheet, and an exit code saying "done, but look at this".

An invalid argument:

java -jar target/tiffin-tracker.jar add 2026-09-05 Priya 9
Error: tiffins must be between 0 and 4, got 9
(exit code 2)

That message came from the Delivery compact constructor, four layers down, unchanged. It names the rule, the value, and nothing else. Nothing in Main knows what the tiffin limit is, which is why changing it means editing one file.

An unknown command:

Unknown command [frobnicate]
tiffin-tracker

  add <yyyy-mm-dd> <customer> <tiffins>   record a delivery
  ...
(exit code 1)

The mistake is quoted in brackets — so a stray space is visible — and the help follows, because somebody who got the command wrong wants the list.

Check your work

Why does main delegate to run? main calls System.exit, which would terminate a test runner. run returns an exit code and is testable.

Why do errors go to System.err? So a user redirecting output to a file still sees the problems.

Why no stack trace for a bad argument? It is for a developer, not a user. Error: tiffins must be between 0 and 4, got 9 is actionable; forty frames are not.

Why is no argument exit 1 but help exit 0? Running the tool with nothing is a mistake; asking for help is what the user intended. A script can tell the difference.

Why is the data file a system property rather than a constant? Configuration is an argument. Hard-coded, the same build cannot run in two places, and the tests could not point it somewhere harmless.

What does exit code 4 mean here? The command succeeded and some rows were unreadable. A single failure code cannot express it.

Where does tiffins must be between 0 and 4 come from? The Delivery compact constructor, unchanged through four layers. Main does not know the limit.

Why ifPresent for the busiest day? An empty month has none, and printing Busiest day: null would be worse than printing nothing.

Practice 3, the exit codes. echo $? gives 0 after a clean report, 1 after an unknown command or no arguments, 2 after an invalid argument, 3 if the data file cannot be read, and 4 after a report over a file with bad rows. If yours returns 0 for the bad-rows case, the report looks successful to a CI job that should have flagged it.

Practice 5, the redirect. report > bill.txt should put the bill in the file and the problem list on the terminal. If the problems end up in the file, they are going to System.out — and the owner's bill now has error messages in it.

Practice

  1. Write main and run. The switch, the two catch blocks, the exit codes. Confirm run can be called from a test without ending the JVM.

  2. Write the three commands. Then run the full sequence: three adds, a list, and a report. Compare your output with this lesson's.

  3. Check every exit code. Run each failure case and print echo $? after it. All five should be distinct.

  4. Break the data file by hand. Append a row with 99 tiffins and one with a missing field. Confirm the report still runs, names both problems with the right line numbers, and exits 4.

  5. Redirect the output. report > bill.txt with a broken row in the file. Confirm the bill is in the file and the problems are on the screen.

  6. Harder — add a command. tiffin-tracker customers listing the subscribers with their plans and prices. Then move the hard-coded subscriber list into its own CSV, loaded by a SubscriberStore that reuses the same Parsed<T> and the same bad-row reporting. Notice how little of the existing code changes — and if a lot of it does, the layers were not as separate as they looked.

Next: the full test suite, and packaging a jar you can hand to somebody.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship