Chapter 20: Final Project and Next Steps
This chapter brings the threads together by building a small cross platform command line program, then extending it with persistence or networking. You will package the result with a tidy Makefile and documentation, then consider testing and standards before choosing a path forward. The goal is finishing with a polished artifact that runs on Linux, macOS, and Windows.
Building a small cross-platform command-line app
The project is a minimal to do list utility named xptodo. It stores one item per line in a text file under the user profile directory; it supports commands add, list, and done. The design favors portability; the program relies on the C standard library, uses platform guards only for locating the home directory, and avoids nonstandard console APIs.
Designing a portable structure
Keep one translation unit for the command and a small header for shared declarations. The only platform difference is the function that returns a writable path for the data file. Everything else is ordinary C that compiles everywhere.
/* file: xptodo.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *data_path(void) {
#if defined(_WIN32)
const char *base = getenv("USERPROFILE");
if (!base) base = ".";
/* returns a pointer to static storage for simplicity */
static char buf[1024];
snprintf(buf, sizeof buf, "%s\\xptodo.txt", base);
return buf;
#else
const char *base = getenv("HOME");
if (!base) base = ".";
static char buf[1024];
snprintf(buf, sizeof buf, "%s/.xptodo", base);
return buf;
#endif
}
static int cmd_list(void) {
FILE *f = fopen(data_path(), "r");
if (!f) { puts("(empty)"); return 0; }
char line[1024];
int n = 1;
while (fgets(line, sizeof line, f)) {
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
printf("%d: %s\n", n++, line);
}
fclose(f);
return 0;
}
static int cmd_add(const char *text) {
FILE *f = fopen(data_path(), "a");
if (!f) { perror("open"); return 1; }
fprintf(f, "%s\n", text);
fclose(f);
return 0;
}
static int cmd_done(int index) {
FILE *in = fopen(data_path(), "r");
if (!in) { perror("open"); return 1; }
FILE *out = tmpfile();
if (!out) { perror("tmpfile"); fclose(in); return 1; }
char line[1024];
int n = 1, removed = 0;
while (fgets(line, sizeof line, in)) {
if (n++ == index) { removed = 1; continue; }
fputs(line, out);
}
rewind(out);
freopen(data_path(), "w", in); /* reopen original as writable */
while (fgets(line, sizeof line, out)) fputs(line, in);
fclose(in);
fclose(out);
if (!removed) { fprintf(stderr, "no such item\n"); return 1; }
return 0;
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s add <text> | list | done <n>\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "list") == 0) {
return cmd_list();
} else if (strcmp(argv[1], "add") == 0) {
if (argc < 3) { fputs("missing text\n", stderr); return 1; }
/* join remaining args separated by spaces */
char buf[2048] = {0};
for (int i = 2; i < argc; ++i) {
if (i > 2) strcat(buf, " ");
strcat(buf, argv[i]);
}
return cmd_add(buf);
} else if (strcmp(argv[1], "done") == 0) {
if (argc < 3) { fputs("missing index\n", stderr); return 1; }
return cmd_done(atoi(argv[2]));
}
fputs("unknown command\n", stderr);
return 1;
}
data_path(). This keeps the rest of the program clean and testable without preprocessor branches.
Compiling and running across platforms
On Linux or macOS you can compile with cc -std=c17 -Wall -Wextra -O2 xptodo.c -o xptodo. On Windows you can use MSVC with cl /std:c17 /W4 /O2 xptodo.c or MinGW with gcc -std=c17 -Wall -Wextra -O2 xptodo.c -o xptodo.exe. The program writes its data file to a sensible default in each system.
Extending it with networking or file persistence
The first extension adds simple persistence with a lock file to prevent clobbering when multiple instances run. The optional alternative adds a tiny networking feature to fetch a remote list; networking needs separate code paths for POSIX sockets and Winsock.
Adding safe file persistence with a lock
Use an advisory lock mechanism when available; fall back to a rudimentary lock file. This keeps the design portable enough for everyday use.
/* naive lock file; suitable for a single user on one machine */
static int with_lock(int (*fn)(void *), void *arg) {
char lockpath[1024];
snprintf(lockpath, sizeof lockpath, "%s.lock", data_path());
FILE *lk = fopen(lockpath, "wx"); /* fail if exists; not on MSVC … */
if (!lk) { fputs("busy; try again\n", stderr); return 1; }
int rc = fn(arg);
fclose(lk);
remove(lockpath);
return rc;
}
flock on BSD like systems or LockFile on Windows, or use a small single process daemon that arbitrates access.
Adding a tiny networking fetch
Networking is optional. The idea is fetching a remote plaintext list and merging it into the local file. The socket setup differs between POSIX and Winsock; you gate the platform specific bits behind a helper named net_fetch().
/* signatures only; platform glue omitted … */
int net_fetch(const char *host, const char *port, const char *path,
char *buf, size_t buflen);
/* usage */
static int cmd_pull(const char *url) {
/* parse url "http://host:port/path" very simply … */
char page[8192];
if (net_fetch("example.com", "80", "/xptodo.txt", page, sizeof page) != 0) {
return 1;
}
/* append to local store line by line … */
return 0;
}
net_posix.c and net_win.c; compile the correct file by choosing a target in your Makefile. This keeps the main program unchanged while you extend capability.
Packaging, documentation, and Makefile distribution
A small but careful Makefile plus a short README and a license allow others to build and use the tool. Declare variables for the compiler and flags; add standard targets such as all, clean, test, and install. Avoid shell features that are not portable.
Writing a portable Makefile
# file: Makefile
CC ?= cc
CFLAGS ?= -std=c17 -Wall -Wextra -O2
BIN ?= xptodo
SRC = xptodo.c
OBJ = $(SRC:.c=.o)
.PHONY: all clean test install uninstall dist
all: $(BIN)
$(BIN): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(BIN)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(BIN)
test: $(BIN)
./$(BIN) add "smoke test item"
./$(BIN) list
install: $(BIN)
mkdir -p $(DESTDIR)/usr/local/bin
cp $(BIN) $(DESTDIR)/usr/local/bin/$(BIN)
uninstall:
rm -f $(DESTDIR)/usr/local/bin/$(BIN)
dist:
mkdir -p dist/xptodo-1.0
cp xptodo.c Makefile README.md LICENSE dist/xptodo-1.0
tar -czf dist/xptodo-1.0.tar.gz -C dist xptodo-1.0
On Windows, build with MinGW or clang; the same Makefile works when make is available. When using MSVC, supply a tiny build.bat that mirrors the commands, since nmake syntax differs.
Writing concise documentation
# file: README.md
xptodo — a minimal cross platform todo list
Build
make # uses CC and CFLAGS
CC=clang make
Usage
xptodo add "buy milk"
xptodo list
xptodo done 1
Data file
Linux, macOS: $HOME/.xptodo
Windows: %USERPROFILE%\xptodo.txt
License
MIT
Testing, Standards, and Beyond
Testing catches regressions and clarifies intent. Start with simple assertions and smoke tests; add static analysis and sanitizers; then pin a code style and a warning profile.
Adding quick tests and sanitizers
Write a shell script that runs the binary and checks outputs. On platforms without a shell, a small C test driver can do the same. When available, compile with sanitizers to catch memory issues at runtime.
# file: test_smoke.sh
set -eu
rm -f ~/.xptodo
./xptodo add "alpha"
./xptodo add "beta"
out=$(./xptodo list | wc -l)
[ "$out" -ge 2 ] && echo "ok"
# add to CFLAGS during local testing
CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer
Adopting warnings and static analysis
Compile with -Wall -Wextra -Werror when developing, then run a static tool such as clang-tidy or cppcheck. Keep README.md honest by listing the exact profiles you use so others can reproduce the checks.
Following a clear coding standard
Pick a straightforward style: two space indentation to match ebook layout, 80 to 100 columns, meaningful names, and short functions. Require that every function has a one line comment that explains purpose and side effects. Enforce consistent error handling by returning integers for status and printing messages in one place.
Where to go next
After shipping a portable C utility you can deepen your stack in several directions. Each path opens new abstractions while keeping your grounding in systems thinking.
Choosing C++ for zero cost abstractions
C++ provides templates, RAII, and a rich standard library for containers and algorithms. You keep control of memory and layout while gaining expressive tools that help manage complexity. Translating xptodo to C++ would replace manual file handling with fstream and error handling with exceptions or std::expected equivalents.
Choosing Rust for memory safety guarantees
Rust offers ownership, borrowing, and a proven toolchain that eliminates entire classes of memory bugs. The language integrates cargo for builds and testing; translating xptodo to Rust demonstrates how lifetimes and pattern matching simplify persistence and parsing while preserving performance.
Specializing in systems topics
If you remain in C, consider deeper areas: writing libraries with stable ABIs, building event driven servers with poll or kqueue, implementing cross platform file watchers, creating FFI boundaries for Python or Java, or working on embedded firmware where timing and power shape every design choice. The skills you practiced in this project transfer directly.