exec: centralize usage validation in catalog

This commit is contained in:
m1ngsama 2026-05-24 14:33:48 +08:00
parent 1391ddca07
commit 0aaba8e1f9
8 changed files with 142 additions and 47 deletions

View file

@ -21,6 +21,8 @@
reducing duplicate command knowledge in `src/exec.c`.
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
public documentation does not imply a specific production host.
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
`src/exec.c` no longer duplicates `--json` and required-message validation.
- Renamed the internal language state from help-oriented names to
UI-language names (`ui_lang_t`, `client->ui_lang`, and
`i18n_*_ui_lang`) so future i18n work has a correctly named seam.

View file

@ -15,7 +15,10 @@ typedef enum {
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
const char **args);
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args);
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang);
#endif /* EXEC_CATALOG_H */

View file

@ -53,10 +53,6 @@ typedef enum {
I18N_UNKNOWN_COMMAND_FORMAT,
I18N_DID_YOU_MEAN_FORMAT,
I18N_UNKNOWN_GUIDANCE,
I18N_EXEC_USERS_USAGE,
I18N_EXEC_STATS_USAGE,
I18N_EXEC_TAIL_USAGE,
I18N_EXEC_POST_USAGE,
I18N_EXEC_POST_EMPTY,
I18N_EXEC_POST_INVALID_UTF8,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,

View file

@ -126,6 +126,17 @@ static int exec_command_help(client_t *client) {
return client_send(client, help_text, pos) == 0 ? 0 : 1;
}
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
char usage[128];
size_t pos = 0;
usage[0] = '\0';
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
client->ui_lang);
client_printf(client, "%s", usage);
return 64;
}
static int exec_command_health(client_t *client) {
static const char ok[] = "ok\n";
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
@ -289,9 +300,7 @@ static int exec_command_tail(client_t *client, const char *args) {
int rc;
if (parse_tail_count(args, &requested) < 0) {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_TAIL_USAGE));
return 64;
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
}
pthread_rwlock_rdlock(&g_room->lock);
@ -343,9 +352,7 @@ static int exec_command_post(client_t *client, const char *args) {
};
if (!args || args[0] == '\0') {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_USAGE));
return 64;
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
}
strncpy(content, args, sizeof(content) - 1);
@ -409,26 +416,18 @@ int exec_dispatch(client_t *client) {
}
if (exec_catalog_match(command_copy, &command_id, &args)) {
if (!exec_catalog_args_valid(command_id, args)) {
return exec_command_usage(client, command_id);
}
switch (command_id) {
case TNT_EXEC_COMMAND_HELP:
return exec_command_help(client);
case TNT_EXEC_COMMAND_HEALTH:
return exec_command_health(client);
case TNT_EXEC_COMMAND_USERS:
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_USERS_USAGE));
return 64;
}
return exec_command_users(client, args != NULL);
case TNT_EXEC_COMMAND_STATS:
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_STATS_USAGE));
return 64;
}
return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);

View file

