Capstone: todo CLI

Contents
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:

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

Related articles
Perl
Perl course: zero to hero in 50 lessons
What is Perl and where it runs
Install Perl and check the version
Your first Perl script
The debugger as a REPL
Scalars and sigils
Numbers
Strings
Interpolation and quoting
Arrays
push, pop, shift and splice
Hashes
keys, values, exists and delete
Scalar and list context
Conditions and comparison
while, for, next and last
foreach and the default variable
Subroutines
Arguments in @_
References
Nested data structures
Regular expressions: match and anchors
Captures and named groups
Substitution with s///
split and join
grep and map
Sort and the Schwartzian transform
Read and write files
File tests and metadata
Directories and paths
die, warn and eval
Modules and Exporter
CPAN and cpanm
Objects with bless
Modern objects with Moo
Testing with Test::More
strict, warnings and diagnostics
Options with Getopt::Long
Environment and %ENV
system, backticks and pipes
One-liners with -n and -p
CSV and TSV processing
JSON with JSON::PP
HTTP clients with HTTP::Tiny
Sockets and IO::Socket
Databases with DBI
Capstone: todo CLI
Capstone: log report
Performance and profiling
Modern Perl tour
Hero roadmap: the whole course on one page
Install Perl on Windows 11
Install Perl on Ubuntu
Install Perl on Rocky Linux
Install Perl on macOS
Install Perl on FreeBSD
Perl in Docker

Search this site

Channel @aofeed Chat @aofeedchat

Contacts and cooperation:
I recommend our hosting beget.com
Write to info@aredel.com if you:
1. Want to write an article for our site or translate an article into your native language.
2. Want to place thematically relevant ads on the site.
3. Ads on my site pass maximum censorship. If you see an ad block unsuitable for school-age children, shocking or misleading - please contact us by e-mail
4. Found a mistake, inaccuracy, bug, etc. on the site. ... .......
5. Articles can be shared on social media by clicking a network icon: