Setiap CLI tool dimulai dari kemampuan membaca argument yang diberikan pengguna di terminal. Pada Bagian 1 ini kita membangun fondasi proyek tool dengan parsing argc/argv secara manual, menangani flag global seperti --help dan --version, serta menyiapkan struktur dasar yang akan diperluas di bagian berikutnya. Tujuannya adalah memastikan bahwa setiap argumen yang diberikan pengguna dikenali dan ditangani dengan tepat sebelum kita menambahkan fitur yang lebih kompleks.

Konsep Argument Parsing di C #

Fungsi main menerima dua parameter: int argc (jumlah argument termasuk nama program) dan char *argv[] (array string berisi argument). Index 0 adalah nama program itu sendiri, index 1 ke atas adalah argument pengguna. Pola parsing manual yang umum dipakai adalah iterasi melalui argv[1] hingga argv[argc-1], mencocokkan setiap entry dengan flag atau subcommand yang dikenal.

Untuk flag yang memerlukan value (misalnya --output file.txt), kita ambil value dari argv[i+1] dan skip satu iterasi. Untuk flag boolean (--verbose), cukup set variabel boolean. Penting untuk menangani kasus di mana value hilang atau argument tidak dikenali, dengan memberi pesan error yang jelas.

Implementasi Argument Parsing #

Mari kita lihat kerangka awal main.c yang menangani argument parsing:

c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct {
    int verbose;
    int quiet;
    int show_help;
    int show_version;
    const char *command;
    const char *command_arg;
} CliArgs;

static const char *VERSION = "tool 1.0.0";

void cli_args_init(CliArgs *args) {
    args->verbose = 0;
    args->quiet = 0;
    args->show_help = 0;
    args->show_version = 0;
    args->command = NULL;
    args->command_arg = NULL;
}

int cli_args_parse(CliArgs *args, int argc, char *argv[]) {
    for (int i = 1; i < argc; i++) {
        const char *arg = argv[i];
        if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) {
            args->show_help = 1;
        } else if (strcmp(arg, "--version") == 0) {
            args->show_version = 1;
        } else if (strcmp(arg, "-v") == 0 || strcmp(arg, "--verbose") == 0) {
            args->verbose = 1;
        } else if (strcmp(arg, "-q") == 0 || strcmp(arg, "--quiet") == 0) {
            args->quiet = 1;
        } else if (arg[0] != '-' && args->command == NULL) {
            args->command = arg;
        } else if (args->command != NULL && args->command_arg == NULL) {
            args->command_arg = arg;
        } else {
            fprintf(stderr, "Error: argument tidak dikenali: %s\n", arg);
            return -1;
        }
    }
    return 0;
}

int main(int argc, char *argv[]) {
    CliArgs args;
    cli_args_init(&args);

    if (cli_args_parse(&args, argc, argv) != 0) {
        return 1;
    }

    if (args.show_version) {
        printf("%s\n", VERSION);
        return 0;
    }

    if (args.show_help || args.command == NULL) {
        printf("Penggunaan: tool <command> [options]\n");
        printf("\nPerintah yang tersedia:\n");
        printf("  run <file>      Proses file input\n");
        printf("  list <dir>      Daftar file di direktori\n");
        printf("  config show     Tampilkan konfigurasi\n");
        printf("  help            Tampilkan bantuan ini\n");
        return 0;
    }

    printf("Command: %s, arg: %s, verbose=%d, quiet=%d\n",
           args.command,
           args.command_arg ? args.command_arg : "(none)",
           args.verbose, args.quiet);
    return 0;
}

Output yang Dihasilkan:

text
$ ./tool --version
tool 1.0.0

$ ./tool --help
Penggunaan: tool <command> [options]

Perintah yang tersedia:
  run <file>      Proses file input
  list <dir>      Daftar file di direktori
  config show     Tampilkan konfigurasi
  help            Tampilkan bantuan ini

$ ./tool run sample.txt
Command: run, arg: sample.txt, verbose=0, quiet=0

$ ./tool --invalid
Error: argument tidak dikenali: --invalid

