From 8ab5026224f3b97df5d086ade0b375a7b65a8f23 Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 3 Sep 2026 15:58:48 +0200 Subject: [PATCH] feat: add human-readable output flag --- README.md | 1 + RSYNC_COMPAT.md | 4 +-- src/client/client_cli.c | 1 + src/client/client_send.c | 58 ++++++++++++++++++++++++------ src/client/usage.c | 1 + src/shared/utils.c | 19 ++++++++++ src/shared/utils.h | 2 ++ tests/integration/test_features.py | 17 +++++++++ tests/test_client_cli.c | 17 +++++++-- tests/test_shared_utils.c | 9 +++++ 10 files changed, 114 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fdbd9f3..edaee00 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ partial, alternate, and planned behavior. | `--backup` | Backup existing destination files before overwriting | | `--backup-dir ` | Target directory for backups (requires `--backup`) | | `--stats` | Print transfer statistics at end (bytes, files, timing) | +| `-h, --human-readable` | Format transfer byte sizes with binary units | | `--max-depth ` | Maximum directory depth to recurse (0 = unlimited, default: 0) | | `--log-file ` | Write log messages to file instead of stderr | | `--source-dir ` | Source directory (overrides `FASTSYNC_SOURCE_DIR`) | diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 4b236ec..0954eec 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -21,7 +21,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `-a`, `--archive` | Archive mode is -rlptgoD | 🔀 Alt Arg | Maps to -c -m -M (compression + multithread + metadata) | | `-v`, `--verbose` | Increase verbosity | ✅ Implemented | Sets `log_level=DEBUG` | | `-q`, `--quiet` | Suppress non-error messages | ❌ Not Implemented | Removed because it had no effect | -| `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted | +| `--help` | Show help | ✅ Implemented | Prints usage and exits | | `-V`, `--version` | Print version | ✅ Implemented | | | `--info=FLAGS` | Fine-grained info verbosity | ❌ Not Implemented | Removed because it had no effect | | `--debug=FLAGS` | Fine-grained debug verbosity | ❌ Not Implemented | Removed because it had no effect | @@ -36,7 +36,7 @@ This document maps rsync's full feature set to FastSync's current implementation | Flag | Rsync Description | FastSync Status | Notes | |------|-------------------|-----------------|-------| | `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts | -| `-h`, `--human-readable` | Human-readable numbers | ❌ Not Implemented | Removed because it had no effect | +| `-h`, `--human-readable` | Human-readable numbers | ✅ Implemented | Formats transfer byte sizes using binary units | | `-i`, `--itemize-changes` | Per-file change summary | ❌ Not Implemented | Removed because it had no effect | | `--progress` | Show progress | ✅ Implemented | Progress callback in sender | | `-P` | Same as --partial --progress | ❌ Not Implemented | | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 191cc07..10c95e1 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -145,6 +145,7 @@ static const OptionEntry OPTION_TABLE[] = { {"--tls", NULL, OPT_FLAG, offsetof(Config, use_tls)}, {"--backup", NULL, OPT_FLAG, offsetof(Config, backup)}, {"--stats", NULL, OPT_FLAG, offsetof(Config, stats)}, + {"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)}, {"--partial", NULL, OPT_FLAG, offsetof(Config, partial)}, {"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)}, {"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)}, diff --git a/src/client/client_send.c b/src/client/client_send.c index 0b4ca5c..0e750b6 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -28,6 +28,14 @@ /* Forward declaration for progress-reporting thread used in multithreaded send. */ static int progress_thread_fn(void* arg); +static const char* display_bytes(unsigned long long bytes, bool human_readable, char* buffer, + size_t buffer_size) { + if (human_readable && format_human_bytes(bytes, buffer, buffer_size)) + return buffer; + snprintf(buffer, buffer_size, "%.1f MB", bytes / 1048576.0); + return buffer; +} + static ScannerOptions scanner_options_from_config(const Config* config, int num_threads) { ScannerOptions options = { config->use_metadata, config->chunk_size, config->exclude_patterns, @@ -134,17 +142,26 @@ static int send_dry_run_manifest(const Config* config) { Chunk* chunk; int file_count = 0; unsigned long long total_bytes = 0; + char size_buffer[32]; printf("Dry run: files to be transferred\n"); while ((chunk = directory_scanner_next(scanner)) != NULL) { for (int i = 0; i < chunk->element_count; i++) { - printf(" %s (%zu bytes)\n", chunk->items[i]->path, chunk->items[i]->data->size); + if (config->human_readable) + printf(" %s (%s)\n", chunk->items[i]->path, + display_bytes(chunk->items[i]->data->size, true, size_buffer, sizeof(size_buffer))); + else + printf(" %s (%zu bytes)\n", chunk->items[i]->path, chunk->items[i]->data->size); total_bytes += chunk->items[i]->data->size; file_count++; } chunk_destroy(chunk); } directory_scanner_destroy(scanner); - printf("Total: %d files, %.1f MB\n", file_count, total_bytes / 1048576.0); + if (config->human_readable) + printf("Total: %d files, %s\n", file_count, + display_bytes(total_bytes, true, size_buffer, sizeof(size_buffer))); + else + printf("Total: %d files, %.1f MB\n", file_count, total_bytes / 1048576.0); return 0; } @@ -548,10 +565,20 @@ static int load_files_multithreaded(void* pipeline_context) { line (e.g. "Done.\n") or is "" for in-place refresh. Shared by the single-threaded loop and the multithreaded progress thread. */ static void print_transfer_progress(unsigned long long total_bytes, time_t start, - const char* suffix) { + const char* suffix, bool human_readable) { double elapsed = difftime(time(NULL), start); double rate = elapsed > 0.0 ? total_bytes / (1048576.0 * elapsed) : 0.0; - fprintf(stderr, "\rSent %.1f MB (%.1f MB/s) %s", total_bytes / 1048576.0, rate, suffix); + if (human_readable) { + char total_buffer[32]; + char rate_buffer[32]; + fprintf(stderr, "\rSent %s (%s/s) %s", + display_bytes(total_bytes, true, total_buffer, sizeof(total_buffer)), + display_bytes((unsigned long long)(rate * 1048576.0), true, rate_buffer, + sizeof(rate_buffer)), + suffix); + } else { + fprintf(stderr, "\rSent %.1f MB (%.1f MB/s) %s", total_bytes / 1048576.0, rate, suffix); + } fflush(stderr); } @@ -569,14 +596,14 @@ static int progress_thread_fn(void* arg) { mtx_unlock(&context->mutex_progress); if (done) { - print_transfer_progress(total, start, "Done.\n"); + print_transfer_progress(total, start, "Done.\n", context->config->human_readable); break; } time_t now = time(NULL); if (now - last_progress >= 1) { last_progress = now; - print_transfer_progress(total, start, ""); + print_transfer_progress(total, start, "", context->config->human_readable); } struct timespec ts = {0, 100 * 1000000L}; /* 100 ms */ @@ -650,12 +677,12 @@ int send_files(Config* config) { manifest = NULL; break; } + total_bytes += chunk_bytes; if (config->show_progress) { - total_bytes += chunk_bytes; time_t now = time(NULL); if (now - last_progress >= 1) { last_progress = now; - print_transfer_progress(total_bytes, start, ""); + print_transfer_progress(total_bytes, start, "", config->human_readable); } } chunk_destroy(current_chunk); @@ -673,12 +700,21 @@ int send_files(Config* config) { } bool ok = finalize_transfer(client); if (config->show_progress) - print_transfer_progress(total_bytes, start, "Done.\n"); + print_transfer_progress(total_bytes, start, "Done.\n", config->human_readable); if (config->stats) { double elapsed_total = difftime(time(NULL), start); double rate = elapsed_total > 0 ? total_bytes / (1048576.0 * elapsed_total) : 0; - fprintf(stderr, "Stats: %d files, %.1f MB, %.1f MB/s\n", total_files, total_bytes / 1048576.0, - rate); + if (config->human_readable) { + char total_buffer[32]; + char rate_buffer[32]; + fprintf(stderr, "Stats: %d files, %s, %s/s\n", total_files, + display_bytes(total_bytes, true, total_buffer, sizeof(total_buffer)), + display_bytes((unsigned long long)(rate * 1048576.0), true, rate_buffer, + sizeof(rate_buffer))); + } else { + fprintf(stderr, "Stats: %d files, %.1f MB, %.1f MB/s\n", total_files, total_bytes / 1048576.0, + rate); + } } ret = ok ? 0 : 1; diff --git a/src/client/usage.c b/src/client/usage.c index 2f800af..266e250 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -56,6 +56,7 @@ void print_usage(void) { printf(" --backup-dir Directory for backups (requires --backup)\n"); printf(" --suffix Backup suffix (default: ~)\n"); printf(" --stats Print transfer statistics at end\n"); + printf(" -h, --human-readable Print byte sizes in human-readable form\n"); printf(" --max-depth Maximum directory depth (0=unlimited)\n"); printf(" --log-file Write log messages to file\n"); printf(" --partial Keep partial files on interrupted transfer\n"); diff --git a/src/shared/utils.c b/src/shared/utils.c index 49c1367..e5efbb3 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -187,6 +187,25 @@ bool glob_match(const char* pattern, const char* str) { return *str == '\0'; } +bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size) { + static const char* const units[] = {"B", "KB", "MB", "GB", "TB", "PB", "EB"}; + double value = (double)bytes; + size_t unit = 0; + int written; + + if (!buffer || buffer_size == 0) + return false; + while (value >= 1024.0 && unit < sizeof(units) / sizeof(units[0]) - 1) { + value /= 1024.0; + unit++; + } + if (unit == 0) + written = snprintf(buffer, buffer_size, "%llu %s", bytes, units[unit]); + else + written = snprintf(buffer, buffer_size, "%.1f %s", value, units[unit]); + return written >= 0 && (size_t)written < buffer_size; +} + static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) { size_t len = strlen(rel_path); for (int i = 0; i < manifest->size; i++) { diff --git a/src/shared/utils.h b/src/shared/utils.h index 757d26f..433fd97 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -4,6 +4,7 @@ #include "array_list.h" #include #include +#include bool mkdir_r(const char* path); char* str_dup(const char* string); @@ -17,5 +18,6 @@ bool utils_set_authorized_root(int fd, const char* canonical_path); void utils_set_authorized_root_fd(int fd); bool has_path_traversal(const char* path); bool utils_valid_batch_path(const char* path); +bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size); #endif diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 312add4..0a36202 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -26,6 +26,12 @@ def setup_test_data(): class TestDryRun: + def test_human_readable_dry_run(self): + result, dur = run_client(SOURCE_DIR, DEST_DIR, flags=["-h", "--dry-run"]) + assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" + assert "Total:" in result.stdout + assert "KB" in result.stdout + def test_dry_run(self): clean_dir(DEST_DIR) result, dur = run_client( @@ -263,6 +269,17 @@ class TestProgress: assert "Sent " in output and "MB" in output, "--progress produced no stable byte marker" assert "Done." in output, "--progress did not report completion" + def test_human_readable_stats(self, shared_server): + clean_dir(DEST_DIR) + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-h", "--stats"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" + assert "Stats:" in result.stderr + assert "KB" in result.stderr + class TestBandwidthLimit: def test_bwlimit_runs(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 656fd40..02c2e73 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -280,8 +280,6 @@ static void test_parse_args_rejects_unimplemented_options() { "--info", "--debug", "--list-only", - "-h", - "--human-readable", "-u", "--update", "--append", @@ -323,6 +321,20 @@ static void test_parse_args_rejects_unimplemented_options() { } } +static void test_parse_args_human_readable() { + static const char* const options[] = {"-h", "--human-readable"}; + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", (char*)options[i], "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->human_readable); + config_delete(cfg); + } +} + /* Test parse_args with --archive flag */ static void test_parse_args_archive() { Config* cfg = config_create(); @@ -359,5 +371,6 @@ void test_client_cli() { test_parse_args_valid_compression_level(); test_parse_args_unknown_option(); test_parse_args_rejects_unimplemented_options(); + test_parse_args_human_readable(); test_parse_args_archive(); } diff --git a/tests/test_shared_utils.c b/tests/test_shared_utils.c index 57b1c74..22d3eb6 100644 --- a/tests/test_shared_utils.c +++ b/tests/test_shared_utils.c @@ -5,6 +5,15 @@ #include void test_shared_utils() { + char formatted[32]; + EXPECT_TRUE(format_human_bytes(0, formatted, sizeof(formatted))); + EXPECT_EQ_STR(formatted, "0 B"); + EXPECT_TRUE(format_human_bytes(1024, formatted, sizeof(formatted))); + EXPECT_EQ_STR(formatted, "1.0 KB"); + EXPECT_TRUE(format_human_bytes(1536 * 1024, formatted, sizeof(formatted))); + EXPECT_EQ_STR(formatted, "1.5 MB"); + EXPECT_FALSE(format_human_bytes(1024, formatted, 4)); + // Test str_dup const char* dup_null = str_dup(NULL); EXPECT_NULL(dup_null);