feat: add structured per-file change output model (-i, --list-only, --out-format, --log-file-format)
Implement rsync-style itemized output backed by one shared change-event engine (src/client/change_list.c): - -i/--itemize-changes prints ">f+++++++++ <path>" for files actually sent (single-threaded and -m); unchanged files print nothing. - --list-only prints an ls-style listing of files that would be transferred without contacting the server or writing anything. - --out-format=FORMAT prints a printf-style template per changed file (tokens %%f %%n %%l %%b %%M %%%%; unknown escapes preserved). - --log-file-format=FMT logs each transferred file when --log-file is set. Events are emitted from the per-file sender path shared by both transfer modes, so the single sender thread is the only reporter (no races).
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ set(TEST_INCLUDES tests src/shared src/server src/client)
|
|||||||
|
|
||||||
# Monolithic test binary (backward compatible)
|
# Monolithic test binary (backward compatible)
|
||||||
file(GLOB TEST_SRCS "tests/test_*.c" "tests/runner.c")
|
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_include_directories(tests PRIVATE ${TEST_INCLUDES})
|
||||||
target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD)
|
target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD)
|
||||||
target_link_libraries(tests PRIVATE ${TEST_LIBS})
|
target_link_libraries(tests PRIVATE ${TEST_LIBS})
|
||||||
|
|||||||
+6
-6
@@ -6,11 +6,11 @@ This document maps rsync's full feature set to FastSync's current implementation
|
|||||||
|
|
||||||
| Status | Count | Description |
|
| 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 |
|
| 🔀 Alt Arg | 3 | Functionality exists but under different flag/semantics |
|
||||||
| ⚠️ Partial | 5 | Flag parsed/stored but behavior incomplete |
|
| ⚠️ Partial | 5 | Flag parsed/stored but behavior incomplete |
|
||||||
| 🔄 Compatibility No-op | 1 | Flag is accepted for CLI compatibility but has no effect |
|
| 🔄 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** | |
|
| **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 |
|
| `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts |
|
||||||
| `-h`, `--human-readable` | Human-readable numbers | ✅ Implemented | Formats transfer byte sizes using binary units |
|
| `-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 |
|
| `--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 |
|
| `-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=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 |
|
| `--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
|
## 3. File Selection
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#include "change_list.h"
|
||||||
|
#include "utils.h"
|
||||||
|
#include <limits.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#ifndef CHANGE_LIST_H
|
||||||
|
#define CHANGE_LIST_H
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "file_types.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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+++++++++ <path>`
|
||||||
|
* 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 <path>`
|
||||||
|
* (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
|
||||||
@@ -390,6 +390,10 @@ static const OptionEntry OPTION_TABLE[] = {
|
|||||||
{"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)},
|
{"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)},
|
||||||
{"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)},
|
{"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)},
|
||||||
{"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)},
|
{"--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)},
|
{"--existing", NULL, OPT_FLAG, offsetof(Config, existing)},
|
||||||
{"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)},
|
{"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)},
|
||||||
{"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)},
|
{"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "client_send.h"
|
#include "client_send.h"
|
||||||
#include "array_list.h"
|
#include "array_list.h"
|
||||||
|
#include "change_list.h"
|
||||||
#include "chunk.h"
|
#include "chunk.h"
|
||||||
#include "compression.h"
|
#include "compression.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
@@ -290,6 +291,113 @@ static int send_dry_run_manifest(const Config* config) {
|
|||||||
return 0;
|
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. */
|
/* 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) {
|
static int send_delete_manifest(int fd, ArrayList* manifest) {
|
||||||
if (!manifest)
|
if (!manifest)
|
||||||
@@ -531,6 +639,10 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
data_destroy(data);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +663,7 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
|
|||||||
source_file_destroy(source);
|
source_file_destroy(source);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
change_emit_file_sent(config, f);
|
||||||
if (source && !array_list_add(remove_sources, source)) {
|
if (source && !array_list_add(remove_sources, source)) {
|
||||||
source_file_destroy(source);
|
source_file_destroy(source);
|
||||||
return -1;
|
return -1;
|
||||||
@@ -804,6 +917,8 @@ static int progress_thread_fn(void* arg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int send_files(Config* config) {
|
int send_files(Config* config) {
|
||||||
|
if (config->list_only)
|
||||||
|
return send_list_only(config);
|
||||||
if (config->dry_run)
|
if (config->dry_run)
|
||||||
return send_dry_run_manifest(config);
|
return send_dry_run_manifest(config);
|
||||||
|
|
||||||
@@ -935,6 +1050,8 @@ int send_files_multithreaded(Config** config_ptr) {
|
|||||||
if (!config_ptr || !*config_ptr)
|
if (!config_ptr || !*config_ptr)
|
||||||
return 1;
|
return 1;
|
||||||
Config* config = *config_ptr;
|
Config* config = *config_ptr;
|
||||||
|
if (config->list_only)
|
||||||
|
return send_list_only(config);
|
||||||
if (config->dry_run)
|
if (config->dry_run)
|
||||||
return send_dry_run_manifest(config);
|
return send_dry_run_manifest(config);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ bool validate_config(const Config* config) {
|
|||||||
log_message(LOG_LEVEL_ERROR, "--delta cannot be combined with -f (sendfile)");
|
log_message(LOG_LEVEL_ERROR, "--delta cannot be combined with -f (sendfile)");
|
||||||
return false;
|
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) {
|
if (config->append || config->append_verify) {
|
||||||
fprintf(
|
fprintf(
|
||||||
stderr,
|
stderr,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ 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(" -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(" -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");
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->preserve_sparse = false;
|
config->preserve_sparse = false;
|
||||||
config->itemize_changes = false;
|
config->itemize_changes = false;
|
||||||
config->out_format = NULL;
|
config->out_format = NULL;
|
||||||
|
config->log_file_format = NULL;
|
||||||
config->info_level = 0;
|
config->info_level = 0;
|
||||||
config->debug_level = 0;
|
config->debug_level = 0;
|
||||||
config->list_only = false;
|
config->list_only = false;
|
||||||
@@ -223,6 +224,7 @@ void config_delete(Config* config) {
|
|||||||
free(config->backup_dir);
|
free(config->backup_dir);
|
||||||
free(config->server_host);
|
free(config->server_host);
|
||||||
free(config->out_format);
|
free(config->out_format);
|
||||||
|
free(config->log_file_format);
|
||||||
free(config->files_from);
|
free(config->files_from);
|
||||||
free(config->rsh_command);
|
free(config->rsh_command);
|
||||||
free(config->rsync_path);
|
free(config->rsync_path);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ typedef struct Config {
|
|||||||
// Issue #122: Output/logging options
|
// Issue #122: Output/logging options
|
||||||
bool itemize_changes;
|
bool itemize_changes;
|
||||||
char* out_format;
|
char* out_format;
|
||||||
|
char* log_file_format;
|
||||||
int info_level;
|
int info_level;
|
||||||
int debug_level;
|
int debug_level;
|
||||||
bool list_only;
|
bool list_only;
|
||||||
|
|||||||
@@ -1021,3 +1021,117 @@ class TestLargeFile:
|
|||||||
assert result.returncode == 0, f"Large-file sync failed: {result.stderr[:200]}"
|
assert result.returncode == 0, f"Large-file sync failed: {result.stderr[:200]}"
|
||||||
received = get_dest_received_dir(dest, source)
|
received = get_dest_received_dir(dest, source)
|
||||||
assert filecmp.cmp(source_file, os.path.join(received, "big.bin"), shallow=False)
|
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 ">.<c"]
|
||||||
|
assert itemized == [], f"unchanged files were itemized: {itemized[:5]}"
|
||||||
|
|
||||||
|
def test_multithreaded_emits_same_itemize_lines(self, shared_server):
|
||||||
|
clean_dir(DEST_DIR)
|
||||||
|
result, _ = run_client(SOURCE_DIR, DEST_DIR,
|
||||||
|
flags=["-M", "-i", "-m"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"itemize -m 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 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}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "test_array_list.h"
|
#include "test_array_list.h"
|
||||||
#include "test_chunk.h"
|
#include "test_chunk.h"
|
||||||
|
#include "test_change_list.h"
|
||||||
#include "test_client_cli.h"
|
#include "test_client_cli.h"
|
||||||
#include "test_compression.h"
|
#include "test_compression.h"
|
||||||
#include "test_config.h"
|
#include "test_config.h"
|
||||||
@@ -40,6 +41,7 @@ int main() {
|
|||||||
RUN_TEST(test_array_list);
|
RUN_TEST(test_array_list);
|
||||||
RUN_TEST(test_shared_utils);
|
RUN_TEST(test_shared_utils);
|
||||||
RUN_TEST(test_chunk);
|
RUN_TEST(test_chunk);
|
||||||
|
RUN_TEST(test_change_list);
|
||||||
RUN_TEST(test_config);
|
RUN_TEST(test_config);
|
||||||
RUN_TEST(test_compression);
|
RUN_TEST(test_compression);
|
||||||
RUN_TEST(test_scanner);
|
RUN_TEST(test_scanner);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#include "test_change_list.h"
|
||||||
|
#include "change_list.h"
|
||||||
|
#include "test_utils.h"
|
||||||
|
#include "utils.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef TEST_CHANGE_LIST_H
|
||||||
|
#define TEST_CHANGE_LIST_H
|
||||||
|
|
||||||
|
void test_change_list(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
+66
-5
@@ -602,10 +602,6 @@ static void test_parse_args_rejects_unimplemented_options() {
|
|||||||
"--xattrs",
|
"--xattrs",
|
||||||
"-D",
|
"-D",
|
||||||
"--devices",
|
"--devices",
|
||||||
"-i",
|
|
||||||
"--itemize-changes",
|
|
||||||
"--out-format",
|
|
||||||
"--list-only",
|
|
||||||
"--append",
|
"--append",
|
||||||
"--append-verify",
|
"--append-verify",
|
||||||
"--delete-excluded",
|
"--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
|
/* Options that take a separate value must report "missing argument", not the
|
||||||
* generic "Unknown option", when they are the final argv entry. */
|
* generic "Unknown option", when they are the final argv entry. */
|
||||||
static void test_parse_args_missing_argument_diagnostic() {
|
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++) {
|
for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) {
|
||||||
Config* cfg = config_create();
|
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() {
|
void test_client_cli() {
|
||||||
test_validate_config_required_paths();
|
test_validate_config_required_paths();
|
||||||
test_validate_config_incompatible_options();
|
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_table_equals_string_and_int_options();
|
||||||
test_parse_args_missing_argument_diagnostic();
|
test_parse_args_missing_argument_diagnostic();
|
||||||
test_parse_args_partial_progress();
|
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_aliases();
|
||||||
test_parse_args_checksum_choice_requires_value();
|
test_parse_args_checksum_choice_requires_value();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user