Mari kita telaah baris-baris kode di atas:

  1. Baris 8-15 (struct CliArgs): menyimpan hasil parsing dalam satu struct yang rapi, sehingga seluruh state CLI tersimpan di satu tempat.
  2. Baris 17-23 (cli_args_init): inisialisasi semua field ke nilai default (0 atau NULL). Mencegah wild pointer dan nilai garbage.
  3. Baris 25-49 (cli_args_parse): iterasi argv dari index 1, mencocokkan setiap entry dengan flag yang dikenal. Argument non-flag pertama menjadi command, kedua menjadi command_arg. Argument tak dikenali menyebabkan return -1.
  4. Baris 51-78 (main): inisialisasi, parse, lalu handle flag global (--version, --help) sebelum dispatch command.

Pengujian Argument Parsing #

Kompilasi dan uji dengan beberapa skenario:

bash
gcc -std=c11 -Wall -Wextra -o tool main.c

./tool --version
./tool --help
./tool run sample.txt
./tool -v run sample.txt
./tool --invalid
echo $?  # exit code 1

Output yang diharapkan:

text
tool 1.0.0

$? akan menampilkan 0 untuk kasus sukses dan 1 untuk kasus error.

Pada Bagian 2 kita akan menambahkan dispatch ke setiap command (run, list, config) dengan implementasi yang sebenarnya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

cli.c

Unduh berkas
c
/* cli.c - Implementasi parsing argumen */
#include "cli.h"

#include <string.h>
#include <stdlib.h>

#define PROGRAM_NAME "mtool"
#define PROGRAM_VERSION "1.0.0"

void cli_print_help(FILE *out) {
    fprintf(out,
        "Penggunaan: %s [SUBCOMMAND] [OPTION]... [FILE]\n"
        "\n"
        "Alat pemrosesan teks baris perintah sederhana.\n"
        "\n"
        "Subperintah:\n"
        "  (none)             Baca stdin dan cetak jumlah baris.\n"
        "  help               Tampilkan pesan ini.\n"
        "  version            Tampilkan versi program.\n"
        "  count [FILE]       Hitung jumlah baris di FILE (atau stdin).\n"
        "  head [-n N] [FILE] Cetak N baris pertama (default 10).\n"
        "  tail [-n N] [FILE] Cetak N baris terakhir (default 10).\n"
        "  stats [FILE]       Statistik: jumlah baris, kata, karakter.\n"
        "  grep PATTERN [-i] [FILE]\n"
        "                     Cetak baris yang mengandung PATTERN.\n"
        "\n"
        "Opsi:\n"
        "  -n N               Jumlah baris untuk head/tail.\n"
        "  -i                 Case-insensitive untuk grep.\n"
        "  -v                 Verbose log ke stderr.\n"
        "  --help             Tampilkan pesan bantuan.\n"
        "  --version          Tampilkan versi.\n",
        PROGRAM_NAME);
}

void cli_print_version(FILE *out) {
    fprintf(out, "%s %s\n", PROGRAM_NAME, PROGRAM_VERSION);
}

/* Cek apakah string diawali tanda strip (option). */
static int is_option(const char *s) {
    return s != NULL && s[0] == '-' && s[1] != '\0';
}

