Overview
A single self-contained POSIX shell script named
todo implements the whole tool. It dispatches on the first argument
(add, list, done, or help) and reads/writes
one plain-text file. Each task is one line with three tab-separated fields: a
stable integer id, a status word, and the free-form text. Modifications are
written to a temporary file in the same directory and then renamed over the
storage file, so a failed or interrupted write never corrupts existing data.
Architecture
Single process, no daemon, no network. One script, invoked per command:
todo <subcommand> [args]
|
v
dispatch (case on $1)
| | |
add list done
\ | /
storage layer ---> $TODO_FILE (plain text, tab-delimited records)
(ensure / read / atomic write)
The storage layer is the only code that touches the file.
Command functions build the new set of lines and hand them to an atomic-write
helper; they never edit the file in place.
Options Considered
Option A — single script, one tab-delimited file, atomic rename (preferred)
Summary: One sh script. Records are
id<TAB>status<TAB>text. Reads use read -r
with IFS set to tab; writes go to a mktemp file in the
storage directory and are mv'd over the target. The next id is
max(existing id) + 1.
Why chosen: Minimum moving parts that still satisfy every
requirement. Tab-delimited records parse safely with pure POSIX read
(text is the last field, so spaces and even tabs inside it survive).
Temp-plus-rename gives atomic writes with only standard utilities, satisfying
NFR2 and NFR3. Stable ids fall out naturally because ids are stored, not positional.
Option B — structured store (JSON) or one-file-per-status
Summary: Keep tasks as JSON, or keep pending and done tasks
in two separate files.
Why rejected: JSON needs jq or hand-rolled
parsing, breaking the zero-dependency and plain-text constraints (C2, NFR2) and
hurting hand-editability (R4). Splitting into two files multiplies the write
paths and the ways they can drift out of sync, for no user-visible benefit
against a single-user tool.
Option C — positional (line-number) ids
Summary: Do not store ids; number pending tasks 1..N at list time.
Why rejected: The user explicitly chose stable ids; positional
numbering makes a task's id change whenever another task is completed, which
would break R1.AC2's "unique, stable" intent.
Simplicity And Elegance Review
- Simplest viable shape: one script, one data file, one write path. Each
subcommand is a small function; the only shared machinery is
ensure-file / read / atomic-write.
- Coupling check: command functions depend on the storage layer through
two points only — reading lines and replacing the whole file. No global mutable
state beyond
$TODO_FILE; no in-place edits.
- Future-proofing: intentionally deferred — delete/edit, done-task views,
priorities, tags. The record format leaves room (fields are positional and a
fourth field could be appended) but nothing is built for it now.
Components And Interfaces
Dispatcher (main / case "$1")
Purpose: Route the subcommand; print usage when none or unknown.
Inputs/Outputs: Reads $1..$n; exits non-zero with
usage on unknown/no command, zero on success.
Requirements: R5.AC1, R5.AC2
cmd_add
Purpose: Append a new pending task with a fresh id.
Inputs/Outputs: Task text from "$*" after the
subcommand; prints confirmation with the new id; error on empty text.
Requirements: R1.AC1, R1.AC2,
R1.AC3, R1.AC4
cmd_list
Purpose: Print pending tasks, or an empty-state message.
Inputs/Outputs: No args; prints id text per
pending task to stdout.
Requirements: R2.AC1, R2.AC2,
R2.AC3, R2.AC4
cmd_done
Purpose: Flip a task's status to done by id.
Inputs/Outputs: One id arg; prints confirmation; distinct
errors for missing id, unknown id, and already-done.
Requirements: R3.AC1, R3.AC2,
R3.AC3, R3.AC4, R3.AC5
Storage layer (ensure_file, read loop, atomic_write)
Purpose: Own all file access. Create the file on demand; read
records; replace the file atomically.
Inputs/Outputs: ensure_file creates
$TODO_FILE and parent dir if absent; atomic_write
takes new content on stdin, writes a sibling mktemp, then
mvs it over $TODO_FILE.
Requirements: R4.AC1, R4.AC2,
R4.AC3, R4.AC4, NFR1, NFR2,
NFR3
Data Models
Storage file: ${TODO_FILE:-$HOME/.todo}. One record per line:
<id>\t<status>\t<text>
id: positive integer, unique, never reused. Next id = highest
existing id + 1 (0 when the file is empty).
status: the literal word pending or done.
text: everything after the second tab; may contain spaces. Read
with IFS=<tab> read -r id status text, so text
absorbs the remainder intact.
1 pending buy milk
2 done call bank
3 pending write report
Input handling: task text is taken verbatim except that any
embedded tab or newline in the provided text is replaced with a space before
storage, to keep one record on one line and protect the field delimiter.
Error Handling
- Empty/missing add text (
R1.AC4): print
error: task text is required plus usage to stderr, exit 1, no write.
- Missing done id (
R3.AC4): print
error: task id is required, exit 1.
- Unknown done id (
R3.AC3): print
error: no task with id <id>, exit 1, no write.
- Already-done (
R3.AC5): print
task <id> is already done, exit 0, no write.
- Unreadable/unwritable file (
R4.AC4): test readability/writability
before acting; on failure print error: cannot access <path>
to stderr, exit 1.
- Unknown/absent subcommand (
R5): print usage, exit non-zero.
- The script runs under
set -eu; a trap removes the
temp file on any exit so a failed write leaves the original file untouched
(R4.AC3).
Security Considerations
Local single-user tool; no network, no privilege boundary. The
temp file is created with mktemp in the same directory as the storage
file (mode 600 by default) so task text is not exposed via a world-readable temp path.
Failure Modes And Tradeoffs
- Write interrupted (crash, full disk, SIGINT) mid-save. Mitigation:
write to a sibling temp file, then atomic
mv; the original is
replaced only if the temp write fully succeeded. Tradeoff: needs free space for
a second copy of the file momentarily — acceptable for a personal todo list.
- Two
todo invocations write concurrently. Mitigation: none
beyond atomic rename; last writer wins, but neither leaves a corrupt file.
Tradeoff: no locking, matching the single-user constraint (C3).
- User hand-edits the file into a malformed line. Mitigation: lines
without a parseable integer id are skipped by list and cannot be targeted by
done; the tool does not crash on them. Tradeoff: silently ignoring garbage lines
rather than repairing them.
Testing Strategy
A single POSIX test script tests/run.sh drives the
real todo script against an isolated TODO_FILE in a temp
directory (no writes to $HOME). It runs end-to-end scenarios and
asserts on output and on file contents. Tests run under dash
(/bin/dash) to enforce POSIX behavior (NFR1). No third-party test
framework is used (NFR2); assertions are plain sh with a small
pass/fail helper.
Verification Plan
- Requirement proof: each acceptance criterion maps to a labelled scenario
in
tests/run.sh; running the suite under dash
demonstrates R1–R5.
- Test evidence:
dash tests/run.sh exits 0 with all scenarios
passing; the atomic-write scenario proves R4.AC3/NFR3 by forcing a write error
and re-reading the untouched file.
- Operational evidence: not applicable (local CLI, no runtime services).
Requirement Coverage
| R1 | cmd_add |
| R2 | cmd_list |
| R3 | cmd_done |
| R4 | Storage layer |
| R5 | Dispatcher |
| R1.AC1 | cmd_add + atomic append |
| R1.AC2 | cmd_add next-id = max+1 |
| R1.AC3 | cmd_add confirmation output |
| R1.AC4 | cmd_add empty-text guard |
| R2.AC1 | cmd_list pending filter |
| R2.AC2 | cmd_list pending filter |
| R2.AC3 | cmd_list id + text formatting |
| R2.AC4 | cmd_list empty-state branch |
| R3.AC1 | cmd_done status flip + atomic write |
| R3.AC2 | cmd_done confirmation output |
| R3.AC3 | cmd_done unknown-id branch |
| R3.AC4 | cmd_done missing-id branch |
| R3.AC5 | cmd_done already-done branch |
| R4.AC1 | Storage layer $TODO_FILE path |
| R4.AC2 | ensure_file |
| R4.AC3 | atomic_write temp + rename, trap cleanup |
| R4.AC4 | Storage layer readability/writability check |
| R5.AC1 | Dispatcher usage on no args |
| R5.AC2 | Dispatcher usage on unknown subcommand |
| NFR1 | Runs under dash; test suite enforces POSIX |
| NFR2 | Only POSIX utilities; no framework/deps |
| NFR3 | atomic_write + trap; atomic-write test |
| NFR4 | Error messages name problem + usage |