diff --git a/CMakeLists.txt b/CMakeLists.txt index a9ce285..f9b2dcc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,7 +82,7 @@ set(TEST_INCLUDES tests src/shared src/server src/client) # Monolithic test binary (backward compatible) file(GLOB TEST_SRCS "tests/test_*.c" "tests/runner.c") -add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_RECEIVER_SRCS} src/client/scanner.c src/client/client_cli.c src/client/client_validation.c src/client/usage.c) +add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_RECEIVER_SRCS} src/client/scanner.c src/client/change_list.c src/client/client_cli.c src/client/client_validation.c src/client/usage.c) target_include_directories(tests PRIVATE ${TEST_INCLUDES}) target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD) target_link_libraries(tests PRIVATE ${TEST_LIBS}) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 6cf0454..81e9cc2 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 | 51 | Feature works end-to-end | +| ✅ Implemented | 54 | Feature works end-to-end | | 🔀 Alt Arg | 3 | Functionality exists but under different flag/semantics | | ⚠️ Partial | 5 | Flag parsed/stored but behavior incomplete | | 🔄 Compatibility No-op | 1 | Flag is accepted for CLI compatibility but has no effect | -| ❌ Not Implemented | 87 | Flag not recognized or no behavior | +| ❌ Not Implemented | 84 | Flag not recognized or no behavior | | **Total** | **147** | | --- @@ -38,14 +38,14 @@ This document maps rsync's full feature set to FastSync's current implementation |------|-------------------|-----------------|-------| | `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts | | `-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 | ✅ Implemented | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-m`); unchanged files print nothing, matching single-`-i` behavior | | `--progress` | Show progress | ✅ Implemented | Progress callback in sender | | `-P` | Same as --partial --progress | ⚠️ Partial | Parses and enables progress, but interrupted files are not retained for resumable transfers | -| `--out-format=FORMAT` | Custom output format | ❌ Not Implemented | Removed because it had no effect | +| `--out-format=FORMAT` | Custom output format | ✅ Implemented | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%`; unknown escapes preserved | | `--log-file=FILE` | Log to file | ✅ Implemented | `log_file` config field | -| `--log-file-format=FMT` | Log format | ❌ Not Implemented | | +| `--log-file-format=FMT` | Log format | ✅ Implemented | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` | | `--8-bit-output`, `-8` | Leave high-bit chars unescaped | ✅ Implemented | Applies to displayed paths and protocol debug output | -| `--list-only` | List files instead of copying | ❌ Not Implemented | Removed because it had no effect | +| `--list-only` | List files instead of copying | ✅ Implemented | `ls -l`-style listing of files that would be transferred; scans the source only, contacts no server, writes nothing; also works with `-n` | ## 3. File Selection diff --git a/src/client/change_list.c b/src/client/change_list.c new file mode 100644 index 0000000..51b1687 --- /dev/null +++ b/src/client/change_list.c @@ -0,0 +1,296 @@ +#include "change_list.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include +#include + +/* Itemize code emitted for a transferred regular file. + * + * Layout (rsync-compatible 11-char item): `>f` marks a regular file that was + * transferred to the remote host; the trailing nine markers are, in order, + * c(hecksum) s(ize) t(ime) p(erms) o(wner) g(roup) u(ser/acl) a(ttrs) x(attrs). + * Every marker is `+` (FastSync does not compare each attribute on the + * receiving side, so a sent file is reported as fully updated). Files that + * are already up to date print no line at all, matching rsync's single -i + * which only itemizes changes. + * + * Because the scanner only yields regular-file transfer candidates, `>d` + * (directory) lines are never produced; directories are not transferred as + * items by FastSync. */ +#define ITEMIZE_SENT_FILE ">f+++++++++" + +typedef struct { + char* data; + size_t length; + size_t capacity; +} StrBuf; + +static void strbuf_free(StrBuf* buf) { + if (buf == NULL) + return; + free(buf->data); + buf->data = NULL; + buf->length = 0; + buf->capacity = 0; +} + +static bool strbuf_reserve(StrBuf* buf, size_t extra) { + if (buf->length > SIZE_MAX - extra - 1) + return false; + size_t need = buf->length + extra + 1; + if (need <= buf->capacity) + return true; + size_t capacity = buf->capacity > 0 ? buf->capacity : 32; + while (capacity < need) { + if (capacity > SIZE_MAX / 2) { + capacity = need; + break; + } + capacity *= 2; + } + char* grown = realloc(buf->data, capacity); + if (!grown) + return false; + buf->data = grown; + buf->capacity = capacity; + return true; +} + +static bool strbuf_append_char(StrBuf* buf, char c) { + if (!strbuf_reserve(buf, 1)) + return false; + buf->data[buf->length++] = c; + buf->data[buf->length] = '\0'; + return true; +} + +static bool strbuf_append(StrBuf* buf, const char* text) { + if (text == NULL) + return true; + size_t length = strlen(text); + if (!strbuf_reserve(buf, length)) + return false; + memcpy(buf->data + buf->length, text, length); + buf->length += length; + buf->data[buf->length] = '\0'; + return true; +} + +static bool strbuf_append_ull(StrBuf* buf, unsigned long long value) { + char digits[32]; + int written = snprintf(digits, sizeof(digits), "%llu", value); + if (written < 0 || (size_t)written >= sizeof(digits)) + return false; + return strbuf_append(buf, digits); +} + +static bool strbuf_append_longlong(StrBuf* buf, long long value) { + char digits[32]; + int written = snprintf(digits, sizeof(digits), "%lld", value); + if (written < 0 || (size_t)written >= sizeof(digits)) + return false; + return strbuf_append(buf, digits); +} + +bool change_list_enabled(const Config* config) { + return config != NULL && (config->itemize_changes || config->out_format != NULL || + (config->log_file != NULL && config->log_file_format != NULL)); +} + +char* change_render_itemize(const ChangeEvent* event) { + if (event == NULL || event->decision != CHANGE_SENT) + return str_dup(""); + const char* code = event->is_directory ? ">d+++++++++" : ITEMIZE_SENT_FILE; + StrBuf line = {0}; + bool ok = strbuf_append(&line, code) && strbuf_append(&line, " ") && + strbuf_append(&line, event->path != NULL ? event->path : ""); + if (!ok) { + strbuf_free(&line); + return NULL; + } + return line.data; +} + +static const char* leaf_name(const char* path) { + if (path == NULL) + return ""; + const char* slash = strrchr(path, '/'); + return slash != NULL && slash[1] != '\0' ? slash + 1 : path; +} + +char* change_render_format(const char* format, const ChangeEvent* event) { + if (format == NULL) + return NULL; + StrBuf line = {0}; + bool ok = true; + for (const char* p = format; *p != '\0' && ok;) { + if (*p != '%') { + ok = strbuf_append_char(&line, *p); + p++; + continue; + } + char token = p[1]; + if (token == '\0') { + ok = strbuf_append_char(&line, '%'); + break; + } + switch (token) { + case '%': + ok = strbuf_append_char(&line, '%'); + break; + case 'f': + ok = strbuf_append(&line, event->path != NULL ? event->path : ""); + break; + case 'n': + ok = strbuf_append(&line, leaf_name(event->path)); + break; + case 'l': + ok = strbuf_append_ull(&line, event->size); + break; + case 'b': + ok = strbuf_append_ull(&line, event->bytes_sent); + break; + case 'M': + ok = strbuf_append_longlong(&line, (long long)event->mtime_sec); + break; + default: + /* Unknown escape sequences are preserved verbatim. */ + ok = strbuf_append_char(&line, '%') && strbuf_append_char(&line, token); + break; + } + p += 2; + } + if (!ok) { + strbuf_free(&line); + return NULL; + } + if (line.data == NULL) { + line.data = str_dup(""); + if (!line.data) + return NULL; + } + return line.data; +} + +/* Format a mode as an `ls -l` permission string, e.g. `-rw-r--r--`. */ +static void mode_to_ls_string(mode_t mode, char out[11]) { + out[0] = S_ISDIR(mode) ? 'd' + : S_ISLNK(mode) ? 'l' + : S_ISCHR(mode) ? 'c' + : S_ISBLK(mode) ? 'b' + : S_ISFIFO(mode) ? 'p' + : S_ISSOCK(mode) ? 's' + : '-'; + mode_t bits = mode & 07777; + out[1] = (bits & S_IRUSR) ? 'r' : '-'; + out[2] = (bits & S_IWUSR) ? 'w' : '-'; + out[3] = (bits & S_IXUSR) ? (bits & S_ISUID ? 's' : 'x') : (bits & S_ISUID ? 'S' : '-'); + out[4] = (bits & S_IRGRP) ? 'r' : '-'; + out[5] = (bits & S_IWGRP) ? 'w' : '-'; + out[6] = (bits & S_IXGRP) ? (bits & S_ISGID ? 's' : 'x') : (bits & S_ISGID ? 'S' : '-'); + out[7] = (bits & S_IROTH) ? 'r' : '-'; + out[8] = (bits & S_IWOTH) ? 'w' : '-'; + out[9] = (bits & S_IXOTH) ? (bits & S_ISVTX ? 't' : 'x') : (bits & S_ISVTX ? 'T' : '-'); + out[10] = '\0'; +} + +char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime, + const char* path) { + char permission[11]; + mode_to_ls_string(mode, permission); + char date[32]; + struct tm broken_down; + if (localtime_r(&mtime, &broken_down) != NULL) { + if (strftime(date, sizeof(date), "%Y/%m/%d %H:%M:%S", &broken_down) == 0) + snprintf(date, sizeof(date), "?"); + } else { + snprintf(date, sizeof(date), "?"); + } + StrBuf line = {0}; + char size_field[32]; + int written = snprintf(size_field, sizeof(size_field), "%llu", size); + if (written < 0 || (size_t)written >= sizeof(size_field)) { + strbuf_free(&line); + return NULL; + } + bool ok = strbuf_append(&line, permission) && strbuf_append_char(&line, ' ') && + strbuf_append(&line, size_field) && strbuf_append_char(&line, ' ') && + strbuf_append(&line, date) && strbuf_append_char(&line, ' ') && + strbuf_append(&line, path != NULL ? path : ""); + if (!ok) { + strbuf_free(&line); + return NULL; + } + return line.data; +} + +static void print_escaped_line(FILE* stream, const char* line, bool eight_bit_output) { + char* escaped = output_escape(line, eight_bit_output); + if (escaped != NULL) { + fprintf(stream, "%s\n", escaped); + free(escaped); + } else { + fprintf(stream, "%s\n", line); + } + fflush(stream); +} + +void change_emit(const Config* config, const ChangeEvent* event) { + if (event == NULL || !change_list_enabled(config)) + return; + if (event->decision == CHANGE_UP_TO_DATE) + return; + bool to_stdout = config->itemize_changes || config->out_format != NULL; + bool to_log = config->log_file != NULL && config->log_file_format != NULL; + if (to_stdout) { + char* line = config->out_format != NULL ? change_render_format(config->out_format, event) + : change_render_itemize(event); + if (line != NULL) { + print_escaped_line(stdout, line, config->eight_bit_output); + free(line); + } + } + if (to_log) { + char* line = change_render_format(config->log_file_format, event); + if (line != NULL) { + print_escaped_line(config->log_file, line, config->eight_bit_output); + free(line); + } + } +} + +static bool format_uses_mtime(const char* format) { + if (format == NULL) + return false; + for (const char* p = format; *p != '\0'; p++) { + if (p[0] == '%' && p[1] == 'M') + return true; + } + return false; +} + +void change_emit_file_sent(const Config* config, const File* file) { + if (file == NULL || !change_list_enabled(config)) + return; + ChangeEvent event; + memset(&event, 0, sizeof(event)); + event.path = file->path; + event.decision = CHANGE_SENT; + event.is_directory = false; + event.size = file->data != NULL ? file->data->size : 0; + /* FastSync does not currently count post-compression/delta wire bytes, so + * the reported value is the source length that had to be delivered. */ + event.bytes_sent = event.size; + if (file->metadata != NULL) { + event.mtime_sec = file->metadata->mtime_sec; + } else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) { + struct stat st; + if (file->path != NULL && stat(file->path, &st) == 0) + event.mtime_sec = st.st_mtime; + } + change_emit(config, &event); +} diff --git a/src/client/change_list.h b/src/client/change_list.h new file mode 100644 index 0000000..b3088b8 --- /dev/null +++ b/src/client/change_list.h @@ -0,0 +1,69 @@ +#ifndef CHANGE_LIST_H +#define CHANGE_LIST_H + +#include "config.h" +#include "file_types.h" +#include +#include +#include + +/* + * Shared per-file change-event / output model (rsync --itemize-changes, + * --out-format, --log-file-format, and --list-only all render from here). + * + * FastSync is a push-style tool: the client sends files from the source tree + * to a server that writes them under the destination root. Events are + * emitted by whichever code path decides a file's fate (the single-threaded + * send loop and the `-m` sender thread both call the same per-file sender, so + * only that one thread ever reports events - no cross-thread printing races). + */ + +typedef enum { + CHANGE_SENT, /* file data (full or delta) was transmitted */ + CHANGE_UP_TO_DATE, /* receiver already had an identical file; skipped */ +} ChangeDecision; + +typedef struct { + const char* path; /* full source path */ + ChangeDecision decision; + bool is_directory; + unsigned long long size; /* source file length in bytes */ + unsigned long long bytes_sent; /* payload bytes sent (best effort) */ + time_t mtime_sec; /* 0 when unknown */ +} ChangeEvent; + +/* True when any output mode is active and per-file events matter. */ +bool change_list_enabled(const Config* config); + +/* Render the rsync-style itemize line for a transferred file: + * `>f+++++++++ ` + * The 11-char code is `>f` (regular file transferred to the remote host) + * followed by c/s/t/p/o/g/u/a/x markers that are all `+` (value will be set + * / differs) because FastSync does not separately compare checksums, size, + * mtime, perms, owner, group, uid, acl, or xattr on the receiving side, so a + * sent file is reported as fully updated. Up-to-date files print no line + * (rsync single `-i` only shows changes). Caller frees the result. */ +char* change_render_itemize(const ChangeEvent* event); + +/* Expand an --out-format/--log-file-format template. Tokens: + * %f full source path %b bytes sent (== %l for a whole file) + * %n leaf (base) name %M mtime in whole seconds since the epoch + * %l file length in bytes %% a literal percent sign + * Unknown %X sequences are preserved verbatim. Caller frees the result. */ +char* change_render_format(const char* format, const ChangeEvent* event); + +/* Render one --list-only long-listing entry: + * `-rw-r--r-- 12 2026/09/06 10:00:00 ` + * (ls -l style columns; mtime in the local time zone). Caller frees it. */ +char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime, const char* path); + +/* Emit an event to every active destination: + * stdout: --itemize-changes line, or the --out-format expansion when set; + * log file: the --log-file-format expansion (requires --log-file). + * CHANGE_UP_TO_DATE events produce no output. */ +void change_emit(const Config* config, const ChangeEvent* event); + +/* Build and emit a CHANGE_SENT event for a file the client just sent. */ +void change_emit_file_sent(const Config* config, const File* file); + +#endif diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 9260277..740bbbc 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -390,6 +390,10 @@ static const OptionEntry OPTION_TABLE[] = { {"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)}, {"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)}, {"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)}, + {"--itemize-changes", "-i", OPT_FLAG, offsetof(Config, itemize_changes)}, + {"--list-only", NULL, OPT_FLAG, offsetof(Config, list_only)}, + {"--out-format", NULL, OPT_STRING, offsetof(Config, out_format)}, + {"--log-file-format", NULL, OPT_STRING, offsetof(Config, log_file_format)}, {"--existing", NULL, OPT_FLAG, offsetof(Config, existing)}, {"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)}, {"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)}, diff --git a/src/client/client_send.c b/src/client/client_send.c index c63e024..dd0c33c 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -1,5 +1,6 @@ #include "client_send.h" #include "array_list.h" +#include "change_list.h" #include "chunk.h" #include "compression.h" #include "config.h" @@ -290,6 +291,113 @@ static int send_dry_run_manifest(const Config* config) { return 0; } +typedef struct { + char* path; + mode_t mode; + unsigned long long size; + time_t mtime; +} ListEntry; + +static void list_entries_destroy(ListEntry* entries, size_t count) { + if (entries == NULL) + return; + for (size_t i = 0; i < count; i++) + free(entries[i].path); + free(entries); +} + +static int compare_list_entries(const void* left, const void* right) { + const ListEntry* a = (const ListEntry*)left; + const ListEntry* b = (const ListEntry*)right; + return strcmp(a->path, b->path); +} + +/* --list-only: print an ls-style listing of the files that WOULD be + * transferred and exit without contacting the server or writing anything. + * Directory lines are not printed because the scanner only yields regular + * transfer candidates. Returns 0 on success, 1 on error. */ +static int send_list_only(const Config* config) { + ScannerOptions options = scanner_options_from_config(config, 0); + options.use_metadata = true; /* capture mode + mtime for the listing */ + DirectoryScanner* scanner = + directory_scanner_create_with_options(config->send_directory, &options); + if (!scanner) + return 1; + ListEntry* entries = NULL; + size_t count = 0; + size_t capacity = 0; + Chunk* chunk; + bool oom = false; + while ((chunk = directory_scanner_next(scanner)) != NULL) { + for (int i = 0; i < chunk->element_count; i++) { + File* f = chunk->items[i]; + if (f == NULL) + continue; + if (count == capacity) { + size_t new_capacity = capacity > 0 ? capacity * 2 : 64; + if (new_capacity <= capacity) { + oom = true; + break; + } + ListEntry* grown = realloc(entries, new_capacity * sizeof(ListEntry)); + if (!grown) { + oom = true; + break; + } + entries = grown; + capacity = new_capacity; + } + char* path = str_dup(f->path); + if (!path) { + oom = true; + break; + } + mode_t mode = 0; + time_t mtime = 0; + if (f->metadata != NULL) { + mode = f->metadata->mode; + mtime = f->metadata->mtime_sec; + } else { + struct stat st; + if (stat(f->path, &st) == 0) { + mode = st.st_mode; + mtime = st.st_mtime; + } + } + entries[count].path = path; + entries[count].mode = mode; + entries[count].mtime = mtime; + entries[count].size = f->data != NULL ? f->data->size : 0; + count++; + } + chunk_destroy(chunk); + if (oom) + break; + } + bool failed = oom || directory_scanner_failed(scanner); + directory_scanner_destroy(scanner); + if (failed) { + list_entries_destroy(entries, count); + if (oom) + log_message(LOG_LEVEL_ERROR, "memory allocation failed while listing"); + return 1; + } + if (count > 1) + qsort(entries, count, sizeof(ListEntry), compare_list_entries); + for (size_t i = 0; i < count; i++) { + char* line = change_render_list_line(entries[i].mode, entries[i].size, entries[i].mtime, + entries[i].path); + if (line != NULL) { + char* escaped = output_escape(line, config->eight_bit_output); + printf("%s\n", escaped != NULL ? escaped : line); + free(escaped); + free(line); + } + } + list_entries_destroy(entries, count); + return 0; +} + /* Send the delete manifest (list of files) to the server. Returns 0 on success, -1 on failure. */ static int send_delete_manifest(int fd, ArrayList* manifest) { if (!manifest) @@ -531,6 +639,10 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config, return -1; } data_destroy(data); + for (int i = 0; i < chunk->element_count; i++) { + if (chunk->items[i] != NULL) + change_emit_file_sent(config, chunk->items[i]); + } return 0; } @@ -551,6 +663,7 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config, source_file_destroy(source); return -1; } + change_emit_file_sent(config, f); if (source && !array_list_add(remove_sources, source)) { source_file_destroy(source); return -1; @@ -804,6 +917,8 @@ static int progress_thread_fn(void* arg) { } int send_files(Config* config) { + if (config->list_only) + return send_list_only(config); if (config->dry_run) return send_dry_run_manifest(config); @@ -935,6 +1050,8 @@ int send_files_multithreaded(Config** config_ptr) { if (!config_ptr || !*config_ptr) return 1; Config* config = *config_ptr; + if (config->list_only) + return send_list_only(config); if (config->dry_run) return send_dry_run_manifest(config); diff --git a/src/client/client_validation.c b/src/client/client_validation.c index 432220c..ad9accb 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -44,6 +44,10 @@ bool validate_config(const Config* config) { log_message(LOG_LEVEL_ERROR, "--delta cannot be combined with -f (sendfile)"); return false; } + if (config->log_file_format && !config->log_file) { + log_message(LOG_LEVEL_ERROR, "--log-file-format requires --log-file"); + return false; + } if (config->append || config->append_verify) { fprintf( stderr, diff --git a/src/client/usage.c b/src/client/usage.c index 3c49e2b..7b6c426 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -81,6 +81,10 @@ 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(" -i, --itemize-changes Print an rsync-style per-file change line\n"); + printf(" --out-format=FORMAT Output format for changed files (%%f %%n %%l %%b %%M %%%%)\n"); + printf(" --list-only List source files instead of transferring\n"); + printf(" --log-file-format=FORMAT Per-file log line format (needs --log-file)\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"); diff --git a/src/shared/config.c b/src/shared/config.c index 50c6f05..1b5c5ee 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -74,6 +74,7 @@ static void config_set_defaults(Config* config) { config->preserve_sparse = false; config->itemize_changes = false; config->out_format = NULL; + config->log_file_format = NULL; config->info_level = 0; config->debug_level = 0; config->list_only = false; @@ -223,6 +224,7 @@ void config_delete(Config* config) { free(config->backup_dir); free(config->server_host); free(config->out_format); + free(config->log_file_format); free(config->files_from); free(config->rsh_command); free(config->rsync_path); diff --git a/src/shared/config.h b/src/shared/config.h index de7fa4e..f89cbe0 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -79,6 +79,7 @@ typedef struct Config { // Issue #122: Output/logging options bool itemize_changes; char* out_format; + char* log_file_format; int info_level; int debug_level; bool list_only; diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 4c8c865..bfb51b7 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -1021,3 +1021,117 @@ class TestLargeFile: assert result.returncode == 0, f"Large-file sync failed: {result.stderr[:200]}" received = get_dest_received_dir(dest, source) assert filecmp.cmp(source_file, os.path.join(received, "big.bin"), shallow=False) + + +def _source_files(): + """All source paths (absolute) that a transfer would send right now.""" + return [ + os.path.join(root, name) + for root, _dirs, names in os.walk(SOURCE_DIR) + for name in names + ] + + +class TestListOnly: + """--list-only prints every transfer candidate and changes nothing.""" + + def test_list_only_prints_each_file_and_does_not_transfer(self): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only"]) + assert result.returncode == 0, f"list-only failed: {result.stderr[:200]}" + for full_path in _source_files(): + assert full_path in result.stdout, f"list-only omitted {full_path}" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + assert not os.path.exists(received), "list-only wrote to the destination" + + def test_list_only_with_dry_run_does_not_error(self): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "--dry-run"]) + assert result.returncode == 0, f"list-only -n failed: {result.stderr[:200]}" + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + assert not os.path.exists(received) + + +class TestItemizeChanges: + """-i/--itemize-changes prints rsync-style lines only for files sent.""" + + def test_first_run_prints_sent_lines(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["-M", "-i"], port=shared_server.port) + assert result.returncode == 0, f"itemize sync failed: {result.stderr[:200]}" + sent_lines = {">f+++++++++ " + p for p in _source_files()} + assert sent_lines <= set(result.stdout.splitlines()), ( + f"missing itemize lines; got {result.stdout[:500]}" + ) + + def test_incremental_second_run_prints_no_line_for_unchanged(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) + assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["-M", "-i", "--incremental"], + port=shared_server.port) + assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}" + itemized = [line for line in result.stdout.splitlines() if line and line[0] in ">.f+++++++++ " + p for p in _source_files()} + assert sent_lines <= set(result.stdout.splitlines()), ( + f"missing itemize lines in -m mode; got {result.stdout[:500]}" + ) + + def test_dry_run_with_itemize_does_not_error(self): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-i", "--dry-run"]) + assert result.returncode == 0, f"dry-run -i failed: {result.stderr[:200]}" + + +class TestOutFormat: + """--out-format prints a line per transferred file using the template.""" + + def test_out_format_path_and_size(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["--out-format=%f %l"], port=shared_server.port) + assert result.returncode == 0, f"out-format sync failed: {result.stderr[:200]}" + expected = {f"{p} {os.path.getsize(p)}" for p in _source_files()} + got = set(result.stdout.splitlines()) + assert expected <= got, f"out-format lines missing: expected {len(expected)} got {len(got)}" + + def test_out_format_multithreaded_matches_single(self, shared_server): + clean_dir(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, + flags=["--out-format=%f %l", "-m"], port=shared_server.port) + assert result.returncode == 0, f"out-format -m sync failed: {result.stderr[:200]}" + expected = {f"{p} {os.path.getsize(p)}" for p in _source_files()} + got = set(result.stdout.splitlines()) + assert expected <= got, f"out-format -m lines missing: {result.stdout[:500]}" + + +class TestLogFileFormat: + """--log-file plus --log-file-format writes per-file lines to the log.""" + + def test_log_file_format_writes_transferred_files(self, shared_server): + clean_dir(DEST_DIR) + log_path = os.path.join(TEST_DATA_DIR, "itemize_transfer.log") + if os.path.exists(log_path): + os.unlink(log_path) + result, _ = run_client( + SOURCE_DIR, DEST_DIR, + flags=["--log-file", log_path, "--log-file-format=%f %l"], + port=shared_server.port, + ) + assert result.returncode == 0, f"log-file sync failed: {result.stderr[:200]}" + assert os.path.exists(log_path), "--log-file created no log" + with open(log_path, encoding="utf-8", errors="replace") as fh: + content = fh.read() + expected = {f"{p} {os.path.getsize(p)}" for p in _source_files()} + for line in expected: + assert line in content, f"log file missing {line!r}" + diff --git a/tests/runner.c b/tests/runner.c index ad30e09..834e9ab 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -1,5 +1,6 @@ #include "test_array_list.h" #include "test_chunk.h" +#include "test_change_list.h" #include "test_client_cli.h" #include "test_compression.h" #include "test_config.h" @@ -40,6 +41,7 @@ int main() { RUN_TEST(test_array_list); RUN_TEST(test_shared_utils); RUN_TEST(test_chunk); + RUN_TEST(test_change_list); RUN_TEST(test_config); RUN_TEST(test_compression); RUN_TEST(test_scanner); diff --git a/tests/test_change_list.c b/tests/test_change_list.c new file mode 100644 index 0000000..a5fe016 --- /dev/null +++ b/tests/test_change_list.c @@ -0,0 +1,101 @@ +#include "test_change_list.h" +#include "change_list.h" +#include "test_utils.h" +#include "utils.h" +#include +#include +#include + +static ChangeEvent sample_event(void) { + ChangeEvent event; + memset(&event, 0, sizeof(event)); + event.path = "/srv/root/sub/file.txt"; + event.decision = CHANGE_SENT; + event.is_directory = false; + event.size = 12345; + event.bytes_sent = 999; + event.mtime_sec = 1700000000; + return event; +} + +static void test_format_tokens() { + ChangeEvent event = sample_event(); + char* line = change_render_format("%f %n %l %b %M %%", &event); + EXPECT_NOT_NULL(line); + EXPECT_EQ_STR(line, "/srv/root/sub/file.txt file.txt 12345 999 1700000000 %"); + free(line); +} + +static void test_format_unknown_tokens_preserved() { + ChangeEvent event = sample_event(); + char* line = change_render_format("x%q=%f%z", &event); + EXPECT_NOT_NULL(line); + EXPECT_EQ_STR(line, "x%q=/srv/root/sub/file.txt%z"); + free(line); +} + +static void test_format_leaf_name() { + ChangeEvent event = sample_event(); + event.path = "bare.txt"; + char* line = change_render_format("%n|%f", &event); + EXPECT_NOT_NULL(line); + EXPECT_EQ_STR(line, "bare.txt|bare.txt"); + free(line); +} + +static void test_render_itemize_sent_file() { + ChangeEvent event = sample_event(); + char* line = change_render_itemize(&event); + EXPECT_NOT_NULL(line); + EXPECT_EQ_STR(line, ">f+++++++++ /srv/root/sub/file.txt"); + free(line); +} + +static void test_render_itemize_up_to_date_is_empty() { + ChangeEvent event = sample_event(); + event.decision = CHANGE_UP_TO_DATE; + char* line = change_render_itemize(&event); + EXPECT_NOT_NULL(line); + EXPECT_EQ_STR(line, ""); + free(line); +} + +static void test_render_list_line() { + char* line = change_render_list_line(0100644, 4096, 1700000000, "/srv/x.txt"); + EXPECT_NOT_NULL(line); + EXPECT_TRUE(strncmp(line, "-rw-r--r--", 10) == 0); + EXPECT_TRUE(strstr(line, "4096") != NULL); + EXPECT_TRUE(strstr(line, "/srv/x.txt") != NULL); + free(line); +} + +static void test_change_list_enabled() { + Config* config = config_create(); + EXPECT_NOT_NULL(config); + EXPECT_FALSE(change_list_enabled(config)); + config->itemize_changes = true; + EXPECT_TRUE(change_list_enabled(config)); + config->itemize_changes = false; + config->out_format = str_dup("%f"); + EXPECT_TRUE(change_list_enabled(config)); + free(config->out_format); + config->out_format = NULL; + EXPECT_FALSE(change_list_enabled(config)); + /* config_delete() closes log_file, so use a throwaway tmpfile. */ + config->log_file = tmpfile(); + EXPECT_NOT_NULL(config->log_file); + EXPECT_FALSE(change_list_enabled(config)); /* needs a format too */ + config->log_file_format = str_dup("%n"); + EXPECT_TRUE(change_list_enabled(config)); + config_delete(config); /* closes config->log_file */ +} + +void test_change_list() { + test_format_tokens(); + test_format_unknown_tokens_preserved(); + test_format_leaf_name(); + test_render_itemize_sent_file(); + test_render_itemize_up_to_date_is_empty(); + test_render_list_line(); + test_change_list_enabled(); +} diff --git a/tests/test_change_list.h b/tests/test_change_list.h new file mode 100644 index 0000000..8887e43 --- /dev/null +++ b/tests/test_change_list.h @@ -0,0 +1,6 @@ +#ifndef TEST_CHANGE_LIST_H +#define TEST_CHANGE_LIST_H + +void test_change_list(void); + +#endif diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index b53a161..afa396c 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -602,10 +602,6 @@ static void test_parse_args_rejects_unimplemented_options() { "--xattrs", "-D", "--devices", - "-i", - "--itemize-changes", - "--out-format", - "--list-only", "--append", "--append-verify", "--delete-excluded", @@ -1126,7 +1122,8 @@ static void test_parse_args_table_equals_string_and_int_options() { /* Options that take a separate value must report "missing argument", not the * generic "Unknown option", when they are the final argv entry. */ static void test_parse_args_missing_argument_diagnostic() { - static const char* const options[] = {"--exclude", "--server-port", "--skip-compress", "-T"}; + static const char* const options[] = {"--exclude", "--server-port", "--skip-compress", + "-T", "--out-format", "--log-file-format"}; for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) { Config* cfg = config_create(); @@ -1152,6 +1149,66 @@ static void test_parse_args_missing_argument_diagnostic() { } } +static void test_parse_args_itemize_changes() { + static const char* const flags[] = {"-i", "--itemize-changes"}; + for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { + Config* cfg = config_create(); + char* argv[] = {"fastsync", (char*)flags[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->itemize_changes); + EXPECT_EQ_INT(positional_count, 2); + config_delete(cfg); + } +} + +static void test_parse_args_list_only() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--list-only", "/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->list_only); + config_delete(cfg); +} + +static void test_parse_args_out_format() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--out-format=%f %l", "/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_STR(cfg->out_format, "%f %l"); + config_delete(cfg); + + cfg = config_create(); + char* separate_argv[] = {"fastsync", "--out-format", "%f %l", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, separate_argv, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->out_format, "%f %l"); + config_delete(cfg); +} + +static void test_parse_args_log_file_format() { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--log-file-format=%n %M", "/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_STR(cfg->log_file_format, "%n %M"); + config_delete(cfg); + + cfg = config_create(); + char* separate_argv[] = {"fastsync", "--log-file-format", "%n %M", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, separate_argv, positional_args, &positional_count), 0); + EXPECT_EQ_STR(cfg->log_file_format, "%n %M"); + config_delete(cfg); +} + void test_client_cli() { test_validate_config_required_paths(); test_validate_config_incompatible_options(); @@ -1223,6 +1280,10 @@ void test_client_cli() { test_parse_args_table_equals_string_and_int_options(); test_parse_args_missing_argument_diagnostic(); test_parse_args_partial_progress(); + test_parse_args_itemize_changes(); + test_parse_args_list_only(); + test_parse_args_out_format(); + test_parse_args_log_file_format(); test_parse_args_checksum_choice_aliases(); test_parse_args_checksum_choice_requires_value(); }