int cli_parse(int argc, char *argv[], cli_args *out) {
    if (out == NULL) {
        return -1;
    }
    memset(out, 0, sizeof(*out));
    out->count = 10; /* default untuk head/tail */

    if (argc < 2) {
        out->subcommand = CLI_SUB_COUNT;
        return 0;
    }

    int i = 1;
    /* Deteksi subperintah pertama bila bukan option. */
    if (!is_option(argv[i])) {
        const char *cmd = argv[i];
        if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
            out->subcommand = CLI_SUB_HELP;
            out->show_help = 1;
            return 0;
        }
        if (strcmp(cmd, "version") == 0 || strcmp(cmd, "--version") == 0) {
            out->subcommand = CLI_SUB_VERSION;
            out->show_version = 1;
            return 0;
        }
        if (strcmp(cmd, "count") == 0) {
            out->subcommand = CLI_SUB_COUNT;
        } else if (strcmp(cmd, "head") == 0) {
            out->subcommand = CLI_SUB_HEAD;
        } else if (strcmp(cmd, "tail") == 0) {
            out->subcommand = CLI_SUB_TAIL;
        } else if (strcmp(cmd, "stats") == 0) {
            out->subcommand = CLI_SUB_STATS;
        } else if (strcmp(cmd, "grep") == 0) {
            out->subcommand = CLI_SUB_GREP;
        } else if (cmd[0] == '-' && cmd[1] == '-') {
            /* long option tanpa subcommand */
            out->subcommand = CLI_SUB_COUNT;
        } else if (is_option(cmd)) {
            /* short option tanpa subcommand: anggap count */
            out->subcommand = CLI_SUB_COUNT;
        } else {
            /* Bukan subperintah yang dikenal, anggap sebagai path file. */
            out->subcommand = CLI_SUB_COUNT;
            out->file_path = cmd;
            i++;
            return 0;
        }
        i++;
    } else {
        out->subcommand = CLI_SUB_COUNT;
    }

    /* Khusus grep: argumen non-option pertama adalah PATTERN. */
    if (out->subcommand == CLI_SUB_GREP) {
        if (i >= argc) {
            return -1; /* grep butuh pattern */
        }
        out->pattern = argv[i++];
    }

    /* Iterasi sisa argumen. */
    while (i < argc) {
        const char *a = argv[i];
        if (strcmp(a, "-n") == 0) {
            if (i + 1 >= argc) {
                return -1;
            }
            out->count = (int)strtol(argv[++i], NULL, 10);
            if (out->count < 0) {
                return -1;
            }
            i++;
        } else if (strcmp(a, "-i") == 0) {
            out->ignore_case = 1;
            i++;
        } else if (strcmp(a, "-v") == 0) {
            out->verbose = 1;
            i++;
        } else if (strcmp(a, "--help") == 0) {
            out->show_help = 1;
            i++;
        } else if (strcmp(a, "--version") == 0) {
            out->show_version = 1;
            i++;
        } else if (is_option(a)) {
            return -1; /* option tidak dikenal */
        } else {
            /* Argumen non-option: file_path. */
            if (out->file_path == NULL) {
                out->file_path = a;
            } else {
                return -1; /* lebih dari satu file (tidak didukung) */
            }
            i++;
        }
    }
    return 0;
}

config.c

Unduh berkas
c
/* config.c - Implementasi loader file konfigurasi */
#include "config.h"

#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <stdlib.h>

void config_init(config *c) {
    if (c == NULL) {
        return;
    }
    c->count = 0;
}

static void strip(char *s) {
    /* Trim whitespace di kiri. */
    char *start = s;
    while (*start != '\0' && isspace((unsigned char)*start)) {
        start++;
    }
    if (start != s) {
        memmove(s, start, strlen(start) + 1);
    }
    /* Trim whitespace di kanan. */
    size_t len = strlen(s);
    while (len > 0 && isspace((unsigned char)s[len - 1])) {
        s[--len] = '\0';
    }
}

static void parse_line(char *line, config *c) {
    /* Lewati komentar dan baris kosong. */
    if (line[0] == '#' || line[0] == ';' || line[0] == '\0') {
        return;
    }
    /* Cari '=' pemisah. */
    char *eq = strchr(line, '=');
    if (eq == NULL) {
        return;
    }
    *eq = '\0';
    char *key = line;
    char *value = eq + 1;
    strip(key);
    strip(value);
    if (key[0] == '\0' || c->count >= CONFIG_ENTRIES_MAX) {
        return;
    }
    config_entry *e = &c->entries[c->count];
    strncpy(e->key, key, CONFIG_KEY_MAX - 1);
    e->key[CONFIG_KEY_MAX - 1] = '\0';
    strncpy(e->value, value, CONFIG_VALUE_MAX - 1);
    e->value[CONFIG_VALUE_MAX - 1] = '\0';
    c->count++;
}

int config_load(config *c, const char *path) {
    if (c == NULL || path == NULL) {
        return -1;
    }
    config_init(c);
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        return -1;
    }
    char line[256];
    while (fgets(line, sizeof(line), fp) != NULL) {
        /* Hapus newline. */
        size_t len = strlen(line);
        while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
            line[--len] = '\0';
        }
        parse_line(line, c);
    }
    fclose(fp);
    return c->count;
}

