[C Series 20] Capstone – Building a Text-Based System Tool

한국어 버전

For the final installment, we'll bundle every concept we've covered and build logscan, a console tool that reads and filters text logs. It's a small utility, but it touches file I/O, structs, pointers, function pointers, dynamic memory, and a Makefile.

What You're Building

You will create a small log analyzer that reads a file, parses each line into a structured record, and then either summarizes, filters, or tails the results. The goal is not just to finish one tool, but to see how earlier C concepts start working together inside a multi-file program.

New Terms in This Lesson

  1. Parser: Code that converts text input into structured data
  2. Filter: A function that keeps only the entries matching certain conditions
  3. Subcommand: Breaking commands into variants like logscan stats and logscan tail
  4. Runner: A function that receives callbacks and executes tasks

Key Ideas

Study Notes

  • Estimated time: 2–3 hours (design + implementation + testing)
  • Prereqs: Experience with file I/O, structs, pointers, function pointers, and Makefiles
  • Goal: Implement text parsing, dynamic arrays, callback-driven filters, and automated builds

logscan provides the following:

  • Read a log file line by line and convert it into an array of LogEntry
  • stats subcommand that prints counts per level (INFO, WARN, ERROR)
  • filter level=ERROR keyword=db syntax to combine conditions and output specific entries
  • tail N to show the last N entries

Each stage reinforces a concept.

The data flow looks like this:

input file -> read line by line -> store in LogEntry array -> branch by command (stats/filter/tail) -> print results

Project Structure

logscan/
├── include/
│   └── logscan.h
├── src/
│   ├── main.c
│   ├── parser.c
│   ├── commands.c
│   └── filters.c
├── sample.log
└── Makefile

sample.log example:

2026-03-18T09:00:01Z INFO  auth  login success user=alice
2026-03-18T09:01:15Z WARN  db    connection delay 120ms
2026-03-18T09:02:44Z ERROR payment card approval failed code=42
2026-03-18T09:03:11Z INFO  auth  token refreshed user=bob

Core Code Tour

include/logscan.h

#ifndef LOGSCAN_H
#define LOGSCAN_H

#include <stddef.h>

typedef enum {
    LEVEL_INFO,
    LEVEL_WARN,
    LEVEL_ERROR,
    LEVEL_UNKNOWN
} LogLevel;

typedef struct {
    char timestamp[32];
    LogLevel level;
    char source[32];
    char message[128];
} LogEntry;

typedef int (*entry_filter)(const LogEntry *entry, void *ctx);

typedef struct {
    LogEntry *items;
    size_t count;
} LogBuffer;

LogBuffer parse_file(const char *path);
void free_buffer(LogBuffer *buffer);
void run_stats(const LogBuffer *buffer);
void run_filter(const LogBuffer *buffer, entry_filter filter, void *ctx);
void run_tail(const LogBuffer *buffer, size_t n);
void run_filter_by_level(const LogBuffer *buffer, LogLevel level);
void run_filter_by_keyword(const LogBuffer *buffer, const char *keyword);

#endif

