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);