const char *config_get(const config *c, const char *key) {
    if (c == NULL || key == NULL) {
        return NULL;
    }
    for (int i = 0; i < c->count; i++) {
        if (strcmp(c->entries[i].key, key) == 0) {
            return c->entries[i].value;
        }
    }
    return NULL;
}

int config_get_int(const config *c, const char *key, int default_value) {
    const char *v = config_get(c, key);
    if (v == NULL) {
        return default_value;
    }
    return (int)strtol(v, NULL, 10);
}

file_processor.c

Unduh berkas
c
/* file_processor.c - Implementasi pemrosesan file */
#include "file_processor.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

FILE *file_open(const char *path, const char *mode) {
    if (path == NULL) {
        if (strcmp(mode, "r") == 0) {
            return stdin;
        }
        if (strcmp(mode, "w") == 0) {
            return stdout;
        }
        return NULL;
    }
    return fopen(path, mode);
}

static void close_file(FILE *fp, const char *path) {
    if (path != NULL && fp != NULL) {
        fclose(fp);
    }
    (void)fp;
}

/* Helper: duplikasi string (pengganti strdup agar portable C11 murni). */
static char *dup_str(const char *s) {
    size_t len = strlen(s) + 1;
    char *copy = malloc(len);
    if (copy != NULL) {
        memcpy(copy, s, len);
    }
    return copy;
}

/* Helper: cek apakah string `s` mengandung substring `pattern`
 * secara case-insensitive. */
static int contains_ci(const char *s, const char *pattern) {
    if (pattern[0] == '\0') {
        return 1;
    }
    for (const char *p = s; *p != '\0'; p++) {
        const char *si = p;
        const char *pi = pattern;
        while (*si != '\0' && *pi != '\0' &&
               tolower((unsigned char)*si) == tolower((unsigned char)*pi)) {
            si++;
            pi++;
        }
        if (*pi == '\0') {
            return 1;
        }
    }
    return 0;
}

int file_stats_compute(const char *path, file_stats *out) {
    if (out == NULL) {
        return -1;
    }
    out->lines = 0;
    out->words = 0;
    out->chars = 0;
    FILE *fp = file_open(path, "r");
    if (fp == NULL) {
        return -1;
    }
    int prev_was_space = 1;
    int c;
    while ((c = fgetc(fp)) != EOF) {
        out->chars++;
        if (c == '\n') {
            out->lines++;
            prev_was_space = 1;
        } else if (isspace(c)) {
            prev_was_space = 1;
        } else {
            if (prev_was_space) {
                out->words++;
            }
            prev_was_space = 0;
        }
    }
    /* Hitung baris terakhir bila tidak diakhiri newline. */
    if (out->chars > 0 && !prev_was_space && out->lines == 0) {
        out->lines = 1;
    }
    close_file(fp, path);
    return 0;
}

int file_head(const char *path, int n) {
    if (n < 0) {
        return -1;
    }
    FILE *fp = file_open(path, "r");
    if (fp == NULL) {
        return -1;
    }
    int printed = 0;
    char buf[4096];
    while (printed < n && fgets(buf, sizeof(buf), fp) != NULL) {
        fputs(buf, stdout);
        printed++;
    }
    close_file(fp, path);
    return printed;
}

int file_tail(const char *path, int n) {
    if (n <= 0) {
        return 0;
    }
    FILE *fp = file_open(path, "r");
    if (fp == NULL) {
        return -1;
    }
    /* Alokasikan buffer sirkular untuk N baris terakhir. */
    char **ring = calloc((size_t)n, sizeof(char *));
    if (ring == NULL) {
        close_file(fp, path);
        return -1;
    }
    int idx = 0;
    int filled = 0;
    char buf[4096];
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        if (ring[idx] != NULL) {
            free(ring[idx]);
        }
        ring[idx] = dup_str(buf);
        idx = (idx + 1) % n;
        if (filled < n) {
            filled++;
        }
    }
    /* Cetak sesuai urutan kronologis. */
    int start = (filled < n) ? 0 : idx;
    for (int k = 0; k < filled; k++) {
        int pos = (start + k) % n;
        if (ring[pos] != NULL) {
            fputs(ring[pos], stdout);
            free(ring[pos]);
        }
    }
    free(ring);
    close_file(fp, path);
    return filled;
}

