Capstone: todo CLI
| What the tool does | |
| Storage in a module | |
| Commands and dispatch | |
| Tests for the store | |
| Where to take it |
What the tool does
The first capstone is a todo list in the terminal. It is small enough to finish in an evening and it uses almost every earlier lesson: options, files, JSON, references, modules and tests.
todo/
├── todo
├── lib/Todo/Store.pm
└── t/store.t
The command set stays tiny on purpose. Four verbs, one file of data, and no database server to install:
./todo add "Write lesson 46"
added #1
./todo list
[ ] 1 Write lesson 46
The data lives in $HOME/.todo.json, so the tool works from any folder. --file overrides the path, which is what the tests will use.
Storage in a module
Keep reading and writing in one module, so the command code never touches a file handle. The store knows the JSON format; everything else works with plain array references.
package Todo::Store;
use strict;
use warnings;
use JSON::PP;
use Exporter 'import';
our @EXPORT_OK = qw(load_todos save_todos);
sub load_todos {
my ($path) = @_;
return [] unless -e $path;
open my $fh, "<:raw", $path
or die "cannot read $path: $!\n";
my $text = do { local $/; <$fh> };
return JSON::PP->new->utf8->decode($text);
}
sub save_todos {
my ($path, $todos) = @_;
my $json = JSON::PP->new->utf8->canonical->pretty;
my $tmp = "$path.tmp";
open my $fh, ">:raw", $tmp
or die "cannot write $tmp: $!\n";
print $fh $json->encode($todos);
close $fh or die "cannot close $tmp: $!\n";
rename $tmp, $path
or die "cannot replace $path: $!\n";
}
1;
Writing to a temporary file and renaming it means a crash cannot leave a half-written list behind. The rename is atomic on the same filesystem, which is the one property that matters here.
Commands and dispatch
The script reads global options, then takes the first remaining word as a command and looks it up in a hash of subroutines. Adding a command later means adding one entry, not another branch in an if-chain.
#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Long;
use List::Util 'max';
use FindBin;
use lib "$FindBin::Bin/lib";
use Todo::Store qw(load_todos save_todos);
my $path = "$ENV{HOME}/.todo.json";
GetOptions("file=s" => \$path)
or die "usage: todo [--file PATH] COMMAND ...\n";
my %cmd = (
add => \&cmd_add,
list => \&cmd_list,
done => \&cmd_done,
remove => \&cmd_remove,
);
my $command = shift @ARGV // "list";
my $run = $cmd{$command}
or die "unknown command: $command\n";
$run->(@ARGV);
Each command is short enough to read at a glance. Adding a todo assigns the next free number, list prints one aligned line per record, done flips the flag on one record, and remove filters a record out:
sub cmd_add {
my @words = @_;
die "usage: todo add TITLE\n" unless @words;
my $todos = load_todos($path);
my $id = 1 + max(0, map { $_->{id} } @$todos);
push @$todos, {
id => $id,
title => join(" ", @words),
done => 0,
};
save_todos($path, $todos);
print "added #$id\n";
}
sub cmd_list {
my $todos = load_todos($path);
printf "[%s] %3d %s\n", $_->{done} ? "x" : " ", $_->{id}, $_->{title}
for @$todos;
}
sub cmd_done {
my ($id) = @_;
die "usage: todo done ID\n" unless defined $id;
my $todos = load_todos($path);
my ($todo) = grep { $_->{id} == $id } @$todos;
die "no todo #$id\n" unless $todo;
$todo->{done} = 1;
save_todos($path, $todos);
}
sub cmd_remove {
my ($id) = @_;
die "usage: todo remove ID\n" unless defined $id;
my $todos = load_todos($path);
my $found = grep { $_->{id} == $id } @$todos;
die "no todo #$id\n" unless $found;
@$todos = grep { $_->{id} != $id } @$todos;
save_todos($path, $todos);
print "removed #$id\n";
}
Marking a record done prints nothing, so ask for the list again to see the flag change:
./todo done 1
./todo list
[x] 1 Write lesson 46
Tests for the store
The store is the part that can destroy data, so it gets the tests. A temp directory keeps them away from your real list, and is_deeply compares the structure that comes back.
use strict;
use warnings;
use Test::More;
use File::Temp 'tempdir';
use lib "lib";
use Todo::Store qw(load_todos save_todos);
my $dir = tempdir(CLEANUP => 1);
my $path = "$dir/todo.json";
is_deeply(load_todos($path), [], "missing file is an empty list");
my $todos = [{ id => 1, title => "write tests", done => 0 }];
save_todos($path, $todos);
is_deeply(load_todos($path), $todos, "round trip keeps the data");
ok(-e $path, "file exists after save");
ok(!-e "$path.tmp", "temporary file is gone");
done_testing();
prove -l t/
t/store.t .. ok
All tests successful.
Files=1, Tests=4, 0 wallclock secs
Result: PASS
The wallclock time and the CPU breakdown that prove adds to the Files= line vary by machine and by run; the result above them does not.
Where to take it
The tool is deliberately unfinished. Each of these additions is a lesson in disguise, and none of them requires a rewrite:
- Due dates with Time::Piece, and a --overdue filter.
- Priorities and a sort by priority, using the techniques from lesson 26.
- Search with grep { $_->{title} =~ /\Q$term\E/i }, the filter from the grep lesson.
- A SQLite backend: keep the same four commands and swap the store for DBI.
Keep the module boundary intact while you extend it. When the store grows a second implementation, the tests stay the same and prove both behave alike.
Next: Capstone: log report
Article author: Arthur Isaev