diff --git a/README.md b/README.md index 6962093..5413ea8 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ partial, alternate, and planned behavior. | `-n, --dry-run` | Scan and print what would be transferred | | `-p ` | SSH port (default: 22) | | `-v, --verbose` | Enable debug logging | +| `-q, --quiet` | Suppress non-error output | | `--progress` | Show real-time transfer speed | | `--delete` | Delete files on receiver not present in source | | `--exclude ` | Exclude files matching glob pattern (repeatable) | diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index ee9d621..3d4e51a 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -6,11 +6,11 @@ This document maps rsync's full feature set to FastSync's current implementation | Status | Count | Description | |--------|-------|-------------| -| ✅ Implemented | 34 | Feature works end-to-end | +| ✅ Implemented | 35 | Feature works end-to-end | | 🔀 Alt Arg | 3 | Functionality exists but under different flag/semantics | | ⚠️ Partial | 1 | Flag parsed/stored but behavior incomplete | | ❌ Not Implemented | 98 | Flag not recognized or no behavior | -| **Total** | **136** | | +| **Total** | **137** | | --- @@ -20,7 +20,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 | +| `-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 | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 522049a..d121c9b 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -212,6 +212,7 @@ static int apply_table_option(Config* config, const OptionEntry* entry, const ch /* Parse CLI arguments into config. Returns 0 on success, -1 on error, 1 for help/clean-exit. */ int parse_args(Config* config, int argc, char* argv[], int* positional_args, int* positional_count) { + bool verbose = false; for (int i = 1; i < argc; i++) { const OptionEntry* entry = find_table_option(argv[i]); if (entry) { @@ -352,7 +353,9 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, 0) return -1; } else if (opt_is(argv[i], "-v", "--verbose")) { - set_log_level(LOG_LEVEL_DEBUG); + verbose = true; + } else if (opt_is(argv[i], "-q", "--quiet")) { + config->quiet = true; } else if (opt_is(argv[i], "-T", NULL) && i + 1 < argc) { if (set_positive_int_option(&config->timeout, argv[++i], "-T") != 0) return -1; @@ -377,6 +380,7 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, } } } + set_log_level(config->quiet ? LOG_LEVEL_ERROR : (verbose ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING)); return 0; } diff --git a/src/client/client_send.c b/src/client/client_send.c index f32e569..eeea505 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -219,17 +219,20 @@ static int send_dry_run_manifest(const Config* config) { Chunk* chunk; int file_count = 0; unsigned long long total_bytes = 0; - printf("Dry run: files to be transferred\n"); + 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++) { - printf(" %s (%zu bytes)\n", chunk->items[i]->path, chunk->items[i]->data->size); + if (!config->quiet) + 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->quiet) + printf("Total: %d files, %.1f MB\n", file_count, total_bytes / 1048576.0); return 0; } @@ -762,7 +765,7 @@ int send_files(Config* config) { manifest = NULL; break; } - if (config->show_progress) { + if (config->show_progress && !config->quiet) { total_bytes += chunk_bytes; time_t now = time(NULL); if (now - last_progress >= 1) { @@ -786,9 +789,9 @@ int send_files(Config* config) { bool ok = finalize_transfer(client); if (ok) remove_transferred_sources(config, remove_sources); - if (config->show_progress) + if (config->show_progress && !config->quiet) print_transfer_progress(total_bytes, start, "Done.\n"); - if (config->stats) { + 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, @@ -884,7 +887,7 @@ int send_files_multithreaded(Config** config_ptr) { thrd_t progress; bool progress_created = false; - if (config->show_progress) { + if (config->show_progress && !config->quiet) { progress_created = (thrd_create(&progress, progress_thread_fn, context) == thrd_success); if (!progress_created) { log_perror("Error creating progress thread"); diff --git a/src/client/usage.c b/src/client/usage.c index fe7ad7d..07698f3 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -38,6 +38,7 @@ void print_usage(void) { printf(" -s Enable chunk serialization\n"); printf(" -f Enable sendfile (TCP only, not with -c or -s)\n"); printf(" -v, --verbose Enable debug logging\n"); + printf(" -q, --quiet Suppress non-error output\n"); printf(" -M, --preserve Preserve file metadata\n"); printf(" --chunk-size Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE); printf(" --source-dir Source directory\n"); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 45cd0d3..1b1fba8 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -35,6 +35,39 @@ class TestDryRun: assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert "Dry run:" in result.stdout, f"No dry run output: {result.stdout[:200]}" + def test_quiet_suppresses_dry_run_output(self): + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["-q", "-n", "--progress", "--stats"], + ) + assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" + assert result.stdout == "" + assert result.stderr == "" + + def test_quiet_preserves_errors(self): + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--quiet", "--server-port", "1"], + ) + assert result.returncode != 0 + assert result.stderr != "" + + @pytest.mark.parametrize("flags", [["-q", "-v"], ["-v", "-q"]]) + def test_quiet_successful_transfer_and_verbose_order(self, shared_server, flags): + clean_dir(DEST_DIR) + result, dur = run_client( + SOURCE_DIR, DEST_DIR, + flags=flags, + port=shared_server.port, + ) + assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" + assert result.stdout == "" + assert result.stderr == "" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"Missing: {missing}" + assert not mismatches, f"Mismatch: {mismatches}" + class TestRemoveSourceFiles: def test_removes_only_transferred_regular_files(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 9320ac4..a202a0a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -283,9 +283,7 @@ static void test_parse_args_unknown_option() { /* Parsed-but-unimplemented options must fail instead of being silently accepted. */ static void test_parse_args_rejects_unimplemented_options() { - static const char* const options[] = {"-q", - "--quiet", - "--silent", + static const char* const options[] = {"--silent", "--queue-size", "-H", "--hard-links", @@ -344,6 +342,22 @@ static void test_parse_args_rejects_unimplemented_options() { } } +/* Test both rsync-compatible quiet spellings and option ordering. */ +static void test_parse_args_quiet() { + static const char* const options[][2] = { + {"-q", "-v"}, {"-v", "-q"}, {"--quiet", "-v"}, {"-v", "--quiet"}}; + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", (char*)options[i][0], (char*)options[i][1], "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->quiet); + config_delete(cfg); + } +} + /* Test parse_args with --archive flag */ static void test_parse_args_archive() { Config* cfg = config_create(); @@ -393,6 +407,7 @@ void test_client_cli() { test_parse_args_valid_compression_level(); test_parse_args_unknown_option(); test_parse_args_rejects_unimplemented_options(); + test_parse_args_quiet(); test_parse_args_archive(); test_parse_args_fsync(); }