int file_grep(const char *path, const char *pattern, int ignore_case) {
    if (pattern == NULL) {
        return -1;
    }
    FILE *fp = file_open(path, "r");
    if (fp == NULL) {
        return -1;
    }
    int printed = 0;
    char buf[4096];
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        int match;
        if (ignore_case) {
            match = contains_ci(buf, pattern);
        } else {
            match = (strstr(buf, pattern) != NULL);
        }
        if (match) {
            fputs(buf, stdout);
            printed++;
        }
    }
    close_file(fp, path);
    return printed;
}

logger.c

Unduh berkas
c
/* logger.c - Implementasi logger */
#include "logger.h"

#include <stdarg.h>
#include <string.h>
#include <time.h>

static log_level g_min_level = LOG_INFO;
static FILE     *g_out        = NULL;

static const char *level_name(log_level l) {
    switch (l) {
        case LOG_DEBUG: return "DEBUG";
        case LOG_INFO:  return "INFO";
        case LOG_WARN:  return "WARN";
        case LOG_ERROR: return "ERROR";
        case LOG_OFF:   return "OFF";
        default:        return "?";
    }
}

void log_init(log_level min, FILE *out) {
    g_min_level = min;
    g_out = (out != NULL) ? out : stderr;
}

void log_write(log_level level, const char *file, int line,
               const char *fmt, ...) {
    if (level < g_min_level || g_out == NULL) {
        return;
    }
    /* Timestamp sederhana: detik sejak epoch. */
    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);
    char ts[20];
    strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm_info);

    /* Ambil basename file agar log ringkas. */
    const char *slash = strrchr(file, '/');
    const char *base = (slash != NULL) ? slash + 1 : file;

    fprintf(g_out, "[%s] [%s] %s:%d ", ts, level_name(level), base, line);

    va_list ap;
    va_start(ap, fmt);
    vfprintf(g_out, fmt, ap);
    va_end(ap);

    fputc('\n', g_out);
    fflush(g_out);
}

main.c

Unduh berkas
c
/* main.c - Program utama command-line tool "mtool" */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "cli.h"
#include "config.h"
#include "file_processor.h"
#include "logger.h"

/* Konfigurasi default bila file config ada. */
static const char *CONFIG_FILE = ".mtoolrc";

static int run_subcommand(const cli_args *args) {
    file_stats stats;

    switch (args->subcommand) {
        case CLI_SUB_HELP:
            cli_print_help(stdout);
            return 0;
        case CLI_SUB_VERSION:
            cli_print_version(stdout);
            return 0;
        case CLI_SUB_COUNT: {
            if (args->file_path == NULL) {
                /* Hitung baris dari stdin. */
                int c;
                long count = 0;
                while ((c = fgetc(stdin)) != EOF) {
                    if (c == '\n') {
                        count++;
                    }
                }
                printf("%ld\n", count);
                return 0;
            }
            if (file_stats_compute(args->file_path, &stats) != 0) {
                LOG_ERROR("tidak bisa membuka file: %s", args->file_path);
                return 1;
            }
            printf("%ld %s\n", stats.lines, args->file_path);
            return 0;
        }
        case CLI_SUB_HEAD:
            if (file_head(args->file_path, args->count) < 0) {
                LOG_ERROR("head gagal");
                return 1;
            }
            return 0;
        case CLI_SUB_TAIL:
            if (file_tail(args->file_path, args->count) < 0) {
                LOG_ERROR("tail gagal");
                return 1;
            }
            return 0;
        case CLI_SUB_STATS:
            if (file_stats_compute(args->file_path, &stats) != 0) {
                LOG_ERROR("tidak bisa membuka file: %s",
                          args->file_path ? args->file_path : "stdin");
                return 1;
            }
            printf("%8ld baris  %8ld kata  %8ld karakter  %s\n",
                   stats.lines, stats.words, stats.chars,
                   args->file_path ? args->file_path : "-");
            return 0;
        case CLI_SUB_GREP:
            if (args->pattern == NULL) {
                fprintf(stderr, "grep butuh PATTERN\n");
                return 1;
            }
            if (file_grep(args->file_path, args->pattern,
                          args->ignore_case) < 0) {
                LOG_ERROR("grep gagal");
                return 1;
            }
            return 0;
        case CLI_SUB_NONE:
        default:
            cli_print_help(stderr);
            return 1;
    }
}