@ -5,31 +5,52 @@ typedef struct {
const char *name;
const char *alias;
const char *usage;
const char *usage_syntax;
const char *summary_en;
const char *summary_zh;
bool no_args;
bool optional_json;
bool requires_args;
} exec_catalog_entry_t;
static const exec_catalog_entry_t entries[] = {
{TNT_EXEC_COMMAND_HELP, "help", "--help",
"help", "Show this help", "显示此帮助"},
"help", "help", "Show this help", "显示此帮助", true, false, false},
{TNT_EXEC_COMMAND_HEALTH, "health", NULL,
"health", "Print service health", "输出服务健康状态"},
"health", "health", "Print service health", "输出服务健康状态",
true, false, false},
{TNT_EXEC_COMMAND_USERS, "users", NULL,
"users [--json]", "List online users", "列出在线用户"},
"users [--json]", "users [--json]",
"List online users", "列出在线用户", false, true, false},
{TNT_EXEC_COMMAND_STATS, "stats", NULL,
"stats [--json]", "Print room statistics", "输出房间统计"},
"stats [--json]", "stats [--json]",
"Print room statistics", "输出房间统计", false, true, false},
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
"tail [N]", "Print recent messages", "输出最近消息"},
"tail [N]", "tail [N] | tail -n N",
"Print recent messages", "输出最近消息", false, false, false},
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
"tail -n N", "Print recent messages", "输出最近消息"},
"tail -n N", "tail [N] | tail -n N",
"Print recent messages", "输出最近消息", false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "Post a message non-interactively", "非交互发送消息"},
"post MESSAGE", "post MESSAGE",
"Post a message non-interactively", "非交互发送消息",
false, false, true},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post \"/me act\"", "Post an action message", "发送动作消息"},
"post \"/me act\"", "post MESSAGE",
"Post an action message", "发送动作消息", false, false, true},
{TNT_EXEC_COMMAND_EXIT, "exit", NULL,
"exit", "Exit successfully", "成功退出"}
"exit", "exit", "Exit successfully", "成功退出", true, false, false}
};
static const exec_catalog_entry_t *entry_for_id(tnt_exec_command_id_t id) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
if (entries[i].id == id) {
return &entries[i];
}
}
return NULL;
}
static const char *skip_spaces(const char *value) {
while (value && *value && (*value == ' ' || *value == '\t')) {
value++;
@ -84,6 +105,24 @@ bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
return false;
}
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args) {
const exec_catalog_entry_t *entry = entry_for_id(id);
if (!entry) {
return false;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}
if (entry->optional_json) {
return !args || strcmp(args, "--json") == 0;
}
if (entry->requires_args) {
return args && args[0] != '\0';
}
return true;
}
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
if (lang == UI_LANG_ZH) {
@ -99,3 +138,19 @@ void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
entries[i].usage, summary);
}
}
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang) {
const exec_catalog_entry_t *entry = entry_for_id(id);
if (!entry) {
return;
}
if (lang == UI_LANG_ZH) {
buffer_appendf(buffer, buf_size, pos, "%s: 用法: %s\n",
entry->name, entry->usage_syntax);
return;
}
buffer_appendf(buffer, buf_size, pos, "%s: usage: %s\n",
entry->name, entry->usage_syntax);
}

View file

@ -208,22 +208,6 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = {
"Type :help for help\n",
"输入 :help 查看帮助\n"
},
[I18N_EXEC_USERS_USAGE] = {
"users: usage: users [--json]\n",
"users: 用法: users [--json]\n"
},
[I18N_EXEC_STATS_USAGE] = {
"stats: usage: stats [--json]\n",
"stats: 用法: stats [--json]\n"
},
[I18N_EXEC_TAIL_USAGE] = {
"tail: usage: tail [N] | tail -n N\n",
"tail: 用法: tail [N] | tail -n N\n"
},
[I18N_EXEC_POST_USAGE] = {
"post: usage: post MESSAGE\n",
"post: 用法: post MESSAGE\n"
},
[I18N_EXEC_POST_EMPTY] = {
"post: message cannot be empty\n",
"post: 消息不能为空\n"

View file

@ -51,6 +51,17 @@ else
FAIL=$((FAIL + 1))
fi
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
if [ $? -eq 0 ]; then
echo "✓ no-arg exec usage follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ no-arg exec usage output unexpected"
printf '%s\n' "$HEALTH_USAGE"
FAIL=$((FAIL + 1))
fi
STATS_OUTPUT=$(ssh $SSH_OPTS localhost stats 2>/dev/null || true)
printf '%s\n' "$STATS_OUTPUT" | grep -q '^status ok$' &&
printf '%s\n' "$STATS_OUTPUT" | grep -q '^online_users 0$'
@ -109,6 +120,17 @@ else
FAIL=$((FAIL + 1))
fi
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ]; then
echo "✓ users usage follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ users usage output unexpected"
printf '%s\n' "$USERS_USAGE"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message"

View file

@ -71,11 +71,45 @@ TEST(matches_exec_commands_and_args) {
assert(!exec_catalog_match("nope", &id, &args));
}
TEST(validates_argument_shapes) {
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, NULL));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, "now"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, NULL));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, "now"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--json"));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--xml"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20"));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
}
TEST(generates_localized_usage) {
char en[256] = {0};
char zh[256] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_TAIL, UI_LANG_EN);
exec_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_EXEC_COMMAND_POST, UI_LANG_ZH);
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
assert(strcmp(zh, "post: 用法: post MESSAGE\n") == 0);
}
int main(void) {
printf("Running exec catalog unit tests...\n\n");
RUN_TEST(generates_localized_exec_help);
RUN_TEST(matches_exec_commands_and_args);
RUN_TEST(validates_argument_shapes);
RUN_TEST(generates_localized_usage);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;