Merge remote-tracking branch 'origin/feat/human-readable' into dev
# Conflicts: # RSYNC_COMPAT.md # src/client/client_send.c # tests/test_client_cli.c
This commit is contained in:
@@ -110,6 +110,7 @@ partial, alternate, and planned behavior.
|
|||||||
| `--backup` | Backup existing destination files before overwriting |
|
| `--backup` | Backup existing destination files before overwriting |
|
||||||
| `--backup-dir <dir>` | Target directory for backups (requires `--backup`) |
|
| `--backup-dir <dir>` | Target directory for backups (requires `--backup`) |
|
||||||
| `--stats` | Print transfer statistics at end (bytes, files, timing) |
|
| `--stats` | Print transfer statistics at end (bytes, files, timing) |
|
||||||
|
| `-h, --human-readable` | Format transfer byte sizes with binary units |
|
||||||
| `--max-depth <n>` | Maximum directory depth to recurse (0 = unlimited, default: 0) |
|
| `--max-depth <n>` | Maximum directory depth to recurse (0 = unlimited, default: 0) |
|
||||||
| `--log-file <path>` | Write log messages to file instead of stderr |
|
| `--log-file <path>` | Write log messages to file instead of stderr |
|
||||||
| `--source-dir <path>` | Source directory (overrides `FASTSYNC_SOURCE_DIR`) |
|
| `--source-dir <path>` | Source directory (overrides `FASTSYNC_SOURCE_DIR`) |
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
|||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| Flag | Rsync Description | FastSync Status | Notes |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts |
|
| `--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 |
|
| `-i`, `--itemize-changes` | Per-file change summary | ❌ Not Implemented | Removed because it had no effect |
|
||||||
| `--progress` | Show progress | ✅ Implemented | Progress callback in sender |
|
| `--progress` | Show progress | ✅ Implemented | Progress callback in sender |
|
||||||
| `-P` | Same as --partial --progress | ❌ Not Implemented | |
|
| `-P` | Same as --partial --progress | ❌ Not Implemented | |
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ static const OptionEntry OPTION_TABLE[] = {
|
|||||||
{"--tls", NULL, OPT_FLAG, offsetof(Config, use_tls)},
|
{"--tls", NULL, OPT_FLAG, offsetof(Config, use_tls)},
|
||||||
{"--backup", NULL, OPT_FLAG, offsetof(Config, backup)},
|
{"--backup", NULL, OPT_FLAG, offsetof(Config, backup)},
|
||||||
{"--stats", NULL, OPT_FLAG, offsetof(Config, stats)},
|
{"--stats", NULL, OPT_FLAG, offsetof(Config, stats)},
|
||||||
|
{"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)},
|
||||||
{"--partial", NULL, OPT_FLAG, offsetof(Config, partial)},
|
{"--partial", NULL, OPT_FLAG, offsetof(Config, partial)},
|
||||||
{"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)},
|
{"--links", "-l", OPT_FLAG, offsetof(Config, follow_symlinks)},
|
||||||
{"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)},
|
{"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)},
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
/* Forward declaration for progress-reporting thread used in multithreaded send. */
|
/* Forward declaration for progress-reporting thread used in multithreaded send. */
|
||||||
static int progress_thread_fn(void* arg);
|
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) {
|
static ScannerOptions scanner_options_from_config(const Config* config, int num_threads) {
|
||||||
ScannerOptions options = {
|
ScannerOptions options = {
|
||||||
config->use_metadata, config->chunk_size, config->exclude_patterns,
|
config->use_metadata, config->chunk_size, config->exclude_patterns,
|
||||||
@@ -219,20 +227,31 @@ static int send_dry_run_manifest(const Config* config) {
|
|||||||
Chunk* chunk;
|
Chunk* chunk;
|
||||||
int file_count = 0;
|
int file_count = 0;
|
||||||
unsigned long long total_bytes = 0;
|
unsigned long long total_bytes = 0;
|
||||||
|
char size_buffer[32];
|
||||||
if (!config->quiet)
|
if (!config->quiet)
|
||||||
printf("Dry run: files to be transferred\n");
|
printf("Dry run: files to be transferred\n");
|
||||||
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||||
for (int i = 0; i < chunk->element_count; i++) {
|
for (int i = 0; i < chunk->element_count; i++) {
|
||||||
if (!config->quiet)
|
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);
|
printf(" %s (%zu bytes)\n", chunk->items[i]->path, chunk->items[i]->data->size);
|
||||||
|
}
|
||||||
total_bytes += chunk->items[i]->data->size;
|
total_bytes += chunk->items[i]->data->size;
|
||||||
file_count++;
|
file_count++;
|
||||||
}
|
}
|
||||||
chunk_destroy(chunk);
|
chunk_destroy(chunk);
|
||||||
}
|
}
|
||||||
directory_scanner_destroy(scanner);
|
directory_scanner_destroy(scanner);
|
||||||
if (!config->quiet)
|
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);
|
printf("Total: %d files, %.1f MB\n", file_count, total_bytes / 1048576.0);
|
||||||
|
}
|
||||||
return 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
|
line (e.g. "Done.\n") or is "" for in-place refresh. Shared by the
|
||||||
single-threaded loop and the multithreaded progress thread. */
|
single-threaded loop and the multithreaded progress thread. */
|
||||||
static void print_transfer_progress(unsigned long long total_bytes, time_t start,
|
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 elapsed = difftime(time(NULL), start);
|
||||||
double rate = elapsed > 0.0 ? total_bytes / (1048576.0 * elapsed) : 0.0;
|
double rate = elapsed > 0.0 ? total_bytes / (1048576.0 * elapsed) : 0.0;
|
||||||
|
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);
|
fprintf(stderr, "\rSent %.1f MB (%.1f MB/s) %s", total_bytes / 1048576.0, rate, suffix);
|
||||||
|
}
|
||||||
fflush(stderr);
|
fflush(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,14 +709,14 @@ static int progress_thread_fn(void* arg) {
|
|||||||
mtx_unlock(&context->mutex_progress);
|
mtx_unlock(&context->mutex_progress);
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
print_transfer_progress(total, start, "Done.\n");
|
print_transfer_progress(total, start, "Done.\n", context->config->human_readable);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
time_t now = time(NULL);
|
time_t now = time(NULL);
|
||||||
if (now - last_progress >= 1) {
|
if (now - last_progress >= 1) {
|
||||||
last_progress = now;
|
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 */
|
struct timespec ts = {0, 100 * 1000000L}; /* 100 ms */
|
||||||
@@ -765,12 +794,12 @@ int send_files(Config* config) {
|
|||||||
manifest = NULL;
|
manifest = NULL;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (config->show_progress && !config->quiet) {
|
|
||||||
total_bytes += chunk_bytes;
|
total_bytes += chunk_bytes;
|
||||||
|
if (config->show_progress && !config->quiet) {
|
||||||
time_t now = time(NULL);
|
time_t now = time(NULL);
|
||||||
if (now - last_progress >= 1) {
|
if (now - last_progress >= 1) {
|
||||||
last_progress = now;
|
last_progress = now;
|
||||||
print_transfer_progress(total_bytes, start, "");
|
print_transfer_progress(total_bytes, start, "", config->human_readable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chunk_destroy(current_chunk);
|
chunk_destroy(current_chunk);
|
||||||
@@ -790,13 +819,22 @@ int send_files(Config* config) {
|
|||||||
if (ok)
|
if (ok)
|
||||||
remove_transferred_sources(config, remove_sources);
|
remove_transferred_sources(config, remove_sources);
|
||||||
if (config->show_progress && !config->quiet)
|
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) {
|
if (config->stats && !config->quiet) {
|
||||||
double elapsed_total = difftime(time(NULL), start);
|
double elapsed_total = difftime(time(NULL), start);
|
||||||
double rate = elapsed_total > 0 ? total_bytes / (1048576.0 * elapsed_total) : 0;
|
double rate = elapsed_total > 0 ? total_bytes / (1048576.0 * elapsed_total) : 0;
|
||||||
|
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,
|
fprintf(stderr, "Stats: %d files, %.1f MB, %.1f MB/s\n", total_files, total_bytes / 1048576.0,
|
||||||
rate);
|
rate);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ret = ok ? 0 : 1;
|
ret = ok ? 0 : 1;
|
||||||
|
|
||||||
send_fail:
|
send_fail:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ void print_usage(void) {
|
|||||||
printf(" --backup-dir <dir> Directory for backups (requires --backup)\n");
|
printf(" --backup-dir <dir> Directory for backups (requires --backup)\n");
|
||||||
printf(" --suffix <str> Backup suffix (default: ~)\n");
|
printf(" --suffix <str> Backup suffix (default: ~)\n");
|
||||||
printf(" --stats Print transfer statistics at end\n");
|
printf(" --stats Print transfer statistics at end\n");
|
||||||
|
printf(" -h, --human-readable Print byte sizes in human-readable form\n");
|
||||||
printf(" --max-depth <n> Maximum directory depth (0=unlimited)\n");
|
printf(" --max-depth <n> Maximum directory depth (0=unlimited)\n");
|
||||||
printf(" --log-file <path> Write log messages to file\n");
|
printf(" --log-file <path> Write log messages to file\n");
|
||||||
printf(" --partial Keep partial files on interrupted transfer\n");
|
printf(" --partial Keep partial files on interrupted transfer\n");
|
||||||
|
|||||||
@@ -187,6 +187,25 @@ bool glob_match(const char* pattern, const char* str) {
|
|||||||
return *str == '\0';
|
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) {
|
static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) {
|
||||||
size_t len = strlen(rel_path);
|
size_t len = strlen(rel_path);
|
||||||
for (int i = 0; i < manifest->size; i++) {
|
for (int i = 0; i < manifest->size; i++) {
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ bool utils_set_authorized_root(int fd, const char* canonical_path);
|
|||||||
void utils_set_authorized_root_fd(int fd);
|
void utils_set_authorized_root_fd(int fd);
|
||||||
bool has_path_traversal(const char* path);
|
bool has_path_traversal(const char* path);
|
||||||
bool utils_valid_batch_path(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
|
#endif
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ def setup_test_data():
|
|||||||
|
|
||||||
|
|
||||||
class TestDryRun:
|
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):
|
def test_dry_run(self):
|
||||||
clean_dir(DEST_DIR)
|
clean_dir(DEST_DIR)
|
||||||
result, dur = run_client(
|
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 "Sent " in output and "MB" in output, "--progress produced no stable byte marker"
|
||||||
assert "Done." in output, "--progress did not report completion"
|
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:
|
class TestBandwidthLimit:
|
||||||
def test_bwlimit_runs(self, shared_server):
|
def test_bwlimit_runs(self, shared_server):
|
||||||
|
|||||||
+15
-2
@@ -299,8 +299,6 @@ static void test_parse_args_rejects_unimplemented_options() {
|
|||||||
"--info",
|
"--info",
|
||||||
"--debug",
|
"--debug",
|
||||||
"--list-only",
|
"--list-only",
|
||||||
"-h",
|
|
||||||
"--human-readable",
|
|
||||||
"-u",
|
"-u",
|
||||||
"--update",
|
"--update",
|
||||||
"--append",
|
"--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 */
|
/* Test parse_args with --archive flag */
|
||||||
static void test_parse_args_archive() {
|
static void test_parse_args_archive() {
|
||||||
Config* cfg = config_create();
|
Config* cfg = config_create();
|
||||||
@@ -408,6 +420,7 @@ void test_client_cli() {
|
|||||||
test_parse_args_unknown_option();
|
test_parse_args_unknown_option();
|
||||||
test_parse_args_rejects_unimplemented_options();
|
test_parse_args_rejects_unimplemented_options();
|
||||||
test_parse_args_quiet();
|
test_parse_args_quiet();
|
||||||
|
test_parse_args_human_readable();
|
||||||
test_parse_args_archive();
|
test_parse_args_archive();
|
||||||
test_parse_args_fsync();
|
test_parse_args_fsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
void test_shared_utils() {
|
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
|
// Test str_dup
|
||||||
const char* dup_null = str_dup(NULL);
|
const char* dup_null = str_dup(NULL);
|
||||||
EXPECT_NULL(dup_null);
|
EXPECT_NULL(dup_null);
|
||||||
|
|||||||
Reference in New Issue
Block a user