int main(int argc, char *argv[]) {
    /* Inisialisasi logger default: level INFO ke stderr. */
    log_init(LOG_INFO, stderr);

    /* Muat config opsional bila ada. */
    config cfg;
    config_init(&cfg);
    if (config_load(&cfg, CONFIG_FILE) > 0) {
        const char *lvl = config_get(&cfg, "log_level");
        if (lvl != NULL) {
            if (strcmp(lvl, "DEBUG") == 0) {
                log_init(LOG_DEBUG, stderr);
            } else if (strcmp(lvl, "WARN") == 0) {
                log_init(LOG_WARN, stderr);
            } else if (strcmp(lvl, "ERROR") == 0) {
                log_init(LOG_ERROR, stderr);
            }
        }
    }

    cli_args args;
    if (cli_parse(argc, argv, &args) != 0) {
        fprintf(stderr, "Argumen tidak valid. Jalankan '%s help'.\n",
                argv[0]);
        return 2;
    }

    if (args.verbose) {
        log_init(LOG_DEBUG, stderr);
    }

    if (args.show_help) {
        cli_print_help(stdout);
        return 0;
    }
    if (args.show_version) {
        cli_print_version(stdout);
        return 0;
    }

    LOG_DEBUG("subcommand=%d file=%s",
               (int)args.subcommand,
               args.file_path ? args.file_path : "(stdin)");

    return run_subcommand(&args);
}

tests.c

Unduh berkas
c
/* tests.c - Unit test untuk CLI tool */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#include "cli.h"
#include "config.h"
#include "file_processor.h"

static int tests_run = 0;
static int tests_pass = 0;

#define ASSERT(cond, msg) \
    do { \
        tests_run++; \
        if (cond) { \
            tests_pass++; \
        } else { \
            printf("FAIL: %s (line %d)\n", msg, __LINE__); \
        } \
    } while (0)

static void test_cli_parse_count(void) {
    char *argv[] = {"mtool", "count", "data.txt"};
    cli_args args;
    int rc = cli_parse(3, argv, &args);
    ASSERT(rc == 0, "count harus sukses");
    ASSERT(args.subcommand == CLI_SUB_COUNT, "subcommand = COUNT");
    ASSERT(args.file_path != NULL, "file_path = data.txt");
    ASSERT(strcmp(args.file_path, "data.txt") == 0, "file_path benar");
}

static void test_cli_parse_head_with_n(void) {
    char *argv[] = {"mtool", "head", "-n", "5", "data.txt"};
    cli_args args;
    int rc = cli_parse(5, argv, &args);
    ASSERT(rc == 0, "head -n 5 harus sukses");
    ASSERT(args.subcommand == CLI_SUB_HEAD, "subcommand = HEAD");
    ASSERT(args.count == 5, "count = 5");
}

static void test_cli_parse_grep(void) {
    char *argv[] = {"mtool", "grep", "hello", "-i", "data.txt"};
    cli_args args;
    int rc = cli_parse(5, argv, &args);
    ASSERT(rc == 0, "grep harus sukses");
    ASSERT(args.subcommand == CLI_SUB_GREP, "subcommand = GREP");
    ASSERT(strcmp(args.pattern, "hello") == 0, "pattern = hello");
    ASSERT(args.ignore_case == 1, "ignore_case = 1");
}

static void test_cli_parse_help_version(void) {
    char *argv1[] = {"mtool", "help"};
    cli_args args;
    ASSERT(cli_parse(2, argv1, &args) == 0, "help sukses");
    ASSERT(args.subcommand == CLI_SUB_HELP, "subcommand = HELP");

    char *argv2[] = {"mtool", "version"};
    ASSERT(cli_parse(2, argv2, &args) == 0, "version sukses");
    ASSERT(args.subcommand == CLI_SUB_VERSION, "subcommand = VERSION");

    char *argv3[] = {"mtool", "--help"};
    ASSERT(cli_parse(2, argv3, &args) == 0, "--help sukses");
    ASSERT(args.show_help == 1, "show_help = 1");
}

