diff --git a/README.md b/README.md index 5413ea8..7b73f49 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,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 3d4e51a..1df13bf 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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 d121c9b..9afba64 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -146,6 +146,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 eeea505..4b8f913 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -31,6 +31,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, @@ -219,20 +227,31 @@ 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]; if (!config->quiet) printf("Dry run: files to be transferred\n"); while ((chunk = directory_scanner_next(scanner)) != NULL) { for (int i = 0; i < chunk->element_count; i++) { - if (!config->quiet) - printf(" %s (%zu bytes)\n", chunk->items[i]->path, chunk->items[i]->data->size); + if (!config->quiet) { + 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); - if (!config->quiet) - printf("Total: %d files, %.1f MB\n", file_count, total_bytes / 1048576.0); + if (!config->quiet) { + 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; } @@ -659,10 +678,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); } @@ -680,14 +709,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 */ @@ -765,12 +794,12 @@ int send_files(Config* config) { manifest = NULL; break; } + total_bytes += chunk_bytes; if (config->show_progress && !config->quiet) { - 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); @@ -790,12 +819,21 @@ int send_files(Config* config) { if (ok) remove_transferred_sources(config, remove_sources); if (config->show_progress && !config->quiet) - print_transfer_progress(total_bytes, start, "Done.\n"); + print_transfer_progress(total_bytes, start, "Done.\n", config->human_readable); if (config->stats && !config->quiet) { 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 07698f3..5718e03 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -58,6 +58,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..6681758 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -17,5 +17,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 1b1fba8..382cd68 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( @@ -375,6 +381,30 @@ 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 + + def test_human_readable_progress_multithreaded(self, shared_server): + clean_dir(DEST_DIR) + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-m", "-h", "--progress"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" + output = result.stdout + result.stderr + assert "Sent " in output + assert "KB" in output + assert "Done." in output + class TestBandwidthLimit: def test_bwlimit_runs(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index a202a0a..852d773 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -299,8 +299,6 @@ static void test_parse_args_rejects_unimplemented_options() { "--info", "--debug", "--list-only", - "-h", - "--human-readable", "-u", "--update", "--append", @@ -358,6 +356,20 @@ static void test_parse_args_quiet() { } } +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(); @@ -408,6 +420,7 @@ void test_client_cli() { test_parse_args_unknown_option(); test_parse_args_rejects_unimplemented_options(); test_parse_args_quiet(); + test_parse_args_human_readable(); test_parse_args_archive(); test_parse_args_fsync(); } 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);