Refactoring Tally, modules and CLI in Rust

2022-02-17T22:07:26.818905

Tally is a logging time tracker pomorodo that I wrote in Rust last fall. I have wanted to revisit it since I actually use it everyday and while it doesn't have many issues for my particular use case it should be refactored to objectively better code. I wrote it it in a procedural manner: you run the program, it asks you questions, you answer them, it sets up a timer based on your input. The result was not only a command line app that doesn't act like other command line apps (which I can almost forgive), but a codebase not easy to change due to its interconnected pieces, very poor separation of concerns, and functions doing too many things. I thought this would be a great opportunity to accomplish two tasks:

  1. use the Rust module system to split the codebase up in bite-size name-spaced chunks
  2. use the clap library to make it a command line app more in keeping with the unix philosophy.

I have come to believe that learning how to properly split a project up into multiple files is one of most effective things one can do when learning a language. Is it possible it should be included earlier in the learning and tutorial process included with the basics like variables?

Yes it is possible.

On that note the Rust module system is quite nice once you wrap your brain around what is happening. The code inside any file (besides main.rs and lib.rs) in your src/ directory are effectively modules. You declare them in your main.rs / lib.rs file like so:

// this refers to pomodoro.rs, task.rs, and utility.rs

pub mod pomodoro;
pub mod task;
pub mod utility;

In classic Rust fashion, just how all the variables are immutable by default, every layer of the module system in Rust is private by default, so you not only have to declare your modules public but any functions you want to call that are inside of them. To use anything in these modules in any of your code (including inside other modules), you simply need to declare them with:

// this makes Pomodoro struct & Tasks enum available from their respective modules
// the utility module is also available which contains a couple of functions
// again, these structs, enums, and functions need to be declared public in their module

use crate::pomodoro::Pomodoro;
use crate::task::Tasks;
use crate::utility;

Now main function can actually have some breathing room, a necessary precursor to wanted to add a whole new library to the application. Clap provides convenient command line argument parsing.

Thinking about command line apps: it feels to me the one necessary feature is to be able to handle the -h --help flags. Past that it is all personal preference, but you need that to ground the app in the ecosystem it exists in; this lends itself to natural discoverability.

There are two different approaches to building applications with clap since the 3.0.0 release: [builder, derive]. I used builder because it seems a little more logical to me. I think I took the simplest possible approach to my logic, but I just wanted to get something working (and I haven't used Rust in a bit). Despite working in Javascript and Ruby mostly the last six months, I find Rust to be a deeply satisfying experience. Makes me interested in trying out Typescript or Crystal to get a little bit more of that compiler type-checking in my life. Here is the cleaned up main.rs:

use clap::{arg, Command};
use std::{
    fs,
    io::{self, Write},
    process::Command as ShellCommand,
};

pub mod pomodoro;
pub mod task;
pub mod utility;
use crate::pomodoro::Pomodoro;
use crate::task::Tasks;

fn main() -> Result<(), std::fmt::Error> {
    let matches = Command::new("Tally")
        .author("Tim Taylor")
        .version("0.8")
        .about("Time tracking and logging")
        .arg(arg!(-l --list "lists task options"))
        .arg(arg!(-t --task [ID] "set the task"))
        .arg(arg!(-p --parse "parse & display the logs"))
        .get_matches();

    if matches.is_present("list") {
        Tasks::print_list();
    } else if matches.is_present("parse") {
        let output = ShellCommand::new("ruby")
            .arg("parse.rb")
            .output()
            .expect("failed to run parse.rb");
        io::stdout().write_all(&output.stdout).unwrap();
    } else if let Some(task) = matches.value_of("task") {
        let mut file = fs::OpenOptions::new()
            .write(true)
            .append(true)
            .open("logs.txt")
            .expect("Failed to open logs.txt");
        let task = Tasks::choose(task);
        let pom = Pomodoro::new(25, 5, task);
        writeln!(file, "{}", pom).ok();
    }

    Ok(())
}

My most recent Tally:

Since Dec 6, 2021 Total: 64 hours, 35 minutes ////////////////// Chess: 0.6%; 0 hours, 25 minutes Ruby: 18.1%; 11 hours, 40 minutes Javascript: 34.2%; 22 hours, 5 minutes Design: 14.2%; 9 hours, 10 minutes Learning: 5.8%; 3 hours, 45 minutes Meditate: 4.5%; 2 hours, 55 minutes Journal: 7.7%; 5 hours, 0 minutes Website: 2.6%; 1 hours, 40 minutes Music: 3.9%; 2 hours, 30 minutes Monome: 7.7%; 5 hours, 0 minutes Rust: 0.6%; 0 hours, 25 minutes

Nice to see Rust back on the list.