static void test_cli_parse_unknown_option(void) {
    char *argv[] = {"mtool", "head", "-Z"};
    cli_args args;
    int rc = cli_parse(3, argv, &args);
    ASSERT(rc == -1, "opsi tidak dikenal = error");
}

static void test_config_load(void) {
    const char *path = "/tmp/test_mtool.cfg";
    FILE *fp = fopen(path, "w");
    ASSERT(fp != NULL, "bisa membuat file config");
    fprintf(fp, "# komentar\n");
    fprintf(fp, "log_level = DEBUG\n");
    fprintf(fp, "default_count = 25\n");
    fprintf(fp, "; komentar titik koma\n");
    fprintf(fp, "name = mtool\n");
    fclose(fp);

    config c;
    int n = config_load(&c, path);
    ASSERT(n == 3, "harus ada 3 entri");

    const char *v = config_get(&c, "log_level");
    ASSERT(v != NULL && strcmp(v, "DEBUG") == 0, "log_level = DEBUG");

    int i = config_get_int(&c, "default_count", 10);
    ASSERT(i == 25, "default_count = 25");

    /* key tidak ada -> default */
    i = config_get_int(&c, "tidak_ada", 42);
    ASSERT(i == 42, "key tidak ada = default");
}

static void test_file_stats(void) {
    const char *path = "/tmp/test_mtool_data.txt";
    FILE *fp = fopen(path, "w");
    ASSERT(fp != NULL, "bisa membuat file data");
    fprintf(fp, "hello world\n");
    fprintf(fp, "second line here\n");
    fprintf(fp, "third\n");
    fclose(fp);

    file_stats s;
    int rc = file_stats_compute(path, &s);
    ASSERT(rc == 0, "stats harus sukses");
    ASSERT(s.lines == 3, "lines = 3");
    ASSERT(s.words == 6, "words = 6");
    ASSERT(s.chars > 0, "chars > 0");
}

static void test_file_head_tail(void) {
    const char *path = "/tmp/test_mtool_data.txt";
    /* File ini sudah dibuat di test sebelumnya. */

    /* Head 2 baris: kembalikan jumlah baris yang dicetak. */
    int n = file_head(path, 2);
    ASSERT(n == 2, "head harus cetak 2 baris");

    /* Head dengan n melebihi total baris: tetap OK, cetak semua. */
    n = file_head(path, 100);
    ASSERT(n == 3, "head 100 harus cetak 3 baris (semua)");

    n = file_tail(path, 1);
    ASSERT(n == 1, "tail 1 harus cetak 1 baris");

    n = file_tail(path, 100);
    ASSERT(n == 3, "tail 100 harus cetak 3 baris");
}

static void test_file_grep(void) {
    const char *path = "/tmp/test_mtool_data.txt";

    int n = file_grep(path, "line", 0);
    ASSERT(n == 1, "grep 'line' case-sensitive = 1 (second line here)");

    n = file_grep(path, "LINE", 0);
    ASSERT(n == 0, "grep 'LINE' case-sensitive = 0");

    n = file_grep(path, "LINE", 1);
    ASSERT(n == 1, "grep 'LINE' case-insensitive = 1");

    n = file_grep(path, "hello", 0);
    ASSERT(n == 1, "grep 'hello' = 1");
}

int main(void) {
    test_cli_parse_count();
    test_cli_parse_head_with_n();
    test_cli_parse_grep();
    test_cli_parse_help_version();
    test_cli_parse_unknown_option();
    test_config_load();
    test_file_stats();
    test_file_head_tail();
    test_file_grep();

    printf("\n=== Hasil Test ===\n");
    printf("Total : %d\n", tests_run);
    printf("Lulus : %d\n", tests_pass);
    printf("Gagal : %d\n", tests_run - tests_pass);
    return (tests_run == tests_pass) ? 0 : 1;
}