diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 1a08235..e6f52d3 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -24,8 +24,8 @@ This document maps rsync's full feature set to FastSync's current implementation | `-q`, `--quiet` | Suppress non-error messages | ✅ Implemented | Suppresses client output while preserving errors | | `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted | | `-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 | ⚠️ Partial | `io`, `proto`, `pack`, and `util` are supported; other rsync categories are rejected | +| `--info=FLAGS` | Fine-grained info verbosity | ✅ Implemented | Supports `copy`, `misc`, `skip`, `stats`, `all`, and `none`; explicit flags override `--verbose`, and `none` suppresses info output; unsupported names are rejected | +| `--debug=FLAGS` | Fine-grained debug verbosity | ✅ Implemented | `io`, `proto`, `pack`, and `util` are supported; `--debug=help` lists flags; other rsync categories are rejected | | `--stderr=MODE` | Change stderr output mode | ⚠️ Partial | `errors` (default) and `all` are supported; `client` is rejected because FastSync has no rsync message channel | | `--no-motd` | Suppress daemon MOTD | ❌ Not Implemented | | | `--exclude=PATTERN` | Exclude files matching pattern | ✅ Implemented | Glob matching in scanner | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 5f0ae93..7c7b68c 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -178,6 +178,52 @@ static int parse_debug_flags(const char* value, Config* config) { return 0; } +static int parse_info_flags(const char* value, Config* config) { + if (!value || value[0] == '\0' || value[0] == ',' || value[strlen(value) - 1] == ',' || + strstr(value, ",,")) { + log_message(LOG_LEVEL_ERROR, "--info requires at least one flag"); + return -1; + } + char* flags = str_dup(value); + if (!flags) { + log_message(LOG_LEVEL_ERROR, "memory allocation failed for --info"); + return -1; + } + + uint32_t parsed = (uint32_t)config->info_level; + char* saveptr = NULL; + for (char* token = strtok_r(flags, ",", &saveptr); token != NULL; + token = strtok_r(NULL, ",", &saveptr)) { + uint32_t flag = 0; + if (strcmp(token, "all") == 0) { + parsed = LOG_INFO_ALL; + continue; + } + if (strcmp(token, "none") == 0) { + parsed = 0; + continue; + } + if (strcmp(token, "copy") == 0) + flag = LOG_INFO_COPY; + else if (strcmp(token, "misc") == 0) + flag = LOG_INFO_MISC; + else if (strcmp(token, "skip") == 0) + flag = LOG_INFO_SKIP; + else if (strcmp(token, "stats") == 0) + flag = LOG_INFO_STATS; + else { + log_message(LOG_LEVEL_ERROR, "unsupported --info flag: %s", token); + free(flags); + return -1; + } + parsed |= flag; + } + free(flags); + config->info_level = (int)parsed; + set_log_info_flags(parsed); + return 0; +} + /* Parse a string as an unsigned long long. Returns 0 on success, -1 on error. */ static int parse_ull_arg(const char* val, unsigned long long* out, const char* optname) { char* end; @@ -418,8 +464,22 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, int* positional_count) { bool verbose = false; protocol_set_8_bit_output(config->eight_bit_output); + + /* Apply output controls before processing other options so their order is irrelevant. */ for (int i = 1; i < argc; i++) { -const char* modify_window_prefix = "--modify-window="; + if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { + set_log_level(LOG_LEVEL_DEBUG); + } else if (strncmp(argv[i], "--info=", 7) == 0) { + if (parse_info_flags(argv[i] + 7, config) != 0) + return -1; + } else if (strcmp(argv[i], "--info") == 0) { + if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0) + return -1; + } + } + + for (int i = 1; i < argc; i++) { + const char* modify_window_prefix = "--modify-window="; if (strncmp(argv[i], modify_window_prefix, strlen(modify_window_prefix)) == 0) { if (set_nonneg_int_option(&config->modify_window, argv[i] + strlen(modify_window_prefix), "--modify-window") != 0) @@ -518,7 +578,7 @@ const char* modify_window_prefix = "--modify-window="; !config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; config->use_multithreading = true; config->use_metadata = true; - log_message(LOG_LEVEL_INFO, "Enabled archive mode (-c -m -M)"); + log_info_message(LOG_INFO_MISC, "Enabled archive mode (-c -m -M)"); } else if (opt_is(argv[i], "-p", NULL) && i + 1 < argc) { if (set_positive_int_option(&config->ssh_port, argv[++i], "-p") != 0) return -1; @@ -553,7 +613,7 @@ const char* modify_window_prefix = "--modify-window="; } else if (opt_is(argv[i], "-c", "-z")) { config->use_compression = !config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; - log_message(LOG_LEVEL_INFO, "Enabled Compression"); + log_info_message(LOG_INFO_MISC, "Enabled Compression"); if (i + 1 < argc) { char* end_ptr; long level = strtol(argv[i + 1], &end_ptr, 10); @@ -563,26 +623,26 @@ const char* modify_window_prefix = "--modify-window="; return -1; } config->compression_level = (int)level; - log_message(LOG_LEVEL_INFO, "Set Compression level to %ld", level); + log_info_message(LOG_INFO_MISC, "Set Compression level to %ld", level); i++; } } } else if (opt_is(argv[i], "-M", "--preserve")) { config->use_metadata = true; - log_message(LOG_LEVEL_INFO, "Enabled metadata preservation"); + log_info_message(LOG_INFO_MISC, "Enabled metadata preservation"); } else if (opt_is(argv[i], "-E", "--executability")) { config->use_metadata = true; config->use_executability = true; - log_message(LOG_LEVEL_INFO, "Enabled executable permission preservation"); + log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation"); } else if (opt_is(argv[i], "-f", "--sendfile")) { config->use_sendfile = true; - log_message(LOG_LEVEL_INFO, "Enabled sendfile"); + log_info_message(LOG_INFO_MISC, "Enabled sendfile"); } else if (opt_is(argv[i], "-m", NULL)) { config->use_multithreading = true; - log_message(LOG_LEVEL_INFO, "Enabled Multithreading"); + log_info_message(LOG_INFO_MISC, "Enabled Multithreading"); } else if (opt_is(argv[i], "-s", NULL)) { config->use_chunk_serialization = true; - log_message(LOG_LEVEL_INFO, "Enabled Chunk Serialization"); + log_info_message(LOG_INFO_MISC, "Enabled Chunk Serialization"); } else if (opt_is(argv[i], "--server-port", NULL) && i + 1 < argc) { if (!parse_positive_int(argv[++i], &config->server_port)) { char* escaped = output_escape(argv[i], false); @@ -608,7 +668,7 @@ const char* modify_window_prefix = "--modify-window="; return -1; } io_set_bwlimit(kbps * 1024); - log_message(LOG_LEVEL_INFO, "Set bandwidth limit to %llu KB/s", kbps); + log_info_message(LOG_INFO_MISC, "Set bandwidth limit to %llu KB/s", kbps); } else if (opt_is(argv[i], "--chunk-size", NULL) && i + 1 < argc) { unsigned long long val; if (parse_ull_arg(argv[++i], &val, "--chunk-size") != 0) @@ -650,6 +710,7 @@ const char* modify_window_prefix = "--modify-window="; return -1; } else if (opt_is(argv[i], "-v", "--verbose")) { verbose = true; + set_log_level(LOG_LEVEL_DEBUG); } else if (opt_is(argv[i], "-q", "--quiet")) { config->quiet = true; } else if (strncmp(argv[i], "--debug=", 8) == 0) { @@ -662,6 +723,12 @@ const char* modify_window_prefix = "--modify-window="; int debug_ret = parse_debug_flags(argv[++i], config); if (debug_ret != 0) return debug_ret; + } else if (strncmp(argv[i], "--info=", 7) == 0) { + if (parse_info_flags(argv[i] + 7, config) != 0) + return -1; + } else if (opt_is(argv[i], "--info", NULL)) { + if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0) + return -1; } else if (opt_is(argv[i], "-T", NULL) && i + 1 < argc) { if (set_positive_int_option(&config->timeout, argv[++i], "-T") != 0) return -1; @@ -806,11 +873,11 @@ int main(int argc, char* argv[]) { /* Enable implicit flags */ if (config->use_incremental && !config->use_metadata) { - log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for --incremental"); + log_info_message(LOG_INFO_MISC, "Enabling metadata preservation for --incremental"); config->use_metadata = true; } if (config->use_delta && !config->use_metadata) { - log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for --delta"); + log_info_message(LOG_INFO_MISC, "Enabling metadata preservation for --delta"); config->use_metadata = true; } /* Initialize TLS if needed */ diff --git a/src/client/client_send.c b/src/client/client_send.c index af2c0ec..0d47a77 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -240,8 +240,9 @@ static int send_dry_run_manifest(const Config* config) { return -1; } if (config->human_readable) - printf(" %s (%s)\n", escaped_path, - display_bytes(chunk->items[i]->data->size, true, size_buffer, sizeof(size_buffer))); + printf( + " %s (%s)\n", escaped_path, + display_bytes(chunk->items[i]->data->size, true, size_buffer, sizeof(size_buffer))); else printf(" %s (%zu bytes)\n", escaped_path, chunk->items[i]->data->size); free(escaped_path); @@ -397,6 +398,7 @@ static bool send_file_direct_sendfile(File* file, int fd, bool use_metadata, con static int send_single_file(Client* client, File* file, Config* config, bool use_incremental, bool use_sendfile) { int compression_level = config->use_compression ? config->compression_level : 0; + log_info_message(LOG_INFO_COPY, "Transferring %s", file->path); if (!use_incremental) { if (use_sendfile) { @@ -415,6 +417,7 @@ static int send_single_file(Client* client, File* file, Config* config, bool use DeltaSignature* sig = NULL; int rc = incremental_check(client, file, config, &sig); if (rc == 1) { + log_info_message(LOG_INFO_SKIP, "Skipping unchanged %s", file->path); delta_signature_destroy(sig); return 1; } @@ -447,6 +450,7 @@ static int send_single_file(Client* client, File* file, Config* config, bool use return -1; } if (rc == 1) { + log_info_message(LOG_INFO_SKIP, "Skipping unchanged %s", file->path); delta_signature_destroy(sig); return 1; } @@ -574,6 +578,14 @@ static int send_chunks_multithreaded(void* pipeline_context) { bool ok = finalize_transfer(client); if (ok) remove_transferred_sources(context->config, context->remove_source_files); + mtx_lock(&context->mutex_progress); + int total_files = context->total_files; + unsigned long long total_bytes = context->total_bytes; + mtx_unlock(&context->mutex_progress); + if (context->config->stats) + fprintf(stderr, "Stats: %d files, %.1f MB\n", total_files, total_bytes / 1048576.0); + log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files, + total_bytes / 1048576.0); disconnect_transfer_client(client); mark_sender_done(context); protocol_session_unbind(); @@ -596,16 +608,19 @@ static int send_chunks_multithreaded(void* pipeline_context) { protocol_session_unbind(); return thrd_error; } - if (context->config->show_progress) { - unsigned long long chunk_bytes = 0; - for (int i = 0; i < current_chunk->element_count; i++) { - if (current_chunk->items[i] && current_chunk->items[i]->data) - chunk_bytes += current_chunk->items[i]->data->size; + unsigned long long chunk_bytes = 0; + int chunk_files = 0; + for (int i = 0; i < current_chunk->element_count; i++) { + if (current_chunk->items[i] && current_chunk->items[i]->data) { + chunk_files++; + chunk_bytes += current_chunk->items[i]->data->size; } - mtx_lock(&context->mutex_progress); - context->progress_bytes += chunk_bytes; - mtx_unlock(&context->mutex_progress); } + mtx_lock(&context->mutex_progress); + context->total_files += chunk_files; + context->total_bytes += chunk_bytes; + context->progress_bytes = context->total_bytes; + mtx_unlock(&context->mutex_progress); chunk_destroy(current_chunk); } } @@ -871,6 +886,8 @@ int send_files(Config* config) { rate); } } + log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files, + total_bytes / 1048576.0); ret = ok ? 0 : 1; send_fail: diff --git a/src/client/usage.c b/src/client/usage.c index 93f2413..4eb7179 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -52,6 +52,8 @@ void print_usage(void) { printf(" -v, --verbose Enable debug logging\n"); printf(" -q, --quiet Suppress non-error output\n"); printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n"); + printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n"); + printf(" none suppresses info even with --verbose\n"); printf(" -M, --preserve Preserve file metadata\n"); printf(" -E, --executability Preserve executable permission bits\n"); printf(" --chmod Modify transferred permissions (rsync syntax)\n"); diff --git a/src/shared/log.c b/src/shared/log.c index 0f4dec8..65189b4 100644 --- a/src/shared/log.c +++ b/src/shared/log.c @@ -1,5 +1,6 @@ #include "log.h" #include +#include #include #include #include @@ -8,6 +9,8 @@ static const char* log_level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"}; static LogLevel current_log_level = LOG_LEVEL_WARNING; static uint32_t current_debug_flags = 0; +static uint32_t info_flags = 0; +static bool info_flags_explicit = false; static FILE* log_fp = NULL; static _Thread_local bool eight_bit_output; static LogStderrMode stderr_mode = LOG_STDERR_ERRORS; @@ -24,6 +27,15 @@ uint32_t get_log_debug_flags(void) { return current_debug_flags; } +void set_log_info_flags(uint32_t flags) { + info_flags = flags; + info_flags_explicit = true; +} + +uint32_t get_log_info_flags(void) { + return info_flags; +} + void log_set_file(FILE* fp) { log_fp = fp; } @@ -101,6 +113,28 @@ void log_debug_message(LogDebugFlag flag, const char* format, ...) { } } +void log_info_message(LogInfoFlag flag, const char* format, ...) { + if ((info_flags_explicit && (info_flags & flag) == 0) || + (!info_flags_explicit && current_log_level > LOG_LEVEL_DEBUG)) + return; + + time_t now = time(NULL); + struct tm t; + if (!localtime_r(&now, &t)) + return; + + va_list args; + va_start(args, format); + write_message(stdout, LOG_LEVEL_INFO, t, format, args); + va_end(args); + + if (log_fp) { + va_start(args, format); + write_message(log_fp, LOG_LEVEL_INFO, t, format, args); + va_end(args); + } +} + void log_perror(const char* context) { log_message(LOG_LEVEL_ERROR, "%s: %s", context, strerror(errno)); } diff --git a/src/shared/log.h b/src/shared/log.h index 849199f..881aae3 100644 --- a/src/shared/log.h +++ b/src/shared/log.h @@ -16,12 +16,23 @@ typedef enum { LOG_DEBUG_ALL = (1u << 4) - 1, } LogDebugFlag; +typedef enum { + LOG_INFO_COPY = 1u << 0, + LOG_INFO_MISC = 1u << 1, + LOG_INFO_SKIP = 1u << 2, + LOG_INFO_STATS = 1u << 3, + LOG_INFO_ALL = LOG_INFO_COPY | LOG_INFO_MISC | LOG_INFO_SKIP | LOG_INFO_STATS, +} LogInfoFlag; + void log_message(LogLevel log_level, const char* message, ...); void log_perror(const char* context); void set_log_level(LogLevel level); void set_log_debug_flags(uint32_t flags); uint32_t get_log_debug_flags(void); void log_debug_message(LogDebugFlag flag, const char* message, ...); +void set_log_info_flags(uint32_t flags); +uint32_t get_log_info_flags(void); +void log_info_message(LogInfoFlag flag, const char* message, ...); void log_set_file(FILE* fp); void log_set_8_bit_output(bool enabled); bool log_get_8_bit_output(void); diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 5944ef6..cc357e1 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -27,7 +27,9 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que context->loader_done = false; context->manifest = NULL; context->remove_source_files = NULL; + context->total_files = 0; context->progress_bytes = 0; + context->total_bytes = 0; context->sender_done = false; atomic_init(&context->cancelled, false); protocol_session_init(&context->allocation_session, -1, -1); diff --git a/src/shared/multiprocessing.h b/src/shared/multiprocessing.h index 8a9b79b..5431c1f 100644 --- a/src/shared/multiprocessing.h +++ b/src/shared/multiprocessing.h @@ -26,7 +26,9 @@ typedef struct { ArrayList* manifest; ArrayList* remove_source_files; mtx_t mutex_progress; + int total_files; unsigned long long progress_bytes; + unsigned long long total_bytes; bool sender_done; atomic_bool cancelled; ProtocolSession allocation_session; diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 71704c3..40812b4 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -741,6 +741,51 @@ class TestProgress: assert "Done." in output +class TestInfo: + def test_info_copy_reports_transfers(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--info=copy"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Info sync failed: {(result.stderr or result.stdout)[:200]}" + output = result.stdout + result.stderr + assert "[INFO]" in output and "Transferring" in output + + def test_info_stats_reports_multithreaded_transfer(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-m", "--info=stats"], + port=shared_server.port, + ) + assert result.returncode == 0, f"Info stats sync failed: {(result.stderr or result.stdout)[:200]}" + output = result.stdout + result.stderr + assert "[INFO]" in output and "Transfer summary:" in output + + def test_info_rejects_unknown_flag(self): + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--info=unknown"], + ) + assert result.returncode != 0 + assert "unsupported --info flag" in result.stderr + + @pytest.mark.parametrize("flags", [ + ["--info=none", "--verbose"], + ["--verbose", "--info=none"], + ]) + def test_info_none_suppresses_verbose_info(self, shared_server, flags): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) + assert result.returncode == 0, f"Info sync failed: {(result.stderr or result.stdout)[:200]}" + output = result.stdout + result.stderr + assert "[INFO]" not in output + assert "Transferring" not in output + assert "Transfer summary:" not in output + + class TestBandwidthLimit: def test_bwlimit_runs(self, shared_server): clean_dir(DEST_DIR) diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index d8a8519..4f48539 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -561,7 +561,6 @@ static void test_parse_args_rejects_unimplemented_options() { "-i", "--itemize-changes", "--out-format", - "--info", "--list-only", "--append", "--append-verify", @@ -646,6 +645,48 @@ static void test_parse_args_update() { config_delete(cfg); } +static void test_parse_args_info_flags() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--info=copy,skip", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->info_level, LOG_INFO_COPY | LOG_INFO_SKIP); + EXPECT_EQ_INT(get_log_info_flags(), LOG_INFO_COPY | LOG_INFO_SKIP); + config_delete(cfg); +} + +static void test_parse_args_info_verbose_order() { + char* argv_info_first[] = {"fastsync", "--info=none", "--verbose", "/src", "/dst"}; + char* argv_verbose_first[] = {"fastsync", "--verbose", "--info=none", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + Config* cfg = config_create(); + EXPECT_EQ_INT(parse_args(cfg, 5, argv_info_first, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->info_level, 0); + EXPECT_EQ_INT(get_log_info_flags(), 0); + config_delete(cfg); + + cfg = config_create(); + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv_verbose_first, positional_args, &positional_count), 0); + EXPECT_EQ_INT(cfg->info_level, 0); + EXPECT_EQ_INT(get_log_info_flags(), 0); + config_delete(cfg); +} + +static void test_parse_args_rejects_invalid_info_flag() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--info=copy,unknown", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1); + config_delete(cfg); +} + /* Test parse_args with --archive flag */ static void test_parse_args_archive() { Config* cfg = config_create(); @@ -909,7 +950,7 @@ void test_client_cli() { test_parse_args_invalid_server_port(); test_parse_args_invalid_compression_level(); test_parse_args_valid_compression_level(); -test_parse_args_debug_flags(); + test_parse_args_debug_flags(); test_parse_args_debug_help(); test_parse_args_debug_flags_validation(); test_parse_args_modify_window(); @@ -924,6 +965,9 @@ test_parse_args_debug_flags(); test_parse_args_quiet(); test_parse_args_human_readable(); test_parse_args_update(); + test_parse_args_info_flags(); + test_parse_args_info_verbose_order(); + test_parse_args_rejects_invalid_info_flag(); test_parse_args_archive(); test_parse_args_fsync(); test_parse_args_existing();