commands: centralize usage validation in catalog

This commit is contained in:
m1ngsama 2026-05-24 15:00:41 +08:00
parent 0aaba8e1f9
commit f2942e9c9e
12 changed files with 176 additions and 79 deletions

View file

@ -250,9 +250,9 @@ TNT/
├── src/ # source code
│ ├── main.c # entry point
│ ├── cli_text.c # startup CLI help and option text
│ ├── command_catalog.c # command metadata
│ ├── command_catalog.c # command metadata, usage, and argument shape
│ ├── commands.c # COMMAND-mode command dispatch
│ ├── exec_catalog.c # SSH exec command matching and metadata
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
│ ├── exec.c # SSH exec command dispatch
│ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap

View file

@ -23,6 +23,9 @@
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.
- Moved interactive command usage text and first-pass argument-shape checks
into `command_catalog`, so known commands with bad arguments now show usage
instead of unknown-command guidance.
- 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

@ -37,9 +37,9 @@ make check
```
main.c → entry point, signal handling
cli_text.c → startup CLI text
command_catalog.c → COMMAND-mode command metadata
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching and help metadata
exec_catalog.c → SSH exec command matching, usage, and argument shape
exec.c → SSH exec command dispatch
ssh_server.c → SSH listener setup
bootstrap.c → SSH authentication/session bootstrap
@ -83,7 +83,8 @@ utf8.c → UTF-8 string handling
## Adding Features
1. Add interactive command metadata in `src/command_catalog.c`.
1. Add interactive command metadata, usage text, and argument shape in
`src/command_catalog.c`.
2. Add interactive command behavior in `src/commands.c`.
3. Add SSH exec metadata in `src/exec_catalog.c` and dispatch in `src/exec.c`
only when the feature should be scriptable.

View file

@ -46,12 +46,12 @@ INSERT MODE
STRUCTURE
src/main.c entry, signals
src/cli_text.c startup CLI text
src/command_catalog.c command metadata
src/command_catalog.c command metadata, usage, argument shape
src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap
src/chat_room.c broadcast and room state
src/commands.c COMMAND-mode command dispatch
src/exec_catalog.c SSH exec command matching and metadata
src/exec_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch
src/message.c persistence, search
src/history_view.c message viewport / scroll state

View file

@ -22,16 +22,18 @@ typedef struct {
tnt_command_id_t id;
const char *canonical;
const char *names[4];
bool accepts_args;
} tnt_command_spec_t;
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id);
bool command_catalog_match(const char *line, tnt_command_id_t *id,
const char **args);
bool command_catalog_args_valid(tnt_command_id_t id, const char *args);
const char *command_catalog_suggest(const char *name);
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang);
#endif /* COMMAND_CATALOG_H */

View file

@ -29,19 +29,15 @@ typedef enum {
I18N_SYSTEM_LEAVE_FORMAT,
I18N_SYSTEM_NICK_FORMAT,
I18N_USERS_TITLE,
I18N_MSG_USAGE,
I18N_MSG_SENT_FORMAT,
I18N_MSG_USER_NOT_FOUND_FORMAT,
I18N_INBOX_TITLE,
I18N_INBOX_EMPTY,
I18N_NICK_USAGE,
I18N_NICK_INVALID,
I18N_NICK_TAKEN_FORMAT,
I18N_NICK_UNCHANGED,
I18N_NICK_CHANGED_FORMAT,
I18N_LAST_USAGE,
I18N_LAST_HEADER_FORMAT,
I18N_SEARCH_USAGE,
I18N_SEARCH_HEADER_FORMAT,
I18N_MUTE_JOINS_FORMAT,
I18N_MUTE_JOINS_MUTED,

View file

@ -10,76 +10,106 @@ typedef struct {
const char *summary_zh;
const char *manual_usage_en;
const char *manual_usage_zh;
const char *error_usage_en;
const char *error_usage_zh;
int manual_group;
bool no_args;
bool requires_args;
} command_catalog_entry_t;
static const command_catalog_entry_t entries[] = {
{
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}, false},
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}},
":users, :list, :who", ":users, :list, :who",
"Show online users", "显示在线用户",
":users", ":users", 1
":users", ":users",
"Usage: users\n", "用法: users\n",
1, true, false
},
{
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}, true},
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}},
":msg <user> <message>, :w <user> <message>",
":msg <user> <message>, :w <user> <message>",
"Send private message", "发送私信",
":msg <user> <message>", ":msg <user> <message>", 2
":msg <user> <message>", ":msg <user> <message>",
"Usage: msg <user> <message>\n"
" w <user> <message>\n",
"用法: msg <user> <message>\n"
" w <user> <message>\n",
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}, false},
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
":inbox", ":inbox",
"Show private messages", "查看私信",
":inbox", ":inbox", 2
":inbox", ":inbox",
"Usage: inbox\n", "用法: inbox\n",
2, true, false
},
{
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}, true},
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
":nick <name>, :name <name>", ":nick <name>, :name <name>",
"Change nickname", "更改昵称",
":nick <name>", ":nick <name>", 2
":nick <name>", ":nick <name>",
"Usage: nick <name>\n", "用法: nick <name>\n",
2, false, true
},
{
{TNT_COMMAND_LAST, "last", {"last", NULL}, true},
{TNT_COMMAND_LAST, "last", {"last", NULL}},
":last [N]", ":last [N]",
"Show last N messages (max 50)", "显示最后 N 条消息(最多50)",
":last [N]", ":last [N]", 1
":last [N]", ":last [N]",
"Usage: last [N] (N: 1-50, default 10)\n",
"用法: last [N] (N: 1-50默认 10)\n",
1, false, false
},
{
{TNT_COMMAND_SEARCH, "search", {"search", NULL}, true},
{TNT_COMMAND_SEARCH, "search", {"search", NULL}},
":search <keyword>", ":search <keyword>",
"Search message history", "搜索消息历史",
":search <keyword>", ":search <keyword>", 1
":search <keyword>", ":search <keyword>",
"Usage: search <keyword>\n", "用法: search <keyword>\n",
1, false, true
},
{
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}, false},
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}},
":mute-joins, :mute", ":mute-joins, :mute",
"Toggle join/leave notices", "切换加入/离开提示",
":mute-joins", ":mute-joins", 3
":mute-joins", ":mute-joins",
"Usage: mute-joins\n", "用法: mute-joins\n",
3, true, false
},
{
{TNT_COMMAND_HELP, "help", {"help", NULL}, false},
{TNT_COMMAND_HELP, "help", {"help", NULL}},
":help", ":help",
"Show concise manual", "显示简明手册",
NULL, NULL, 0
NULL, NULL,
"Usage: help\n", "用法: help\n",
0, true, false
},
{
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}, true},
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}},
":lang <en|zh>", ":lang <en|zh>",
"Switch UI language", "切换界面语言",
NULL, NULL, 0
NULL, NULL,
"Usage: lang <en|zh>\n", "用法: lang <en|zh>\n",
0, false, false
},
{
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}, false},
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}},
":clear, :cls", ":clear, :cls",
"Clear command output", "清空命令输出",
":clear", ":clear", 3
":clear", ":clear",
"Usage: clear\n", "用法: clear\n",
3, true, false
},
{
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}, false},
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}},
":q, :quit, :exit", ":q, :quit, :exit",
"Disconnect", "断开连接",
":q", ":q", 3
":q", ":q",
"Usage: q\n", "用法: q\n",
3, true, false
}
};
@ -177,10 +207,6 @@ bool command_catalog_match(const char *line, tnt_command_id_t *id,
if (!name_matches(line, spec->names[n], &candidate_args)) {
continue;
}
if (candidate_args && candidate_args[0] != '\0' &&
!spec->accepts_args) {
continue;
}
if (id) {
*id = spec->id;
}
@ -194,6 +220,22 @@ bool command_catalog_match(const char *line, tnt_command_id_t *id,
return false;
}
bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
const command_catalog_entry_t *entry = entry_for_id(id);
args = skip_spaces(args);
if (!entry) {
return false;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}
if (entry->requires_args) {
return args && args[0] != '\0';
}
return true;
}
const char *command_catalog_suggest(const char *name) {
const char *best = NULL;
int best_distance = 99;
@ -257,3 +299,17 @@ void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
buffer_appendf(buffer, buf_size, pos, "\n");
}
}
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang) {
const command_catalog_entry_t *entry = entry_for_id(id);
const char *usage;
if (!entry) {
return;
}
usage = lang == UI_LANG_ZH ? entry->error_usage_zh
: entry->error_usage_en;
buffer_appendf(buffer, buf_size, pos, "%s", usage);
}

View file

@ -47,6 +47,11 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos,
}
}
static void append_command_usage(char *output, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang) {
command_catalog_append_usage(output, buf_size, pos, id, lang);
}
void commands_dispatch(client_t *client) {
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
@ -107,6 +112,12 @@ void commands_dispatch(client_t *client) {
goto cmd_done;
}
if (!command_catalog_args_valid(command_id, arg)) {
append_command_usage(output, sizeof(output), &pos, command_id,
client->ui_lang);
goto cmd_done;
}
if (command_id == TNT_COMMAND_USERS) {
pthread_rwlock_rdlock(&g_room->lock);
int total = g_room->client_count;
@ -171,8 +182,8 @@ void commands_dispatch(client_t *client) {
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_MSG_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
} else {
bool found = false;
client_t *target = NULL;
@ -267,8 +278,8 @@ void commands_dispatch(client_t *client) {
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_NICK_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_NICK, client->ui_lang);
} else if (!is_valid_username(new_name)) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_NICK_INVALID));
@ -331,8 +342,8 @@ void commands_dispatch(client_t *client) {
char *endp;
long val = strtol(arg, &endp, 10);
if (*endp != '\0' || val < 1 || val > 50) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_LAST_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_LAST, client->ui_lang);
goto cmd_done;
}
n = (int)val;
@ -357,8 +368,8 @@ void commands_dispatch(client_t *client) {
const char *query = arg;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_SEARCH_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_SEARCH, client->ui_lang);
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);

View file

@ -106,12 +106,6 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = {
"Online users",
"在线用户"
},
[I18N_MSG_USAGE] = {
"Usage: msg <user> <message>\n"
" w <user> <message>\n",
"用法: msg <user> <message>\n"
" w <user> <message>\n"
},
[I18N_MSG_SENT_FORMAT] = {
"Private message sent to %s\n",
"私信已发送给 %s\n"
@ -128,10 +122,6 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = {
"(empty)",
"(空)"
},
[I18N_NICK_USAGE] = {
"Usage: nick <name>\n",
"用法: nick <name>\n"
},
[I18N_NICK_INVALID] = {
"Invalid username\n",
"用户名无效\n"
@ -148,18 +138,10 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = {
"Nickname changed: %s -> %s\n",
"昵称已修改: %s -> %s\n"
},
[I18N_LAST_USAGE] = {
"Usage: last [N] (N: 1-50, default 10)\n",
"用法: last [N] (N: 1-50默认 10)\n"
},
[I18N_LAST_HEADER_FORMAT] = {
"--- Last %d message(s) ---\n",
"--- 最近 %d 条消息 ---\n"
},
[I18N_SEARCH_USAGE] = {
"Usage: search <keyword>\n",
"用法: search <keyword>\n"
},
[I18N_SEARCH_HEADER_FORMAT] = {
"--- Search: \"%s\" (%d match(es)) ---\n",
"--- 搜索: \"%s\" (%d 条匹配) ---\n"

View file

@ -305,6 +305,20 @@ send -- "last 999\r"
expect "Usage: last \\[N\\]"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "users extra\r"
expect "Usage: users"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "help now\r"
expect "Usage: help"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2

View file

@ -40,13 +40,37 @@ TEST(matches_canonical_names_and_aliases) {
assert(strcmp(args, "zh") == 0);
}
TEST(rejects_arguments_for_no_arg_commands) {
TEST(matches_known_commands_before_argument_validation) {
tnt_command_id_t id;
const char *args;
assert(!command_catalog_match("users extra", &id, &args));
assert(!command_catalog_match("help now", &id, &args));
assert(!command_catalog_match("q now", &id, &args));
assert(command_catalog_match("users extra", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(strcmp(args, "extra") == 0);
assert(command_catalog_match("help now", &id, &args));
assert(id == TNT_COMMAND_HELP);
assert(strcmp(args, "now") == 0);
assert(command_catalog_match("q now", &id, &args));
assert(id == TNT_COMMAND_QUIT);
assert(strcmp(args, "now") == 0);
}
TEST(validates_argument_shapes) {
assert(command_catalog_args_valid(TNT_COMMAND_USERS, NULL));
assert(!command_catalog_args_valid(TNT_COMMAND_USERS, "extra"));
assert(command_catalog_args_valid(TNT_COMMAND_HELP, NULL));
assert(!command_catalog_args_valid(TNT_COMMAND_HELP, "now"));
assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, ""));
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
assert(command_catalog_args_valid(TNT_COMMAND_LAST, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_LAST, "999"));
assert(command_catalog_args_valid(TNT_COMMAND_LANG, "fr"));
}
TEST(suggests_from_catalog_aliases) {
@ -81,13 +105,31 @@ TEST(generates_localized_help_sections) {
assert_ascii_angle_placeholders(zh);
}
TEST(generates_localized_usage) {
char en[256] = {0};
char zh[256] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_LAST, UI_LANG_EN);
command_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_COMMAND_MSG, UI_LANG_ZH);
assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0);
assert(strcmp(zh, "用法: msg <user> <message>\n"
" w <user> <message>\n") == 0);
}
int main(void) {
printf("Running command catalog unit tests...\n\n");
RUN_TEST(matches_canonical_names_and_aliases);
RUN_TEST(rejects_arguments_for_no_arg_commands);
RUN_TEST(matches_known_commands_before_argument_validation);
RUN_TEST(validates_argument_shapes);
RUN_TEST(suggests_from_catalog_aliases);
RUN_TEST(generates_localized_help_sections);
RUN_TEST(generates_localized_usage);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;

View file

@ -111,12 +111,6 @@ TEST(text_lookup_matches_language) {
"idle timeout") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
"空闲超时") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_USAGE),
"msg <user>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_USAGE),
"msg <user>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_USAGE),
"<用户>") == NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_SENT_FORMAT),
"Private message sent") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
@ -125,14 +119,10 @@ TEST(text_lookup_matches_language) {
"Private messages") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
"私信") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_NICK_USAGE),
"nick <name>") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
"Search") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
"搜索") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_USAGE),
"search <keyword>") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT),
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),