Parser (src/parser.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "logscan.h"

static LogLevel parse_level(const char *token) {
    if (strcmp(token, "INFO") == 0) return LEVEL_INFO;
    if (strcmp(token, "WARN") == 0) return LEVEL_WARN;
    if (strcmp(token, "ERROR") == 0) return LEVEL_ERROR;
    return LEVEL_UNKNOWN;
}

LogBuffer parse_file(const char *path) {
    FILE *fp = fopen(path, "r");
    LogBuffer buffer = {NULL, 0};
    char line[256];
    size_t capacity = 0;

    if (!fp) {
        perror("failed to open file");
        return buffer;
    }

    while (fgets(line, sizeof(line), fp)) {
        if (buffer.count == capacity) {
            size_t new_cap = capacity == 0 ? 8 : capacity * 2;
            LogEntry *new_items = realloc(buffer.items, new_cap * sizeof(LogEntry));
            if (!new_items) {
                perror("memory allocation failed");
                free_buffer(&buffer);
                break;
            }
            buffer.items = new_items;
            capacity = new_cap;
        }

        LogEntry *entry = &buffer.items[buffer.count];
        char level_str[8];
        if (sscanf(line, "%31s %7s %31s %127[^\n]",
                   entry->timestamp, level_str, entry->source, entry->message) == 4) {
            entry->level = parse_level(level_str);
            buffer.count++;
        }
    }

    fclose(fp);
    return buffer;
}

void free_buffer(LogBuffer *buffer) {
    free(buffer->items);
    buffer->items = NULL;
    buffer->count = 0;
}

Command Runners (src/commands.c)

#include <stdio.h>
#include "logscan.h"

static const char *level_to_string(LogLevel level) {
    switch (level) {
        case LEVEL_INFO: return "INFO";
        case LEVEL_WARN: return "WARN";
        case LEVEL_ERROR: return "ERROR";
        default: return "UNKNOWN";
    }
}

void run_stats(const LogBuffer *buffer) {
    size_t info = 0, warn = 0, error = 0;
    for (size_t i = 0; i < buffer->count; i++) {
        switch (buffer->items[i].level) {
            case LEVEL_INFO: info++; break;
            case LEVEL_WARN: warn++; break;
            case LEVEL_ERROR: error++; break;
            default: break;
        }
    }

    printf("INFO=%zu WARN=%zu ERROR=%zu\n", info, warn, error);
}

void run_filter(const LogBuffer *buffer, entry_filter filter, void *ctx) {
    for (size_t i = 0; i < buffer->count; i++) {
        if (filter(&buffer->items[i], ctx)) {
            printf("%s %s %-5s %s\n",
                   buffer->items[i].timestamp,
                   level_to_string(buffer->items[i].level),
                   buffer->items[i].source,
                   buffer->items[i].message);
        }
    }
}

void run_tail(const LogBuffer *buffer, size_t n) {
    size_t start = buffer->count > n ? buffer->count - n : 0;
    for (size_t i = start; i < buffer->count; i++) {
        printf("%s %s %-5s %s\n",
               buffer->items[i].timestamp,
               level_to_string(buffer->items[i].level),
               buffer->items[i].source,
               buffer->items[i].message);
    }
}

Filters (src/filters.c)

#include <string.h>
#include "logscan.h"

typedef struct {
    LogLevel level;
} LevelCtx;

typedef struct {
    const char *keyword;
} KeywordCtx;

int filter_by_level(const LogEntry *entry, void *ctx) {
    LevelCtx *c = (LevelCtx *)ctx;
    return entry->level == c->level;
}

int filter_by_keyword(const LogEntry *entry, void *ctx) {
    KeywordCtx *c = (KeywordCtx *)ctx;
    return strstr(entry->message, c->keyword) != NULL;
}

void run_filter_by_level(const LogBuffer *buffer, LogLevel level) {
    LevelCtx ctx = {level};
    run_filter(buffer, filter_by_level, &ctx);
}

void run_filter_by_keyword(const LogBuffer *buffer, const char *keyword) {
    KeywordCtx ctx = {keyword};
    run_filter(buffer, filter_by_keyword, &ctx);
}

Entry Point (src/main.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "logscan.h"

static LogLevel level_from_arg(const char *arg) {
    if (strcmp(arg, "INFO") == 0) return LEVEL_INFO;
    if (strcmp(arg, "WARN") == 0) return LEVEL_WARN;
    if (strcmp(arg, "ERROR") == 0) return LEVEL_ERROR;
    return LEVEL_UNKNOWN;
}

static void print_usage(void) {
    printf("Usage:\n");
    printf("  logscan stats <path>\n");
    printf("  logscan filter level=<LEVEL>|keyword=<TEXT> <path>\n");
    printf("  logscan tail <N> <path>\n");
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        print_usage();
        return 1;
    }

    const char *command = argv[1];

    if (strcmp(command, "stats") == 0) {
        if (argc != 3) {
            print_usage();
            return 1;
        }

        LogBuffer buffer = parse_file(argv[2]);
        run_stats(&buffer);
        free_buffer(&buffer);
    } else if (strcmp(command, "filter") == 0) {
        if (argc != 4) {
            print_usage();
            return 1;
        }

        const char *arg = argv[2];
        LogBuffer buffer = parse_file(argv[3]);

        if (strncmp(arg, "level=", 6) == 0) {
            run_filter_by_level(&buffer, level_from_arg(arg + 6));
        } else if (strncmp(arg, "keyword=", 8) == 0) {
            run_filter_by_keyword(&buffer, arg + 8);
        } else {
            print_usage();
        }

        free_buffer(&buffer);
    } else if (strcmp(command, "tail") == 0) {
        if (argc != 4) {
            print_usage();
            return 1;
        }

        char *end = NULL;
        unsigned long parsed = strtoul(argv[2], &end, 10);
        if (end == argv[2] || *end != '\0') {
            fprintf(stderr, "tail count must be a positive integer.\n");
            return 1;
        }

        LogBuffer buffer = parse_file(argv[3]);
        size_t n = (size_t)parsed;
        run_tail(&buffer, n);
        free_buffer(&buffer);
    } else {
        print_usage();
        return 1;
    }
    return 0;
}

Build Automation (Makefile)

CC = clang
CFLAGS = -Wall -Wextra -std=c11 -Iinclude
BUILD = build
SRCS = src/main.c src/parser.c src/commands.c src/filters.c
OBJS = $(SRCS:src/%.c=$(BUILD)/%.o)
TARGET = $(BUILD)/logscan

.PHONY: all clean run

all: $(TARGET)

$(TARGET): $(OBJS)
	@mkdir -p $(BUILD)
	$(CC) $(OBJS) -o $(TARGET)

$(BUILD)/%.o: src/%.c include/logscan.h | $(BUILD)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD):
	@mkdir -p $(BUILD)

