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:
2026-09-06 12:34:10 +02:00
parent 06e83f2402
commit 7f5547e900
15 changed files with 793 additions and 12 deletions
+66 -5
View File
@@ -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();
}