run: $(TARGET)
	./$(TARGET) stats sample.log

clean:
	rm -rf $(BUILD)

Sample Run

$ make run
INFO=2 WARN=1 ERROR=1

$ ./build/logscan filter level=ERROR sample.log
2026-03-18T09:02:44Z ERROR payment card approval failed code=42

$ ./build/logscan filter keyword=token sample.log
2026-03-18T09:03:11Z INFO  auth  token refreshed user=bob

$ ./build/logscan tail 2 sample.log
2026-03-18T09:02:44Z ERROR payment card approval failed code=42
2026-03-18T09:03:11Z INFO  auth  token refreshed user=bob

Why It Matters

  • Even small tools become maintainable when they have clear structure and automated builds.
  • Parsing, filtering, and statistics live in separate files so responsibilities stay readable.
  • The function-pointer + void * context pattern shows up naturally in real utilities.
  • This capstone provides a base skeleton for future OS assignments or server tools.

Practice in CodeSandbox

The sandbox below uses CodeSandbox's Universal starter. For C, the key learning loop is still compile and run in the terminal, so recreate the lesson code as a source file and repeat that cycle directly.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. Fork the starter and create a practice file such as hello.c
  2. Paste in the lesson code and compile it if clang or gcc is available
  3. Edit the code, rebuild it, and compare the new output

For C, the terminal build loop matters more than a browser preview. Compiler availability can vary by environment, so first confirm that clang or gcc is present in the Universal starter.

Practice

  • Follow along: Recreate the directory, run make, and exercise the logscan commands.
  • Extend: Add a source=<NAME> option to the filter command to pull logs from a specific module.
  • Debug: Insert blank lines into sample.log, observe how the parser reacts, and add guards as needed.
  • Completion check: Deliver an executable with at least two working subcommands that still functions when you swap log files.

Wrap-Up

This capstone shows how the C series concepts connect inside a real tool. Don't stop here—add JSON parsing, accept input over sockets, or pursue your own ideas. You'll finish the series with confidence and be ready for the next stage of systems programming.

💬 댓글

이 글에 대한 의견을 남겨주세요