Compare commits
21 Commits
test/ai-co
...
92f601d2ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
92f601d2ea
|
|||
|
555faaa232
|
|||
|
b1d820462a
|
|||
|
8868d58920
|
|||
|
7ba7e53291
|
|||
|
a96a4ad41e
|
|||
|
58002409ff
|
|||
|
2b1b70665b
|
|||
|
0d38a0a2cb
|
|||
|
71f9ab5007
|
|||
|
06463b75b4
|
|||
|
507ef91b5f
|
|||
|
9f3020e40a
|
|||
|
7e320ccdb0
|
|||
|
b5fe714a89
|
|||
|
1f3d3117bf
|
|||
|
23723376c6
|
|||
|
16ae7fb85c
|
|||
|
d25f90899c
|
|||
|
89b90e5cf2
|
|||
|
31e8c30c34
|
12
Makefile.am
12
Makefile.am
@@ -37,7 +37,6 @@ core_sources = \
|
||||
src/ui/window_list.c src/ui/window_list.h \
|
||||
src/ui/rosterwin.c src/ui/occupantswin.c \
|
||||
src/ui/buffer.c src/ui/buffer.h \
|
||||
src/ui/aiwin.c \
|
||||
src/ui/chatwin.c \
|
||||
src/ui/mucwin.c \
|
||||
src/ui/privwin.c \
|
||||
@@ -79,8 +78,7 @@ core_sources = \
|
||||
src/plugins/themes.c src/plugins/themes.h \
|
||||
src/plugins/settings.c src/plugins/settings.h \
|
||||
src/plugins/disco.c src/plugins/disco.h \
|
||||
src/ui/tray.h src/ui/tray.c \
|
||||
src/ai/ai_client.h src/ai/ai_client.c
|
||||
src/ui/tray.h src/ui/tray.c
|
||||
|
||||
unittest_sources = \
|
||||
src/xmpp/contact.c src/xmpp/contact.h src/common.c \
|
||||
@@ -91,7 +89,6 @@ unittest_sources = \
|
||||
src/xmpp/chat_state.h src/xmpp/chat_state.c \
|
||||
src/xmpp/roster_list.c src/xmpp/roster_list.h \
|
||||
src/xmpp/xmpp.h src/xmpp/form.c \
|
||||
src/ai/ai_client.h src/ai/ai_client.c \
|
||||
src/ui/ui.h \
|
||||
src/otr/otr.h \
|
||||
src/pgp/gpg.h \
|
||||
@@ -141,7 +138,6 @@ unittest_sources = \
|
||||
tests/unittests/xmpp/stub_message.c \
|
||||
tests/unittests/ui/stub_ui.c tests/unittests/ui/stub_ui.h \
|
||||
tests/unittests/ui/stub_vcardwin.c \
|
||||
tests/unittests/ui/stub_ai.c \
|
||||
tests/unittests/log/stub_log.c \
|
||||
tests/unittests/chatlog/stub_chatlog.c \
|
||||
tests/unittests/database/stub_database.c \
|
||||
@@ -178,7 +174,6 @@ unittest_sources = \
|
||||
tests/unittests/test_callbacks.c tests/unittests/test_callbacks.h \
|
||||
tests/unittests/test_plugins_disco.c tests/unittests/test_plugins_disco.h \
|
||||
tests/unittests/test_forced_encryption.c tests/unittests/test_forced_encryption.h \
|
||||
tests/unittests/test_ai_client.c tests/unittests/test_ai_client.h \
|
||||
tests/unittests/test_database_export.c tests/unittests/test_database_export.h \
|
||||
tests/unittests/test_database_stress.c tests/unittests/test_database_stress.h \
|
||||
tests/unittests/unittests.c
|
||||
@@ -203,13 +198,8 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
|
||||
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
|
||||
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
dist_check_DATA = tests/functionaltests/ai_http_stub.py
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
python_sources = \
|
||||
|
||||
@@ -204,13 +204,6 @@ case "$ARCH" in
|
||||
""
|
||||
)
|
||||
source /etc/profile.d/debuginfod.sh 2>/dev/null || true
|
||||
|
||||
if grep -q 'ID=arch' /etc/os-release 2>/dev/null && [ -f /etc/makepkg.conf ]; then
|
||||
echo "--> [Parity Mode] Simulating Pikaur collision..."
|
||||
set -a
|
||||
source /etc/makepkg.conf
|
||||
set +a
|
||||
fi
|
||||
;;
|
||||
darwin*)
|
||||
# 4 configurations for parallel CI
|
||||
|
||||
@@ -396,11 +396,12 @@ AC_SUBST([FORKPTY_LIB])
|
||||
## Default parameters
|
||||
AM_CFLAGS="$AM_CFLAGS -Wall -Wextra -Wformat=2 -Wno-format-zero-length"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wno-deprecated-declarations -Wno-unused-parameter -Wno-missing-field-initializers -Wno-sign-compare -Wno-cast-function-type"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wnull-dereference -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wimplicit-function-declaration"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wundef"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wfloat-equal -Wredundant-decls"
|
||||
AM_CFLAGS="$AM_CFLAGS -fstack-protector-strong -fno-common"
|
||||
AM_CFLAGS="$AM_CFLAGS -D_FORTIFY_SOURCE=2"
|
||||
AM_CFLAGS="$AM_CFLAGS -std=gnu99 -ggdb3"
|
||||
|
||||
# GCC-specific warnings (not supported by clang) — test each one
|
||||
|
||||
1482
src/ai/ai_client.c
1482
src/ai/ai_client.c
File diff suppressed because it is too large
Load Diff
@@ -1,275 +0,0 @@
|
||||
#ifndef AI_CLIENT_H
|
||||
#define AI_CLIENT_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/**
|
||||
* @brief AI message structure for conversation history.
|
||||
*/
|
||||
typedef struct ai_message_t
|
||||
{
|
||||
gchar* role; /* "user" or "assistant" */
|
||||
gchar* content; /* Message content */
|
||||
} AIMessage;
|
||||
|
||||
/**
|
||||
* @brief AI provider configuration.
|
||||
*/
|
||||
typedef struct ai_provider_t
|
||||
{
|
||||
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
|
||||
gchar* api_url; /* API endpoint URL */
|
||||
gchar* org_id; /* Optional organization ID */
|
||||
gchar* project_id; /* Optional project ID (for some providers) */
|
||||
gchar* default_model; /* Default model for this provider */
|
||||
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
|
||||
GList* models; /* Cached models (gchar*) */
|
||||
gboolean models_fresh; /* Whether models cache is current */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} AIProvider;
|
||||
|
||||
/**
|
||||
* @brief AI chat session structure.
|
||||
*/
|
||||
typedef struct ai_session_t
|
||||
{
|
||||
gchar* provider_name; /* Provider name */
|
||||
AIProvider* provider; /* Provider configuration */
|
||||
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
|
||||
gchar* api_key; /* API key for this session */
|
||||
GList* history; /* Conversation history (GList of AIMessage*) */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} AISession;
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Initialize the AI client and load default providers.
|
||||
*/
|
||||
void ai_client_init(void);
|
||||
|
||||
/**
|
||||
* Shutdown the AI client and free resources.
|
||||
*/
|
||||
void ai_client_shutdown(void);
|
||||
|
||||
/**
|
||||
* Get a provider by name.
|
||||
* @param name The provider name (e.g., "openai", "perplexity")
|
||||
* @return AIProvider*, or NULL if not found
|
||||
*/
|
||||
AIProvider* ai_get_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Add or update a provider configuration.
|
||||
* @param name The provider name
|
||||
* @param api_url The API endpoint URL
|
||||
* @param org_id Optional organization ID (can be NULL)
|
||||
* @return New AIProvider* (caller must unref when done)
|
||||
*/
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
* @param name The provider name
|
||||
* @return TRUE if provider was removed, FALSE if not found
|
||||
*/
|
||||
gboolean ai_remove_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Decrement the reference count of a provider.
|
||||
* @param provider The provider to unreference
|
||||
*/
|
||||
void ai_provider_unref(AIProvider* provider);
|
||||
|
||||
/**
|
||||
* List all configured providers.
|
||||
* @return GList of AIProvider* (caller must not free the list or providers,
|
||||
* and must not unref the returned providers)
|
||||
*/
|
||||
GList* ai_list_providers(void);
|
||||
|
||||
/**
|
||||
* Find a provider name for autocomplete.
|
||||
* @param search_str The search string
|
||||
* @param previous Whether to go to previous match
|
||||
* @param context Unused
|
||||
* @return Provider name, or NULL if not found
|
||||
*/
|
||||
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Find a model name for autocomplete.
|
||||
* @param search_str The search string
|
||||
* @param previous Whether to go to previous match
|
||||
* @param context ProfAiWin* for the current session
|
||||
* @return Model name, or NULL if not found
|
||||
*/
|
||||
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return The API key, or NULL if not set (caller must free)
|
||||
*/
|
||||
gchar* ai_get_provider_key(const gchar* provider_name);
|
||||
|
||||
/**
|
||||
* Set the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param api_key The API key to set
|
||||
*/
|
||||
void ai_set_provider_key(const gchar* provider_name, const gchar* api_key);
|
||||
|
||||
/**
|
||||
* Set the default model for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param model The default model name
|
||||
*/
|
||||
void ai_set_provider_default_model(const gchar* provider_name, const gchar* model);
|
||||
|
||||
/**
|
||||
* Get the default model for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return The default model name (caller must not free), or NULL if not set
|
||||
*/
|
||||
const gchar* ai_get_provider_default_model(const gchar* provider_name);
|
||||
|
||||
/**
|
||||
* Set a custom setting for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param setting The setting key (e.g., "tools", "search")
|
||||
* @param value The setting value
|
||||
*/
|
||||
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
|
||||
|
||||
/**
|
||||
* Get a custom setting for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param setting The setting key
|
||||
* @return The setting value (caller must free), or NULL if not set
|
||||
*/
|
||||
gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting);
|
||||
|
||||
/**
|
||||
* Fetch available models from a provider's API.
|
||||
* @param provider_name The provider name
|
||||
* @param user_data User data (ProfAiWin* for UI display)
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
|
||||
|
||||
/**
|
||||
* Check if models cache is fresh for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return TRUE if models are fresh, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
|
||||
/* ========================================================================
|
||||
* Parsing helpers (exposed for testing)
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Parse model IDs from an OpenAI-compatible API response.
|
||||
* Expected format: {"object":"list","data":[{"id":"model1",...},...]}
|
||||
* @param provider The provider to add models to
|
||||
* @param json The JSON response from the API
|
||||
*/
|
||||
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
|
||||
|
||||
/**
|
||||
* Extract assistant content from an LLM response body. Tries Perplexity
|
||||
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
|
||||
* the \" and \n escape sequences only.
|
||||
* @param response_json The JSON body from the provider
|
||||
* @return Newly allocated content string, or NULL if not found (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_response(const gchar* response_json);
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from an API error envelope.
|
||||
* Expected format: {"error":{"message":"...","type":"...","code":...}}
|
||||
* Decodes \" \n \\ \t escapes.
|
||||
* @param error_json The JSON body of the error response
|
||||
* @return Newly allocated error message, or NULL if not parseable (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_error_message(const gchar* error_json);
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Create a new AI session with the specified provider and model.
|
||||
* @param provider_name The provider name (e.g., "openai")
|
||||
* @param model The model identifier (e.g., "gpt-4")
|
||||
* @return New AISession*, or NULL on failure
|
||||
*/
|
||||
AISession* ai_session_create(const gchar* provider_name, const gchar* model);
|
||||
|
||||
/**
|
||||
* Increment the reference count of an AI session.
|
||||
* @param session The session to reference
|
||||
* @return The same session pointer
|
||||
*/
|
||||
AISession* ai_session_ref(AISession* session);
|
||||
|
||||
/**
|
||||
* Decrement the reference count and free the session when it reaches zero.
|
||||
* @param session The session to unreference
|
||||
*/
|
||||
void ai_session_unref(AISession* session);
|
||||
|
||||
/**
|
||||
* Add a message to the session history.
|
||||
* @param session The session
|
||||
* @param role The message role ("user" or "assistant")
|
||||
* @param content The message content
|
||||
*/
|
||||
void ai_session_add_message(AISession* session, const gchar* role, const gchar* content);
|
||||
|
||||
/**
|
||||
* Clear the conversation history.
|
||||
* @param session The session
|
||||
*/
|
||||
void ai_session_clear_history(AISession* session);
|
||||
|
||||
/**
|
||||
* Get the current model for a session.
|
||||
* @param session The session
|
||||
* @return The model name (caller must not free)
|
||||
*/
|
||||
const gchar* ai_session_get_model(AISession* session);
|
||||
|
||||
/**
|
||||
* Set the model for a session.
|
||||
* @param session The session
|
||||
* @param model The model name
|
||||
*/
|
||||
void ai_session_set_model(AISession* session, const gchar* model);
|
||||
|
||||
/* ========================================================================
|
||||
* Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Send a prompt to the AI provider asynchronously.
|
||||
* @param session The AI session containing provider and model
|
||||
* @param prompt The prompt to send
|
||||
* @param user_data User data (ProfAiWin* for UI display)
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_send_prompt(AISession* session, const gchar* prompt,
|
||||
gpointer user_data);
|
||||
|
||||
/**
|
||||
* Escape a string for JSON embedding.
|
||||
* @param str The string to escape
|
||||
* @return Newly allocated escaped string (caller must free)
|
||||
*/
|
||||
gchar* ai_json_escape(const gchar* str);
|
||||
|
||||
#endif /* AI_CLIENT_H */
|
||||
@@ -66,11 +66,8 @@
|
||||
#include "omemo/omemo.h"
|
||||
#endif
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
|
||||
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
@@ -140,7 +137,6 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo
|
||||
static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
|
||||
static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context);
|
||||
|
||||
@@ -284,16 +280,11 @@ static Autocomplete history_switch_ac;
|
||||
static Autocomplete color_ac;
|
||||
static Autocomplete correction_ac;
|
||||
static Autocomplete avatar_ac;
|
||||
static Autocomplete ai_subcommands_ac;
|
||||
static Autocomplete ai_set_subcommands_ac;
|
||||
static Autocomplete ai_set_custom_subcommands_ac;
|
||||
static Autocomplete ai_remove_subcommands_ac;
|
||||
static Autocomplete url_ac;
|
||||
static Autocomplete executable_ac;
|
||||
static Autocomplete executable_param_ac;
|
||||
static Autocomplete intype_ac;
|
||||
static Autocomplete mood_ac;
|
||||
static Autocomplete ai_models_ac;
|
||||
static Autocomplete mood_type_ac;
|
||||
static Autocomplete strophe_ac;
|
||||
static Autocomplete strophe_sm_ac;
|
||||
@@ -465,11 +456,7 @@ static Autocomplete* all_acs[] = {
|
||||
&vcard_togglable_param_ac,
|
||||
&vcard_address_type_ac,
|
||||
&force_encryption_ac,
|
||||
&force_encryption_policy_ac,
|
||||
&ai_subcommands_ac,
|
||||
&ai_set_subcommands_ac,
|
||||
&ai_set_custom_subcommands_ac,
|
||||
&ai_remove_subcommands_ac
|
||||
&force_encryption_policy_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1159,7 +1146,6 @@ cmd_ac_init(void)
|
||||
|
||||
autocomplete_add(history_ac, "on");
|
||||
autocomplete_add(history_ac, "off");
|
||||
autocomplete_add(history_ac, "backend");
|
||||
autocomplete_add(history_ac, "switch");
|
||||
autocomplete_add(history_ac, "verify");
|
||||
autocomplete_add(history_ac, "export");
|
||||
@@ -1182,27 +1168,6 @@ cmd_ac_init(void)
|
||||
autocomplete_add(correction_ac, "off");
|
||||
autocomplete_add(correction_ac, "char");
|
||||
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "set", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "remove", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "start", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "clear", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "providers", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "switch", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "models", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE);
|
||||
|
||||
autocomplete_add(avatar_ac, "set");
|
||||
autocomplete_add(avatar_ac, "disable");
|
||||
autocomplete_add(avatar_ac, "get");
|
||||
@@ -1478,7 +1443,6 @@ cmd_ac_init(void)
|
||||
g_hash_table_insert(ac_funcs, "/wins", _wins_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/wintitle", _wintitle_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/force-encryption", _force_encryption_autocomplete);
|
||||
g_hash_table_insert(ac_funcs, "/ai", _ai_autocomplete);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -1842,14 +1806,6 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
|
||||
return result;
|
||||
}
|
||||
|
||||
gchar* history_jid_subcmds[] = { "/history verify", "/history export", "/history import" };
|
||||
for (int i = 0; i < ARRAY_SIZE(history_jid_subcmds); i++) {
|
||||
result = autocomplete_param_with_func(input, history_jid_subcmds[i], roster_barejid_autocomplete, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/history", history_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
@@ -4485,167 +4441,4 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/force-encryption", force_encryption_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char*
|
||||
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
{
|
||||
char* result = NULL;
|
||||
|
||||
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
|
||||
gboolean parse_result = FALSE;
|
||||
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
|
||||
gboolean space_at_end = g_str_has_suffix(input, " ");
|
||||
int num_args = g_strv_length(args);
|
||||
|
||||
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
|
||||
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set token <provider> <token> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-provider <provider> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-model <provider> <model> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set custom <provider> <setting> <value> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set custom <provider> <setting> - autocomplete settings
|
||||
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
|
||||
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
|
||||
/* /ai set custom <provider> - check if provider is valid */
|
||||
if (ai_get_provider(args[2])) {
|
||||
/* Valid provider, try settings autocomplete */
|
||||
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
// /ai set <subcommand> - autocomplete subcommands
|
||||
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai remove provider <name> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start [<provider>] [<model>] - use shared parse_args
|
||||
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
|
||||
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
|
||||
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
|
||||
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
|
||||
} else {
|
||||
/* /ai start <provider> <model> - model autocomplete */
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
|
||||
}
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// /ai switch <provider> <model> - autocomplete model names for specified provider
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai switch <model> - autocomplete model names from current session's provider
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai models <provider> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai models", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai clear - no autocomplete
|
||||
// /ai providers - no autocomplete
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
|
||||
* Extracts provider from input and provides model completions from that provider's cached models.
|
||||
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
|
||||
*/
|
||||
static char*
|
||||
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
|
||||
{
|
||||
if (!g_str_has_prefix(input, cmd_prefix)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Extract provider name from input (after cmd_prefix) */
|
||||
auto_gchar gchar* rest = g_strdup(input + strlen(cmd_prefix));
|
||||
char* space = strchr(rest, ' ');
|
||||
if (space) {
|
||||
*space = '\0';
|
||||
}
|
||||
|
||||
/* Look up the provider by name and get its models */
|
||||
AIProvider* prov = ai_get_provider(rest);
|
||||
if (!prov || !prov->models) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!ai_models_ac) {
|
||||
ai_models_ac = autocomplete_new();
|
||||
}
|
||||
/* Convert GList* to char** for autocomplete_update */
|
||||
int model_count = g_list_length(prov->models);
|
||||
char** model_array = g_new0(char*, model_count + 1);
|
||||
GList* curr = prov->models;
|
||||
int i = 0;
|
||||
while (curr) {
|
||||
model_array[i++] = curr->data;
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
autocomplete_update(ai_models_ac, model_array);
|
||||
g_free(model_array);
|
||||
|
||||
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
|
||||
|
||||
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1882,7 +1882,6 @@ static const struct cmd_t command_defs[] = {
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/history on|off",
|
||||
"/history backend",
|
||||
"/history switch sqlite|flatfile",
|
||||
"/history verify [<jid>]",
|
||||
"/history export [<jid>]",
|
||||
@@ -1890,14 +1889,12 @@ static const struct cmd_t command_defs[] = {
|
||||
CMD_DESC(
|
||||
"Switch chat history on or off, /logging chat will automatically be enabled when this setting is on. "
|
||||
"When history is enabled, previous messages are shown in chat windows. "
|
||||
"Use 'backend' to show the active database backend. "
|
||||
"Use 'switch' to change the active database backend at runtime without reconnecting. "
|
||||
"Use 'verify' to check integrity of stored message history. "
|
||||
"Use 'export' to copy messages from SQLite to flat-file format, or 'import' to copy from flat-file to SQLite. "
|
||||
"Both export and import merge with existing data (duplicates are skipped).")
|
||||
CMD_ARGS(
|
||||
{ "on|off", "Enable or disable showing chat history." },
|
||||
{ "backend", "Show the name of the active database backend." },
|
||||
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
|
||||
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },
|
||||
{ "export [<jid>]", "Export SQLite history to flat-file format. Optionally specify a JID to export only one contact." },
|
||||
@@ -2787,71 +2784,6 @@ static const struct cmd_t command_defs[] = {
|
||||
"/force-encryption policy block")
|
||||
},
|
||||
|
||||
{ CMD_PREAMBLE("/ai",
|
||||
parse_args, 0, 5, NULL)
|
||||
CMD_SUBFUNCS(
|
||||
{ "set", cmd_ai_set },
|
||||
{ "remove", cmd_ai_remove },
|
||||
{ "start", cmd_ai_start },
|
||||
{ "clear", cmd_ai_clear },
|
||||
{ "providers", cmd_ai_providers },
|
||||
{ "switch", cmd_ai_switch },
|
||||
{ "models", cmd_ai_models })
|
||||
CMD_MAINFUNC(cmd_ai)
|
||||
CMD_TAGS(
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/ai",
|
||||
"/ai set provider <name> <url>",
|
||||
"/ai set token <provider> <token>",
|
||||
"/ai set default-provider <provider>",
|
||||
"/ai set default-model <provider> <model>",
|
||||
"/ai set custom <provider> <setting> <value>",
|
||||
"/ai remove provider <name>",
|
||||
"/ai providers",
|
||||
"/ai start [<provider>] [<model>]",
|
||||
"/ai switch <provider> [<model>]",
|
||||
"/ai switch <model>",
|
||||
"/ai models <provider>",
|
||||
"/ai clear")
|
||||
CMD_DESC(
|
||||
"Interact with AI models via OpenAI-compatible APIs. "
|
||||
"Supports multiple providers (openai, perplexity, custom). "
|
||||
"Each provider has its own API key, endpoint, default model, and settings. "
|
||||
"Chat history is maintained per session and not persisted locally.")
|
||||
CMD_ARGS(
|
||||
{ "", "Display current AI settings and configured providers" },
|
||||
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
|
||||
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
|
||||
{ "set default-provider <provider>", "Set global default provider for /ai start" },
|
||||
{ "set default-model <provider> <model>", "Set default model for a provider" },
|
||||
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
|
||||
{ "remove provider <name>", "Remove a custom provider" },
|
||||
{ "providers", "List configured providers with full details" },
|
||||
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
|
||||
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
|
||||
{ "switch <model>", "Change model in current session (keeps provider)" },
|
||||
{ "models <provider>", "Fetch and display available models for provider" },
|
||||
{ "clear", "Clear current chat history" })
|
||||
CMD_EXAMPLES(
|
||||
"/ai",
|
||||
"/ai set token openai sk-xxx",
|
||||
"/ai set token perplexity pplx-xxx",
|
||||
"/ai set provider custom https://my-api.com/v1/chat/completions",
|
||||
"/ai set default-provider perplexity",
|
||||
"/ai set default-model perplexity sonar",
|
||||
"/ai set custom perplexity tools enabled",
|
||||
"/ai remove provider custom",
|
||||
"/ai start",
|
||||
"/ai start perplexity",
|
||||
"/ai start perplexity sonar",
|
||||
"/ai start openai gpt-4o",
|
||||
"/ai switch gpt-4o",
|
||||
"/ai switch openai gpt-5.4-nano",
|
||||
"/ai models perplexity",
|
||||
"/ai clear")
|
||||
},
|
||||
|
||||
// NEXT-COMMAND (search helper)
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
#include "tools/bookmark_ignore.h"
|
||||
#include "tools/editor.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/avatar.h"
|
||||
@@ -116,8 +117,6 @@
|
||||
#include "tools/clipboard.h"
|
||||
#endif
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
#ifdef HAVE_PYTHON
|
||||
#include "plugins/python_plugins.h"
|
||||
#endif
|
||||
@@ -6786,15 +6785,6 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "backend") == 0) {
|
||||
if (active_db_backend && active_db_backend->name) {
|
||||
cons_show("Active database backend: %s", active_db_backend->name);
|
||||
} else {
|
||||
cons_show("No database backend is currently active.");
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[0], "switch") == 0) {
|
||||
jabber_conn_status_t conn_status = connection_get_status();
|
||||
if (conn_status != JABBER_CONNECTED) {
|
||||
@@ -8467,7 +8457,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
}
|
||||
|
||||
// handle non commands in non chat or plugin windows
|
||||
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML && window->type != WIN_AI) {
|
||||
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML) {
|
||||
cons_show("Unknown command: %s", inp);
|
||||
cons_alert(NULL);
|
||||
return TRUE;
|
||||
@@ -8513,12 +8503,6 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
connection_send_stanza(inp);
|
||||
break;
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -10787,500 +10771,6 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[0] == NULL) {
|
||||
// Display current AI settings
|
||||
cons_show("AI Chat - OpenAI-compatible API client");
|
||||
cons_show("");
|
||||
|
||||
// List configured providers with full details
|
||||
GList* providers = ai_list_providers();
|
||||
cons_show("Configured providers:");
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
auto_gchar gchar* key = ai_get_provider_key(provider->name);
|
||||
const gchar* default_model = ai_get_provider_default_model(provider->name);
|
||||
cons_show(" %s", provider->name);
|
||||
cons_show(" URL: %s", provider->api_url);
|
||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||
if (default_model) {
|
||||
cons_show(" Default model: %s", default_model);
|
||||
}
|
||||
if (provider->models && g_list_length(provider->models) > 0) {
|
||||
cons_show(" Cached models: %d", g_list_length(provider->models));
|
||||
}
|
||||
cons_show("");
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
|
||||
cons_show("Use '/ai models <provider>' to fetch available models.");
|
||||
cons_show("Available models: https://models.litellm.ai/");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strcmp0(args[1], "provider") == 0) {
|
||||
// /ai set provider <name> <url>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
if (ai_add_provider(args[2], args[3], NULL)) {
|
||||
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
|
||||
} else {
|
||||
cons_show_error("Failed to configure provider '%s'.", args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "token") == 0) {
|
||||
// /ai set token <provider> <token>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_key(args[2], args[3]);
|
||||
cons_show("API token set for provider: %s", args[2]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "default-model") == 0) {
|
||||
// /ai set default-model <provider> <model>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_default_model(args[2], args[3]);
|
||||
cons_show("Default model for provider '%s' set to: %s", args[2], args[3]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "custom") == 0) {
|
||||
// /ai set custom <provider> <setting> <value>
|
||||
if (g_strv_length(args) < 5) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_setting(args[2], args[3], args[4]);
|
||||
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "default-provider") == 0) {
|
||||
// /ai set default-provider <provider>
|
||||
if (g_strv_length(args) < 3) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
const gchar* provider_name = args[2];
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
|
||||
provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
prefs_set_string(PREF_AI_PROVIDER, provider_name);
|
||||
cons_show("Default provider set to: %s", provider_name);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_remove(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai remove provider <name>
|
||||
if (g_strcmp0(args[1], "provider") != 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (g_strv_length(args) < 3) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (ai_remove_provider(args[2])) {
|
||||
cons_show("Provider '%s' removed.", args[2]);
|
||||
} else {
|
||||
cons_show("Provider '%s' not found.", args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai start [<provider>] [<model>]
|
||||
// Space-separated, no slash syntax
|
||||
const gchar* provider_name = NULL;
|
||||
const gchar* model = NULL;
|
||||
|
||||
if (g_strv_length(args) >= 2) {
|
||||
provider_name = args[1];
|
||||
}
|
||||
if (g_strv_length(args) >= 3) {
|
||||
model = args[2];
|
||||
}
|
||||
|
||||
// Resolve defaults
|
||||
auto_gchar gchar* owned_provider_name = NULL;
|
||||
if (!provider_name) {
|
||||
// Use default provider from preferences
|
||||
auto_gchar gchar* default_provider = prefs_get_string(PREF_AI_PROVIDER);
|
||||
if (default_provider && strlen(default_provider) > 0) {
|
||||
provider_name = default_provider;
|
||||
}
|
||||
}
|
||||
if (!provider_name) {
|
||||
provider_name = "openai"; // Fallback
|
||||
}
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
|
||||
provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get model: explicit > provider default > hardcoded fallback
|
||||
if (!model) {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
if (!model) {
|
||||
model = "gpt-4o"; // Fallback
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
gchar* api_key = ai_get_provider_key(provider_name);
|
||||
if (!api_key || strlen(api_key) == 0) {
|
||||
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
|
||||
provider_name, provider_name);
|
||||
g_free(api_key);
|
||||
return TRUE;
|
||||
}
|
||||
g_free(api_key);
|
||||
|
||||
// Create new AI session
|
||||
AISession* session = ai_session_create(provider_name, model);
|
||||
if (!session) {
|
||||
log_error("[AI-CMD] Failed to create AI session");
|
||||
cons_show_error("Failed to create AI session for %s/%s.", provider_name, model);
|
||||
return TRUE;
|
||||
}
|
||||
log_debug("[AI-CMD] AI session created successfully");
|
||||
|
||||
// Create AI chat window
|
||||
log_debug("[AI-CMD] Calling wins_new_ai()...");
|
||||
ProfWin* ai_win = wins_new_ai(session);
|
||||
if (!ai_win) {
|
||||
log_error("[AI-CMD] wins_new_ai() returned NULL");
|
||||
cons_show_error("Failed to create AI chat window.");
|
||||
ai_session_unref(session);
|
||||
return TRUE;
|
||||
}
|
||||
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
|
||||
|
||||
// Add welcome message to the AI window
|
||||
log_debug("[AI-CMD] Adding welcome messages...");
|
||||
win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
|
||||
win_println(ai_win, THEME_DEFAULT, "-", "Type your message and press Enter to start a conversation.");
|
||||
log_debug("[AI-CMD] Welcome messages added");
|
||||
|
||||
// Focus the new window
|
||||
log_debug("[AI-CMD] Calling ui_focus_win()...");
|
||||
ui_focus_win(ai_win);
|
||||
log_debug("[AI-CMD] ui_focus_win() returned");
|
||||
|
||||
cons_show("Started AI chat: %s/%s", provider_name, model);
|
||||
cons_show("");
|
||||
log_debug("[AI-CMD] AI start command completed");
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai model <model>
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const gchar* model = args[1];
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start' first.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
ai_session_set_model(aiwin->session, model);
|
||||
cons_show("Session model changed to: %s", model);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai switch <provider> [<model>] - Change provider and optionally model
|
||||
// /ai switch <model> - Change model only (keeps current provider)
|
||||
// Modifies the existing session's provider and model instead of recreating it
|
||||
if (g_strv_length(args) < 2) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start' first.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const gchar* arg1 = args[1];
|
||||
const gchar* arg2 = (g_strv_length(args) >= 3) ? args[2] : NULL;
|
||||
|
||||
const gchar* provider_name;
|
||||
const gchar* model;
|
||||
gboolean changed_provider = FALSE;
|
||||
|
||||
// Check if arg1 is a known provider
|
||||
AIProvider* provider = ai_get_provider(arg1);
|
||||
if (provider) {
|
||||
// arg1 is a provider name
|
||||
provider_name = arg1;
|
||||
changed_provider = TRUE;
|
||||
|
||||
// Get model: arg2 > provider default > error
|
||||
if (arg2) {
|
||||
model = arg2;
|
||||
} else {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
if (!model) {
|
||||
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
|
||||
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
} else {
|
||||
// arg1 is a model name, keep current provider
|
||||
provider_name = aiwin->session->provider_name;
|
||||
model = arg1;
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
auto_gchar gchar* api_key = ai_get_provider_key(provider_name);
|
||||
if (!api_key || strlen(api_key) == 0) {
|
||||
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
|
||||
provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Update the existing session's provider and model
|
||||
if (changed_provider) {
|
||||
AIProvider* new_provider = ai_get_provider(provider_name);
|
||||
g_free(aiwin->session->provider_name);
|
||||
aiwin->session->provider_name = g_strdup(provider_name);
|
||||
ai_provider_unref(aiwin->session->provider);
|
||||
aiwin->session->provider = new_provider;
|
||||
aiwin->session->provider->ref_count++;
|
||||
}
|
||||
g_free(aiwin->session->model);
|
||||
aiwin->session->model = g_strdup(model);
|
||||
g_free(aiwin->session->api_key);
|
||||
aiwin->session->api_key = g_strdup(api_key);
|
||||
|
||||
// Update window title
|
||||
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
|
||||
|
||||
cons_show("Switched to %s/%s", provider_name, model);
|
||||
cons_show("");
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai models <provider> [--cached]
|
||||
// Default: fetch fresh models from API
|
||||
// --cached: display cached models (if available)
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const gchar* provider_name = args[1];
|
||||
gboolean use_cached = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--cached") == 0);
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
cons_show_error("Provider '%s' not found.", provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (use_cached) {
|
||||
// Display cached models (if available)
|
||||
if (!provider->models) {
|
||||
cons_show("No cached models for provider '%s'. Use '/ai models %s' to fetch from API.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
cons_show("Cached models for provider '%s':", provider_name);
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
cons_show(" %s", (gchar*)curr->data);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
cons_show("");
|
||||
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
|
||||
} else {
|
||||
// Default: fetch fresh models from API
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
ai_fetch_models(provider_name, aiwin);
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai clear
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
|
||||
if (aiwin) {
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
}
|
||||
cons_show("Chat history cleared.");
|
||||
} else {
|
||||
cons_show("No active AI chat window to clear.");
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai correct <message>
|
||||
// Join all arguments from args[1] onwards to support multi-word prompts
|
||||
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
|
||||
if (prompt == NULL || strlen(prompt) == 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get the last user message and replace it
|
||||
GList* history = aiwin->session->history;
|
||||
GList* last_user_msg = NULL;
|
||||
for (GList* curr = history; curr; curr = g_list_next(curr)) {
|
||||
AIMessage* msg = curr->data;
|
||||
if (g_strcmp0(msg->role, "user") == 0) {
|
||||
last_user_msg = curr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!last_user_msg) {
|
||||
cons_show("No user messages in this chat to correct.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Replace the last user message
|
||||
AIMessage* msg = last_user_msg->data;
|
||||
g_free(msg->content);
|
||||
msg->content = g_strdup(prompt);
|
||||
|
||||
// Resend the prompt
|
||||
cons_show("Correcting message...");
|
||||
ai_send_prompt(aiwin->session, prompt, aiwin);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) {
|
||||
// List all available providers
|
||||
cons_show("Available AI providers:");
|
||||
cons_show("");
|
||||
cons_show(" openai - OpenAI API (https://api.openai.com)");
|
||||
cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)");
|
||||
cons_show("");
|
||||
cons_show("Add custom providers with: /ai set provider <name> <url>");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// List configured providers with key status
|
||||
cons_show("Configured providers:");
|
||||
cons_show("");
|
||||
|
||||
GList* providers = ai_list_providers();
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
gchar* key = ai_get_provider_key(provider->name);
|
||||
cons_show(" %s", provider->name);
|
||||
cons_show(" URL: %s", provider->api_url);
|
||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||
if (provider->org_id && strlen(provider->org_id) > 0) {
|
||||
cons_show(" Org: %s", provider->org_id);
|
||||
}
|
||||
cons_show("");
|
||||
g_free(key);
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_force_encryption(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
|
||||
@@ -166,14 +166,6 @@ gboolean cmd_console(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_command_list(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_command_exec(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_change_password(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_switch(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_models(ProfWin* window, const char* const command, gchar** args);
|
||||
|
||||
gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args);
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
#define PREF_GROUP_MUC "muc"
|
||||
#define PREF_GROUP_PLUGINS "plugins"
|
||||
#define PREF_GROUP_EXECUTABLES "executables"
|
||||
#define PREF_GROUP_AI "ai"
|
||||
|
||||
#define INPBLOCK_DEFAULT 1000
|
||||
|
||||
@@ -1707,162 +1706,6 @@ _save_prefs(void)
|
||||
save_keyfile(&prefs_prof_keyfile);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI Token Management
|
||||
* ======================================================================== */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_token(const char* const provider, const char* const token)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
if (token) {
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
} else {
|
||||
return prefs_ai_remove_token(provider);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_token(const char* const provider)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_token(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_tokens(void)
|
||||
{
|
||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len;
|
||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
char* name = keys[i];
|
||||
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
|
||||
if (value) {
|
||||
result = g_list_append(result, g_strdup(name));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_tokens(GList* tokens)
|
||||
{
|
||||
if (tokens) {
|
||||
g_list_free_full(tokens, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
|
||||
{
|
||||
if (!provider || count <= 0)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
/* Build semicolon-separated model list */
|
||||
GString* model_str = g_string_new("");
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i > 0) {
|
||||
g_string_append_c(model_str, ';');
|
||||
}
|
||||
g_string_append(model_str, models[i]);
|
||||
}
|
||||
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
|
||||
g_string_free(model_str, TRUE);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_models(const char* const provider)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_get_models(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
||||
if (!model_str || strlen(model_str) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gchar** models = g_strsplit(model_str, ";", -1);
|
||||
for (int i = 0; models[i] != NULL; i++) {
|
||||
result = g_list_append(result, g_strdup(models[i]));
|
||||
}
|
||||
g_strfreev(models);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_models(GList* models)
|
||||
{
|
||||
if (models) {
|
||||
g_list_free_full(models, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
// get the preference group for a specific preference
|
||||
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
|
||||
// to the [ui] section.
|
||||
@@ -2022,9 +1865,6 @@ _get_group(preference_t pref)
|
||||
return PREF_GROUP_OMEMO;
|
||||
case PREF_OX_LOG:
|
||||
return PREF_GROUP_OX;
|
||||
case PREF_AI_PROVIDER:
|
||||
case PREF_AI_API_KEY:
|
||||
return PREF_GROUP_AI;
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
@@ -2302,10 +2142,6 @@ _get_key(preference_t pref)
|
||||
return "stamp.incoming";
|
||||
case PREF_OX_LOG:
|
||||
return "log";
|
||||
case PREF_AI_PROVIDER:
|
||||
return "provider";
|
||||
case PREF_AI_API_KEY:
|
||||
return "api_key";
|
||||
case PREF_MOOD:
|
||||
return "mood";
|
||||
case PREF_VCARD_PHOTO_CMD:
|
||||
@@ -2483,10 +2319,6 @@ _get_default_string(preference_t pref)
|
||||
return "on";
|
||||
case PREF_FORCE_ENCRYPTION_MODE:
|
||||
return "resend-to-confirm";
|
||||
case PREF_AI_PROVIDER:
|
||||
return "openai";
|
||||
case PREF_AI_API_KEY:
|
||||
return "";
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -183,9 +183,6 @@ typedef enum {
|
||||
PREF_OX_LOG,
|
||||
PREF_MOOD,
|
||||
PREF_STROPHE_VERBOSITY,
|
||||
PREF_AI_PROVIDER,
|
||||
PREF_AI_API_KEY,
|
||||
PREF_AI_DEFAULT_MODEL,
|
||||
PREF_STROPHE_SM_ENABLED,
|
||||
PREF_STROPHE_SM_RESEND,
|
||||
PREF_VCARD_PHOTO_CMD,
|
||||
@@ -359,17 +356,4 @@ gboolean prefs_get_room_notify(const char* const roomjid);
|
||||
gboolean prefs_get_room_notify_mention(const char* const roomjid);
|
||||
gboolean prefs_get_room_notify_trigger(const char* const roomjid);
|
||||
|
||||
/* AI token management */
|
||||
gboolean prefs_ai_set_token(const char* const provider, const char* const token);
|
||||
gboolean prefs_ai_remove_token(const char* const provider);
|
||||
char* prefs_ai_get_token(const char* const provider);
|
||||
GList* prefs_ai_list_tokens(void);
|
||||
void prefs_free_ai_tokens(GList* tokens);
|
||||
|
||||
/* AI model cache management */
|
||||
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
|
||||
gboolean prefs_ai_remove_models(const char* const provider);
|
||||
GList* prefs_ai_get_models(const char* const provider);
|
||||
void prefs_free_ai_models(GList* models);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -93,7 +93,6 @@ void db_sqlite_begin_transaction(void);
|
||||
void db_sqlite_end_transaction(void);
|
||||
void db_sqlite_rollback_transaction(void);
|
||||
int db_sqlite_last_changes(void);
|
||||
gboolean db_sqlite_is_open(void);
|
||||
#endif
|
||||
db_backend_t* db_backend_flatfile(void);
|
||||
|
||||
|
||||
@@ -30,12 +30,10 @@
|
||||
#include "log.h"
|
||||
#include "common.h"
|
||||
#include "config/files.h"
|
||||
#include "config/accounts.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/ui.h"
|
||||
#include "xmpp/session.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/message.h"
|
||||
|
||||
@@ -48,63 +46,23 @@
|
||||
// Print progress to console every N messages during export/import
|
||||
#define EXPORT_PROGRESS_INTERVAL 500
|
||||
|
||||
// Open SQLite if it isn't already (e.g. when flatfile is the active backend
|
||||
// and sqlite was never initialised this session, or was closed by /history
|
||||
// switch). Returns 1 if we opened it (caller must close), 0 if it was already
|
||||
// open, -1 on failure.
|
||||
static int
|
||||
_ensure_sqlite_open(void)
|
||||
{
|
||||
if (db_sqlite_is_open())
|
||||
return 0;
|
||||
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (!sqlite_be || !sqlite_be->init)
|
||||
return -1;
|
||||
|
||||
const char* account_name = session_get_account_name();
|
||||
if (!account_name)
|
||||
return -1;
|
||||
|
||||
ProfAccount* account = accounts_get_account(account_name);
|
||||
if (!account)
|
||||
return -1;
|
||||
|
||||
gboolean ok = sqlite_be->init(account);
|
||||
account_free(account);
|
||||
return ok ? 1 : -1;
|
||||
}
|
||||
|
||||
static void
|
||||
_close_temp_sqlite(int opened)
|
||||
{
|
||||
if (opened != 1)
|
||||
return;
|
||||
db_backend_t* sqlite_be = db_backend_sqlite();
|
||||
if (sqlite_be && sqlite_be->close)
|
||||
sqlite_be->close();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dedup key: stanza_id if present, else hash of timestamp+from+body
|
||||
// =========================================================================
|
||||
|
||||
// Returns a g_strdup'd key — callers free with g_free (gchar* convention).
|
||||
//
|
||||
// stanza_id is mixed into the hash but is NOT used as the sole key, since
|
||||
// some clients (older Pidgin, Adium) reuse incremental ids per session and
|
||||
// servers echo them back unchanged. Trusting stanza_id alone silently drops
|
||||
// legitimate distinct messages on collision. By hashing id + timestamp +
|
||||
// from_jid + body together we preserve id as a disambiguator without making
|
||||
// it a single point of failure: true exact duplicates still dedup, but two
|
||||
// messages that share an id with different content (or different timestamps)
|
||||
// stay distinct.
|
||||
static gchar*
|
||||
_make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_jid, const char* body)
|
||||
{
|
||||
if (stanza_id && stanza_id[0] != '\0')
|
||||
return g_strdup(stanza_id);
|
||||
|
||||
// Fallback for messages without stanza-id: SHA-256 over the FULL body,
|
||||
// not the first 256 bytes. The previous prefix-only hash collided on
|
||||
// legitimate near-identical templates (signatures, code blocks, paste-
|
||||
// bombs) — second occurrence was silently dropped on import. Hashing
|
||||
// whole body is microseconds of extra work per row.
|
||||
GChecksum* sum = g_checksum_new(G_CHECKSUM_SHA256);
|
||||
g_checksum_update(sum, (const guchar*)(stanza_id ? stanza_id : ""), -1);
|
||||
g_checksum_update(sum, (const guchar*)"|", 1);
|
||||
g_checksum_update(sum, (const guchar*)(timestamp ? timestamp : ""), -1);
|
||||
g_checksum_update(sum, (const guchar*)"|", 1);
|
||||
g_checksum_update(sum, (const guchar*)(from_jid ? from_jid : ""), -1);
|
||||
@@ -471,15 +429,6 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
return -1;
|
||||
}
|
||||
|
||||
// SQLite must be open for db_sqlite_list_contacts/db_sqlite_get_all_chat
|
||||
// to return rows. When the flatfile backend is active, sqlite is closed —
|
||||
// queries silently return NULL and the export reports zero changes.
|
||||
int sqlite_opened = _ensure_sqlite_open();
|
||||
if (sqlite_opened < 0) {
|
||||
cons_show_error("Export failed: could not open SQLite database.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contact list
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
@@ -488,7 +437,6 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
contacts = db_sqlite_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No contacts found in SQLite database.");
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return 0;
|
||||
}
|
||||
cons_show("Exporting %d contact(s) to flat-file format...", g_slist_length(contacts));
|
||||
@@ -504,7 +452,6 @@ log_database_export_to_flatfile(const gchar* const contact_jid)
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return total_exported;
|
||||
}
|
||||
|
||||
@@ -531,12 +478,6 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
return -1;
|
||||
}
|
||||
|
||||
int sqlite_opened = _ensure_sqlite_open();
|
||||
if (sqlite_opened < 0) {
|
||||
cons_show_error("Import failed: could not open SQLite database.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Determine contacts
|
||||
GSList* contacts = NULL;
|
||||
if (contact_jid) {
|
||||
@@ -545,7 +486,6 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
contacts = _ff_list_contacts();
|
||||
if (!contacts) {
|
||||
cons_show("No flat-file history found to import.");
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -673,7 +613,6 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
|
||||
}
|
||||
|
||||
g_slist_free_full(contacts, g_free);
|
||||
_close_temp_sqlite(sqlite_opened);
|
||||
return total_imported;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ ff_state_new(const char* filepath)
|
||||
{
|
||||
ff_contact_state_t* state = g_malloc0(sizeof(ff_contact_state_t));
|
||||
state->filepath = g_strdup(filepath);
|
||||
state->cursor_offset = -1;
|
||||
state->archive_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
state->stanza_senders = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
return state;
|
||||
@@ -144,7 +145,7 @@ _ff_maybe_index_line(ff_contact_state_t* state, const char* buf, off_t pos)
|
||||
char* ts_str = g_strndup(buf, space - buf);
|
||||
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
|
||||
if (!dt) {
|
||||
log_warning("flatfile: unparsable timestamp in %s at offset %ld: %s",
|
||||
log_warning("flatfile: unparseable timestamp in %s at offset %ld: %s",
|
||||
state->filepath, (long)pos, ts_str);
|
||||
g_free(ts_str);
|
||||
return;
|
||||
@@ -205,15 +206,6 @@ _ff_state_build(ff_contact_state_t* state)
|
||||
|
||||
state->bom_len = ff_skip_bom(fp);
|
||||
|
||||
int file_version = ff_read_format_version(fp);
|
||||
if (file_version == 0) {
|
||||
log_warning("flatfile: %s has no format-version marker, expected %d",
|
||||
state->filepath, FLATFILE_FORMAT_VERSION);
|
||||
} else if (file_version != FLATFILE_FORMAT_VERSION) {
|
||||
log_warning("flatfile: %s format-version %d does not match expected %d",
|
||||
state->filepath, file_version, FLATFILE_FORMAT_VERSION);
|
||||
}
|
||||
|
||||
state->n_entries = 0;
|
||||
state->total_lines = 0;
|
||||
|
||||
@@ -302,6 +294,7 @@ ff_state_ensure_fresh(ff_contact_state_t* state)
|
||||
if (st.st_ino == state->stamp.inode && st.st_size > state->stamp.size)
|
||||
return _ff_state_extend(state);
|
||||
|
||||
state->cursor_offset = -1;
|
||||
return _ff_state_build(state);
|
||||
}
|
||||
|
||||
@@ -726,28 +719,20 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
|
||||
return DB_RESPONSE_EMPTY;
|
||||
if (state->total_lines == 0)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
// n_entries can be 0 even when total_lines > 0 if every line in the
|
||||
// file failed _ff_maybe_index_line (e.g. unparsable timestamps).
|
||||
// Subsequent backup logic dereferences entries[0] unconditionally,
|
||||
// which would segfault on a NULL entries array.
|
||||
if (state->n_entries == 0)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
// Reset cursor for filtered (non-Page-Up) queries
|
||||
if (start_time)
|
||||
state->cursor_offset = -1;
|
||||
|
||||
// Determine read byte-range using the sparse index
|
||||
off_t read_from = state->bom_len;
|
||||
off_t read_to = state->stamp.size;
|
||||
|
||||
// Upper-bound via bisect on end_time. The previous cursor-based
|
||||
// shortcut (use last batch's oldest file_offset as read_to) was
|
||||
// both buggy and unnecessary: cursor was set to the oldest line in
|
||||
// the read window, but pagination dropped the older portion of that
|
||||
// window before returning to the caller. The next page-up therefore
|
||||
// started reading from a point much earlier than what the user had
|
||||
// actually seen, "jumping" past entire ranges of messages. Bisect
|
||||
// on the index is O(log n_entries) — for typical 200-entry indexes
|
||||
// it's a handful of comparisons, well below the per-line parse
|
||||
// cost — so we now run it unconditionally.
|
||||
if (end_time) {
|
||||
// Use stored cursor for sequential Page Up (skip bisect)
|
||||
if (!start_time && end_time && state->cursor_offset >= 0) {
|
||||
read_to = state->cursor_offset;
|
||||
} else if (end_time) {
|
||||
// Upper-bound: find first index entry AFTER end_time
|
||||
GDateTime* edt = g_date_time_new_from_iso8601(end_time, NULL);
|
||||
if (edt) {
|
||||
gint64 end_epoch = g_date_time_to_unix(edt);
|
||||
@@ -760,8 +745,8 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
// lo = first entry with timestamp > end_epoch.
|
||||
// Use next entry's offset as read_to (+ 1 entry margin).
|
||||
// lo = first entry with timestamp > end_epoch
|
||||
// Use next entry's offset as read_to (+ 1 entry margin)
|
||||
if (lo + 1 < state->n_entries)
|
||||
read_to = state->entries[lo + 1].byte_offset;
|
||||
// else read_to stays at EOF
|
||||
@@ -792,15 +777,6 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
|
||||
read_from = backed;
|
||||
}
|
||||
|
||||
// Defensive: an empty or inverted range means there is nothing to read.
|
||||
// Reachable when start_time bisects past end_time, or when an end_time
|
||||
// upper-bound shrinks read_to below the BOM-adjusted read_from.
|
||||
if (read_from >= read_to) {
|
||||
log_debug("flatfile: empty range [%ld, %ld) for %s",
|
||||
(long)read_from, (long)read_to, contact_barejid);
|
||||
return DB_RESPONSE_EMPTY;
|
||||
}
|
||||
|
||||
// Open file and read lines in [read_from, read_to)
|
||||
FILE* fp = fopen(state->filepath, "r");
|
||||
if (!fp)
|
||||
@@ -873,6 +849,10 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
|
||||
if (!all_parsed)
|
||||
return DB_RESPONSE_EMPTY;
|
||||
|
||||
// Update cursor: byte offset of oldest parsed message in this batch
|
||||
ff_parsed_line_t* oldest = all_parsed->data;
|
||||
state->cursor_offset = oldest->file_offset;
|
||||
|
||||
// Apply LMC corrections (if enabled) and build ProfMessage list
|
||||
GSList* pre_result = NULL;
|
||||
if (prefs_get_boolean(PREF_CORRECTION_ALLOW)) {
|
||||
|
||||
@@ -24,14 +24,10 @@
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define FLATFILE_FORMAT_VERSION 1
|
||||
#define FF_VERSION_MARKER "format-version: "
|
||||
#define FF_STRINGIFY_(x) #x
|
||||
#define FF_STRINGIFY(x) FF_STRINGIFY_(x)
|
||||
#define FLATFILE_HEADER "# cproof chat log — UTF-8, LF line endings, " FF_VERSION_MARKER FF_STRINGIFY(FLATFILE_FORMAT_VERSION) "\n"
|
||||
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
|
||||
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
|
||||
#define DIR_FLATLOG "flatlog"
|
||||
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
|
||||
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
|
||||
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
|
||||
|
||||
// --- Shared global ---
|
||||
|
||||
@@ -68,8 +64,8 @@ typedef struct
|
||||
|
||||
// --- Metadata field prefixes (used in _ff_cache_line_ids and ff_parse_line) ---
|
||||
|
||||
#define FF_META_PREFIX_ID "id:"
|
||||
#define FF_META_PREFIX_AID "aid:"
|
||||
#define FF_META_PREFIX_ID "id:"
|
||||
#define FF_META_PREFIX_AID "aid:"
|
||||
|
||||
typedef struct
|
||||
{
|
||||
@@ -92,6 +88,7 @@ typedef struct
|
||||
size_t cap_entries;
|
||||
size_t total_lines;
|
||||
ff_file_stamp_t stamp;
|
||||
off_t cursor_offset;
|
||||
int bom_len;
|
||||
GHashTable* archive_ids; // set: archive_id -> NULL (MAM dedup, O(1))
|
||||
GHashTable* stanza_senders; // map: stanza_id -> from_jid (LMC validation, O(1))
|
||||
@@ -129,11 +126,6 @@ char* ff_unescape_meta_value(const char* val);
|
||||
// Skip UTF-8 BOM if present; returns 3 if skipped, 0 otherwise.
|
||||
int ff_skip_bom(FILE* fp);
|
||||
|
||||
// Scan leading '#' comment lines for the format-version marker. Returns
|
||||
// the version found, 0 if no marker is present, or -1 on read error.
|
||||
// File position is rewound to the start of the comment block on entry.
|
||||
int ff_read_format_version(FILE* fp);
|
||||
|
||||
char* ff_readline(FILE* fp, gboolean* truncated);
|
||||
void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc,
|
||||
const char* stanza_id, const char* archive_id, const char* replace_id,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#include <sys/stat.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -339,52 +338,6 @@ ff_skip_bom(FILE* fp)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Format-version helper
|
||||
// =========================================================================
|
||||
//
|
||||
// Scan leading '#' comment lines for the `format-version: <N>` marker
|
||||
// (anywhere within the comment line). Stops at the first non-comment
|
||||
// line or after FF_VERSION_SCAN_MAX comments. Restores fp to its
|
||||
// position at entry, so callers can re-parse the header normally
|
||||
// afterwards.
|
||||
#define FF_VERSION_SCAN_MAX 16
|
||||
|
||||
int
|
||||
ff_read_format_version(FILE* fp)
|
||||
{
|
||||
long start = ftell(fp);
|
||||
if (start < 0)
|
||||
return -1;
|
||||
|
||||
int version = 0;
|
||||
size_t marker_len = strlen(FF_VERSION_MARKER);
|
||||
|
||||
for (int i = 0; i < FF_VERSION_SCAN_MAX; i++) {
|
||||
gboolean truncated = FALSE;
|
||||
char* line = ff_readline(fp, &truncated);
|
||||
if (!line)
|
||||
break;
|
||||
if (line[0] != '#') {
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
char* found = strstr(line, FF_VERSION_MARKER);
|
||||
if (found) {
|
||||
char* num_start = found + marker_len;
|
||||
char* endptr = NULL;
|
||||
long v = strtol(num_start, &endptr, 10);
|
||||
if (endptr != num_start && v > 0 && v <= INT_MAX)
|
||||
version = (int)v;
|
||||
free(line);
|
||||
break;
|
||||
}
|
||||
free(line);
|
||||
}
|
||||
|
||||
fseek(fp, start, SEEK_SET);
|
||||
return version;
|
||||
}
|
||||
|
||||
// Readline helper
|
||||
// =========================================================================
|
||||
//
|
||||
@@ -468,13 +421,11 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
|
||||
if (safe_rid) {
|
||||
g_string_append_printf(meta, "|corrects:%s", safe_rid);
|
||||
}
|
||||
if (to_jid && strlen(to_jid) > 0
|
||||
&& g_strcmp0(to_jid, "(null)") != 0) {
|
||||
if (to_jid && strlen(to_jid) > 0) {
|
||||
auto_gchar gchar* safe_to = ff_escape_meta_value(to_jid);
|
||||
g_string_append_printf(meta, "|to:%s", safe_to);
|
||||
}
|
||||
if (to_resource && strlen(to_resource) > 0
|
||||
&& g_strcmp0(to_resource, "(null)") != 0) {
|
||||
if (to_resource && strlen(to_resource) > 0) {
|
||||
auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource);
|
||||
g_string_append_printf(meta, "|to_res:%s", safe_tores);
|
||||
}
|
||||
@@ -485,11 +436,8 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc
|
||||
|
||||
// Build sender — escape ": " in the resource part to prevent
|
||||
// the parser from splitting at the wrong point.
|
||||
const gboolean from_jid_usable = from_jid && *from_jid
|
||||
&& g_strcmp0(from_jid, "(null)") != 0;
|
||||
GString* sender = g_string_new(from_jid_usable ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 0
|
||||
&& g_strcmp0(from_resource, "(null)") != 0) {
|
||||
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
|
||||
if (from_resource && strlen(from_resource) > 0) {
|
||||
// Escape backslash and colon-space in resource
|
||||
GString* safe_res = g_string_sized_new(strlen(from_resource));
|
||||
for (const char* p = from_resource; *p; p++) {
|
||||
|
||||
@@ -77,8 +77,8 @@ _collect_contact_dirs(const gchar* const contact_barejid, GSList** issues)
|
||||
contact_dirs = g_slist_prepend(contact_dirs, g_strdup(cdir));
|
||||
} else {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
|
||||
g_strdup("No log files found for this contact")));
|
||||
_issue_new(INTEGRITY_INFO, contact_barejid, 0,
|
||||
g_strdup("No log files found for this contact")));
|
||||
}
|
||||
return contact_dirs;
|
||||
}
|
||||
@@ -117,9 +117,9 @@ _check_permissions(const char* filepath, const char* basename, GSList** issues)
|
||||
if ((st.st_mode & 0777) == (S_IRUSR | S_IWUSR))
|
||||
return;
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
|
||||
st.st_mode & 0777)));
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup_printf("File permissions are %o, expected 600 (sensitive data)",
|
||||
st.st_mode & 0777)));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -144,8 +144,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
{
|
||||
if (ff_skip_bom(fp)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File has UTF-8 BOM — harmless but unnecessary")));
|
||||
}
|
||||
|
||||
char* buf = NULL;
|
||||
@@ -172,8 +172,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
const gchar* end;
|
||||
if (!g_utf8_validate(buf, -1, &end)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf))));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
@@ -182,8 +182,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
unsigned char ch = (unsigned char)buf[i];
|
||||
if (ch < 0x20 && ch != '\t') {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Contains control character 0x%02x", ch)));
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Contains control character 0x%02x", ch)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -191,8 +191,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
if (!pl) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup("Unparsable line")));
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup("Unparsable line")));
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
@@ -202,8 +202,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
auto_gchar gchar* ts_cur = g_date_time_format_iso8601(pl->timestamp);
|
||||
auto_gchar gchar* ts_prev = g_date_time_format_iso8601(prev_ts);
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev)));
|
||||
}
|
||||
if (prev_ts)
|
||||
g_date_time_unref(prev_ts);
|
||||
@@ -212,8 +212,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
@@ -222,8 +222,8 @@ _first_pass(FILE* fp, const char* basename,
|
||||
if (pl->archive_id && pl->archive_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_archive_ids, pl->archive_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate archive-id \"%s\"", pl->archive_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_archive_ids, g_strdup(pl->archive_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
@@ -237,13 +237,13 @@ _first_pass(FILE* fp, const char* basename,
|
||||
|
||||
if (has_crlf) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
|
||||
_issue_new(INTEGRITY_WARNING, basename, 0,
|
||||
g_strdup("File uses Windows line endings (CRLF) — consider converting to LF")));
|
||||
}
|
||||
if (is_empty) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File is empty (no message lines)")));
|
||||
_issue_new(INTEGRITY_INFO, basename, 0,
|
||||
g_strdup("File is empty (no message lines)")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,9 +281,9 @@ _lmc_pass(FILE* fp, const char* basename, GHashTable* all_stanza_ids, GSList** i
|
||||
if (pl->replace_id && pl->replace_id[0] != '\0'
|
||||
&& !g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Broken correction reference: corrects:%s not found",
|
||||
pl->replace_id)));
|
||||
_issue_new(INTEGRITY_ERROR, basename, lineno,
|
||||
g_strdup_printf("Broken correction reference: corrects:%s not found",
|
||||
pl->replace_id)));
|
||||
}
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
@@ -344,8 +344,8 @@ ff_verify_integrity(const gchar* const contact_barejid)
|
||||
|
||||
if (!g_flatfile_account_jid) {
|
||||
issues = g_slist_prepend(issues,
|
||||
_issue_new(INTEGRITY_ERROR, "N/A", 0,
|
||||
g_strdup("Flat-file backend not initialized")));
|
||||
_issue_new(INTEGRITY_ERROR, "N/A", 0,
|
||||
g_strdup("Flat-file backend not initialized")));
|
||||
return issues;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,12 +65,6 @@ static gboolean _check_available_space_for_db_migration(char* path_to_db);
|
||||
|
||||
static const int latest_version = 2;
|
||||
|
||||
gboolean
|
||||
db_sqlite_is_open(void)
|
||||
{
|
||||
return g_chatlog_database != NULL;
|
||||
}
|
||||
|
||||
// Helper: close DB handle (if any), warn on busy, and shutdown SQLite
|
||||
static void
|
||||
_db_teardown(const char* ctx)
|
||||
|
||||
@@ -37,10 +37,8 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <assert.h>
|
||||
#include <glib.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "chatlog.h"
|
||||
#include "database.h"
|
||||
@@ -49,7 +47,7 @@
|
||||
#include "event/common.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/window.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/chat_session.h"
|
||||
#include "xmpp/session.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
@@ -286,30 +284,3 @@ allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg)
|
||||
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id)
|
||||
{
|
||||
if (!aiwin || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
log_error("[AI] AI session not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Reset paged flag before printing user message.
|
||||
* If the user scrolled up to view history, paged=1 would suppress
|
||||
* the message in _win_printf(). Reset it here so the message displays. */
|
||||
aiwin->window.layout->paged = 0;
|
||||
aiwin->window.layout->unread_msg = 0;
|
||||
|
||||
// Display user message in AI window.
|
||||
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);
|
||||
|
||||
// Send to AI provider.
|
||||
ai_send_prompt(aiwin->session, message, aiwin);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ void cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* con
|
||||
void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg);
|
||||
void cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id);
|
||||
|
||||
// Checks if an unencrypted message can be sent based on encryption preferences.
|
||||
// Returns TRUE if allowed, FALSE if blocked.
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/muc.h"
|
||||
#include "xmpp/chat_session.h"
|
||||
#include "ai/ai_client.h"
|
||||
#include "xmpp/chat_state.h"
|
||||
#include "xmpp/contact.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
@@ -250,7 +249,6 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name)
|
||||
}
|
||||
session_init();
|
||||
cmd_init();
|
||||
ai_client_init();
|
||||
log_info("Initialising contact list");
|
||||
muc_init();
|
||||
tlscerts_init();
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* aiwin.c - AI Chat Window Management
|
||||
*
|
||||
* Provides functions for managing the AI chat window (ProfAiWin) in the
|
||||
* profanity client. This includes retrieving window strings for identification,
|
||||
* displaying AI/LLM responses in the chat view, and handling error messages
|
||||
* from AI interactions.
|
||||
*
|
||||
* Key functions:
|
||||
* aiwin_get_string() - Returns a descriptive string for the window
|
||||
* aiwin_display_response() - Displays an AI/LLM response and appends it
|
||||
* to the conversation history
|
||||
* aiwin_display_error() - Displays an error message from AI operations
|
||||
*
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/win_types.h"
|
||||
|
||||
char*
|
||||
aiwin_get_string(ProfAiWin* win)
|
||||
{
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return g_strdup_printf("AI Chat (%s: %s)", win->session->provider_name, win->session->model);
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_response(ProfAiWin* win, const char* response)
|
||||
{
|
||||
log_debug("[AI-WIN] aiwin_display_response ENTER: win=%p, response='%s'", (void*)win, response);
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!response) {
|
||||
log_error("[AI-WIN] FAIL: null response");
|
||||
return;
|
||||
}
|
||||
|
||||
// Display AI response
|
||||
win_println(&win->window, THEME_DEFAULT, "<< LLM:", "%s", response);
|
||||
log_debug("[AI-WIN] Displayed AI response");
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_error(ProfAiWin* win, const char* error_msg)
|
||||
{
|
||||
log_debug("[AI-WIN] aiwin_display_error ENTER: win=%p, error='%s'", (void*)win, error_msg);
|
||||
assert(win->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
const char* msg = error_msg ? error_msg : "Unknown error";
|
||||
win_println(&win->window, THEME_ERROR, "[ERROR]", "%s", msg);
|
||||
|
||||
// Also show error in console so user sees it regardless of active window
|
||||
cons_show_error("AI Error: %s", msg);
|
||||
}
|
||||
@@ -52,7 +52,6 @@
|
||||
|
||||
#include "config/theme.h"
|
||||
#include "config/preferences.h"
|
||||
#include "database.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/statusbar.h"
|
||||
#include "ui/inputwin.h"
|
||||
@@ -84,7 +83,6 @@ static WINDOW* statusbar_win;
|
||||
void _get_range_bounds(int* start, int* end, gboolean is_static);
|
||||
static int _status_bar_draw_time(int pos);
|
||||
static int _status_bar_draw_maintext(int pos);
|
||||
static int _status_bar_draw_dbbackend(int pos);
|
||||
static int _status_bar_draw_bracket(gboolean current, int pos, const char* ch);
|
||||
static int _status_bar_draw_extended_tabs(int pos, gboolean prefix, int start, int end, gboolean is_static);
|
||||
static int _status_bar_draw_tab(StatusBarTab* tab, int pos, int num, gboolean include_brackets);
|
||||
@@ -315,7 +313,6 @@ status_bar_draw(void)
|
||||
|
||||
pos = _status_bar_draw_time(pos);
|
||||
pos = _status_bar_draw_maintext(pos);
|
||||
pos = _status_bar_draw_dbbackend(pos);
|
||||
if (max_tabs != 0)
|
||||
pos = _status_bar_draw_tabs(pos);
|
||||
|
||||
@@ -606,17 +603,6 @@ _status_bar_draw_maintext(int pos)
|
||||
return pos;
|
||||
}
|
||||
|
||||
static int
|
||||
_status_bar_draw_dbbackend(int pos)
|
||||
{
|
||||
if (!active_db_backend || !active_db_backend->name)
|
||||
return pos;
|
||||
|
||||
auto_gchar gchar* label = g_strdup_printf(" [%s]", active_db_backend->name);
|
||||
mvwprintw(statusbar_win, 0, pos, "%s", label);
|
||||
return pos + utf8_display_len(label);
|
||||
}
|
||||
|
||||
static void
|
||||
_destroy_tab(StatusBarTab* tab)
|
||||
{
|
||||
|
||||
@@ -386,8 +386,6 @@ ProfWin* win_create_config(const char* const title, DataForm* form, ProfConfWinC
|
||||
ProfWin* win_create_private(const char* const fulljid);
|
||||
ProfWin* win_create_plugin(const char* const plugin_name, const char* const tag);
|
||||
ProfWin* win_create_vcard(vCard* vcard);
|
||||
ProfWin* win_create_ai(AISession* session);
|
||||
ProfWin* wins_new_ai(AISession* session);
|
||||
void win_update_virtual(ProfWin* window);
|
||||
void win_free(ProfWin* window);
|
||||
gboolean win_notify_remind(ProfWin* window);
|
||||
@@ -447,9 +445,4 @@ void notify_invite(const char* const from, const char* const room, const char* c
|
||||
void notify(const char* const message, int timeout, const char* const category);
|
||||
void notify_subscription(const char* const from);
|
||||
|
||||
/* AI window */
|
||||
char* aiwin_get_string(ProfAiWin* win);
|
||||
void aiwin_display_response(ProfAiWin* win, const char* response);
|
||||
void aiwin_display_error(ProfAiWin* win, const char* error_msg);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
#define PROFXMLWIN_MEMCHECK 87333463
|
||||
#define PROFPLUGINWIN_MEMCHECK 43434777
|
||||
#define PROFVCARDWIN_MEMCHECK 68947523
|
||||
#define PROFAIWIN_MEMCHECK 91827364
|
||||
|
||||
typedef enum {
|
||||
FIELD_HIDDEN,
|
||||
@@ -145,8 +144,7 @@ typedef enum {
|
||||
WIN_PRIVATE,
|
||||
WIN_XML,
|
||||
WIN_PLUGIN,
|
||||
WIN_VCARD,
|
||||
WIN_AI
|
||||
WIN_VCARD
|
||||
} win_type_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -260,16 +258,4 @@ typedef struct prof_vcard_win_t
|
||||
unsigned long memcheck;
|
||||
} ProfVcardWin;
|
||||
|
||||
/* Forward declaration */
|
||||
typedef struct ai_session_t AISession;
|
||||
|
||||
typedef struct prof_ai_win_t
|
||||
{
|
||||
ProfWin window;
|
||||
ProfBuff message_buffer;
|
||||
GString* conversation_history;
|
||||
AISession* session;
|
||||
unsigned long memcheck;
|
||||
} ProfAiWin;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "ui/win_types.h"
|
||||
#include "ui/window_list.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
@@ -63,7 +62,6 @@
|
||||
#include "ui/screen.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
static const char* LOADING_MESSAGE = "Loading older messages…";
|
||||
static const char* CONS_WIN_TITLE = "CProof. Type /help for help information.";
|
||||
@@ -331,22 +329,6 @@ win_create_vcard(vCard* vcard)
|
||||
return &new_win->window;
|
||||
}
|
||||
|
||||
ProfWin*
|
||||
win_create_ai(AISession* session)
|
||||
{
|
||||
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
|
||||
new_win->window.type = WIN_AI;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->message_buffer = buffer_create();
|
||||
new_win->conversation_history = g_string_new("");
|
||||
new_win->session = session ? ai_session_ref(session) : NULL;
|
||||
new_win->memcheck = PROFAIWIN_MEMCHECK;
|
||||
|
||||
return &new_win->window;
|
||||
}
|
||||
|
||||
gchar*
|
||||
win_get_title(ProfWin* window)
|
||||
{
|
||||
@@ -425,13 +407,6 @@ win_get_title(ProfWin* window)
|
||||
|
||||
return g_string_free(title, FALSE);
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
const ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
return g_strdup_printf("AI Assistant (%s: %s)", aiwin->session->provider_name, aiwin->session->model);
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -479,10 +454,6 @@ win_get_tab_identifier(ProfWin* window)
|
||||
{
|
||||
return strdup("vcard");
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
return strdup("ai");
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -565,12 +536,6 @@ win_to_string(ProfWin* window)
|
||||
ProfVcardWin* vcardwin = (ProfVcardWin*)window;
|
||||
return vcardwin_get_string(vcardwin);
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin_get_string(aiwin);
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -697,17 +662,6 @@ win_free(ProfWin* window)
|
||||
free(pluginwin->plugin_name);
|
||||
break;
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
buffer_free(aiwin->message_buffer);
|
||||
g_string_free(aiwin->conversation_history, TRUE);
|
||||
if (aiwin->session) {
|
||||
ai_session_unref(aiwin->session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,9 @@
|
||||
|
||||
#include "common.h"
|
||||
#include "config/preferences.h"
|
||||
#include "log.h"
|
||||
#include "config/theme.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/roster_list.h"
|
||||
@@ -376,8 +374,6 @@ wins_set_current_by_num(int i)
|
||||
status_bar_active(1, conswin->type, "console");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log_error("Window not found for index %d", i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,20 +714,6 @@ wins_new_vcard(vCard* vcard)
|
||||
return newwin;
|
||||
}
|
||||
|
||||
ProfWin*
|
||||
wins_new_ai(AISession* session)
|
||||
{
|
||||
int result = _wins_get_next_available_num(keys);
|
||||
ProfWin* newwin = win_create_ai(session);
|
||||
if (!newwin) {
|
||||
return NULL;
|
||||
}
|
||||
_wins_htable_insert(windows, GINT_TO_POINTER(result), newwin);
|
||||
autocomplete_add(wins_ac, "ai");
|
||||
autocomplete_add(wins_close_ac, "ai");
|
||||
return newwin;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_do_notify_remind(void)
|
||||
{
|
||||
@@ -859,7 +841,7 @@ wins_get_prune_wins(void)
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE && window->type != WIN_AI) {
|
||||
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE) {
|
||||
result = g_slist_append(result, window);
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
@@ -867,45 +849,6 @@ wins_get_prune_wins(void)
|
||||
return result;
|
||||
}
|
||||
|
||||
ProfAiWin*
|
||||
wins_get_ai(void)
|
||||
{
|
||||
GList* curr = values;
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin;
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_ai_exists(ProfAiWin* const aiwin)
|
||||
{
|
||||
if (!aiwin) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
GList* curr = values;
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
if ((ProfAiWin*)window == aiwin) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
wins_lost_connection(void)
|
||||
{
|
||||
|
||||
@@ -63,8 +63,6 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
|
||||
ProfPluginWin* wins_get_plugin(const char* const tag);
|
||||
ProfXMLWin* wins_get_xmlconsole(void);
|
||||
ProfVcardWin* wins_get_vcard(void);
|
||||
ProfAiWin* wins_get_ai(void);
|
||||
gboolean wins_ai_exists(ProfAiWin* const aiwin);
|
||||
|
||||
void wins_close_plugin(char* tag);
|
||||
|
||||
|
||||
@@ -111,17 +111,6 @@ jid_create(const gchar* const str)
|
||||
Jid*
|
||||
jid_create_from_bare_and_resource(const char* const barejid, const char* const resource)
|
||||
{
|
||||
// NULL, empty, or the literal "(null)" (legacy artefact from earlier
|
||||
// builds where g_strdup_printf("%s", NULL) leaked the glibc placeholder
|
||||
// into stored data) all mean "no value" for either part. With no usable
|
||||
// bare jid there is nothing to construct; with no usable resource we
|
||||
// return the bare jid alone.
|
||||
if (!barejid || !*barejid || g_strcmp0(barejid, "(null)") == 0) {
|
||||
return NULL;
|
||||
}
|
||||
if (!resource || !*resource || g_strcmp0(resource, "(null)") == 0) {
|
||||
return jid_create(barejid);
|
||||
}
|
||||
auto_char char* jid = create_fulljid(barejid, resource);
|
||||
return jid_create(jid);
|
||||
}
|
||||
|
||||
@@ -70,12 +70,9 @@ const char*
|
||||
bench_volume_name(bench_volume_t v)
|
||||
{
|
||||
switch (v) {
|
||||
case BENCH_VOLUME_SMALL:
|
||||
return "small";
|
||||
case BENCH_VOLUME_MEDIUM:
|
||||
return "medium";
|
||||
case BENCH_VOLUME_MAX:
|
||||
return "max";
|
||||
case BENCH_VOLUME_SMALL: return "small";
|
||||
case BENCH_VOLUME_MEDIUM: return "medium";
|
||||
case BENCH_VOLUME_MAX: return "max";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ char* bench_fmt_bytes(uint64_t bytes);
|
||||
char* bench_fmt_ms(double ms);
|
||||
|
||||
// Resolve BENCH_VOLUME env: "small" / "medium" / "max" / unset.
|
||||
typedef enum {
|
||||
typedef enum
|
||||
{
|
||||
BENCH_VOLUME_SMALL, // 10k lines / 1 contact / 1 year
|
||||
BENCH_VOLUME_MEDIUM, // 500k lines / 1 contact / 5 years
|
||||
BENCH_VOLUME_MAX, // 5M lines / 1 contact / 10 years
|
||||
|
||||
@@ -105,25 +105,26 @@ db_path_for(const char* account_jid)
|
||||
// Schema (lifted from database_sqlite.c so the seeder can populate without
|
||||
// going through _sqlite_init's full machinery)
|
||||
|
||||
static const char* SCHEMA_DDL = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, `to_resource` TEXT, "
|
||||
"`message` TEXT, `timestamp` TEXT, `type` TEXT, "
|
||||
"`stanza_id` TEXT, `archive_id` TEXT, "
|
||||
"`encryption` TEXT, `marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, `replaced_by_db_id` INTEGER);"
|
||||
"CREATE TABLE IF NOT EXISTS `DbVersion` ("
|
||||
"`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE);"
|
||||
"INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2');"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`);"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`);"
|
||||
"CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs FOR EACH ROW WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN UPDATE ChatLogs SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; END;";
|
||||
static const char* SCHEMA_DDL =
|
||||
"CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, `to_resource` TEXT, "
|
||||
"`message` TEXT, `timestamp` TEXT, `type` TEXT, "
|
||||
"`stanza_id` TEXT, `archive_id` TEXT, "
|
||||
"`encryption` TEXT, `marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, `replaced_by_db_id` INTEGER);"
|
||||
"CREATE TABLE IF NOT EXISTS `DbVersion` ("
|
||||
"`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE);"
|
||||
"INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2');"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`);"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`);"
|
||||
"CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs FOR EACH ROW WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN UPDATE ChatLogs SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; END;";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed: open sqlite3 directly, build schema, batched-INSERT N rows.
|
||||
@@ -159,15 +160,14 @@ do_seed(const seed_opts_t* o)
|
||||
|
||||
if (sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, &err) != SQLITE_OK) {
|
||||
fprintf(stderr, "seed: BEGIN failed: %s\n", err);
|
||||
sqlite3_free(err);
|
||||
sqlite3_close(db);
|
||||
return 2;
|
||||
sqlite3_free(err); sqlite3_close(db); return 2;
|
||||
}
|
||||
|
||||
const char* INSERT_SQL = "INSERT INTO ChatLogs ("
|
||||
"from_jid, to_jid, from_resource, to_resource, message, timestamp, type, "
|
||||
"stanza_id, archive_id, encryption, marked_read, replace_id"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, 'chat', ?, ?, 'none', -1, ?);";
|
||||
const char* INSERT_SQL =
|
||||
"INSERT INTO ChatLogs ("
|
||||
"from_jid, to_jid, from_resource, to_resource, message, timestamp, type, "
|
||||
"stanza_id, archive_id, encryption, marked_read, replace_id"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, 'chat', ?, ?, 'none', -1, ?);";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(db, INSERT_SQL, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
fprintf(stderr, "seed: prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
@@ -180,8 +180,7 @@ do_seed(const seed_opts_t* o)
|
||||
int64_t span_secs = (int64_t)5 * 365 * 24 * 3600; // 5 years
|
||||
char* prev_sid_per_contact[1024] = { 0 };
|
||||
int n_contacts = o->contacts > 0 ? o->contacts : 1;
|
||||
if (n_contacts > 1024)
|
||||
n_contacts = 1024;
|
||||
if (n_contacts > 1024) n_contacts = 1024;
|
||||
|
||||
double t0 = bench_now_ms();
|
||||
|
||||
@@ -210,14 +209,10 @@ do_seed(const seed_opts_t* o)
|
||||
sqlite3_bind_text(stmt, 5, body, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, iso, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 7, sid, -1, SQLITE_TRANSIENT);
|
||||
if (aid)
|
||||
sqlite3_bind_text(stmt, 8, aid, -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
if (is_lmc)
|
||||
sqlite3_bind_text(stmt, 9, prev_sid_per_contact[ci], -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(stmt, 9);
|
||||
if (aid) sqlite3_bind_text(stmt, 8, aid, -1, SQLITE_TRANSIENT);
|
||||
else sqlite3_bind_null(stmt, 8);
|
||||
if (is_lmc) sqlite3_bind_text(stmt, 9, prev_sid_per_contact[ci], -1, SQLITE_TRANSIENT);
|
||||
else sqlite3_bind_null(stmt, 9);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||
fprintf(stderr, "seed: insert failed at row %" PRId64 ": %s\n",
|
||||
@@ -314,29 +309,21 @@ db_row_count(const char* db_path)
|
||||
static int64_t
|
||||
db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t* total_b)
|
||||
{
|
||||
sqlite3* da = NULL;
|
||||
sqlite3* db = NULL;
|
||||
if (sqlite3_open(a_path, &da) != SQLITE_OK)
|
||||
return -1;
|
||||
if (sqlite3_open(b_path, &db) != SQLITE_OK) {
|
||||
sqlite3_close(da);
|
||||
return -1;
|
||||
}
|
||||
sqlite3* da = NULL; sqlite3* db = NULL;
|
||||
if (sqlite3_open(a_path, &da) != SQLITE_OK) return -1;
|
||||
if (sqlite3_open(b_path, &db) != SQLITE_OK) { sqlite3_close(da); return -1; }
|
||||
|
||||
const char* SQL = "SELECT IFNULL(from_jid,''), IFNULL(to_jid,''), IFNULL(message,''), "
|
||||
"IFNULL(timestamp,''), IFNULL(type,''), IFNULL(stanza_id,''), "
|
||||
"IFNULL(archive_id,''), IFNULL(encryption,''), IFNULL(replace_id,'') "
|
||||
"FROM ChatLogs ORDER BY timestamp ASC, stanza_id ASC;";
|
||||
sqlite3_stmt* sa = NULL;
|
||||
sqlite3_stmt* sb = NULL;
|
||||
const char* SQL =
|
||||
"SELECT IFNULL(from_jid,''), IFNULL(to_jid,''), IFNULL(message,''), "
|
||||
"IFNULL(timestamp,''), IFNULL(type,''), IFNULL(stanza_id,''), "
|
||||
"IFNULL(archive_id,''), IFNULL(encryption,''), IFNULL(replace_id,'') "
|
||||
"FROM ChatLogs ORDER BY timestamp ASC, stanza_id ASC;";
|
||||
sqlite3_stmt* sa = NULL; sqlite3_stmt* sb = NULL;
|
||||
if (sqlite3_prepare_v2(da, SQL, -1, &sa, NULL) != SQLITE_OK
|
||||
|| sqlite3_prepare_v2(db, SQL, -1, &sb, NULL) != SQLITE_OK) {
|
||||
if (sa)
|
||||
sqlite3_finalize(sa);
|
||||
if (sb)
|
||||
sqlite3_finalize(sb);
|
||||
sqlite3_close(da);
|
||||
sqlite3_close(db);
|
||||
if (sa) sqlite3_finalize(sa);
|
||||
if (sb) sqlite3_finalize(sb);
|
||||
sqlite3_close(da); sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -345,16 +332,14 @@ db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t*
|
||||
while (1) {
|
||||
int ra = sqlite3_step(sa);
|
||||
int rb = sqlite3_step(sb);
|
||||
if (ra != SQLITE_ROW && rb != SQLITE_ROW)
|
||||
break;
|
||||
if (ra == SQLITE_ROW)
|
||||
na++;
|
||||
if (rb == SQLITE_ROW)
|
||||
nb++;
|
||||
if (ra != SQLITE_ROW && rb != SQLITE_ROW) break;
|
||||
if (ra == SQLITE_ROW) na++;
|
||||
if (rb == SQLITE_ROW) nb++;
|
||||
if (ra != rb) {
|
||||
mismatches++;
|
||||
if (shown < 5) {
|
||||
fprintf(stderr, " diff: row count drift at ra_row=%" PRId64 " rb_row=%" PRId64 " ra=%d rb=%d\n", na, nb, ra, rb);
|
||||
fprintf(stderr, " diff: row count drift at ra_row=%" PRId64
|
||||
" rb_row=%" PRId64 " ra=%d rb=%d\n", na, nb, ra, rb);
|
||||
shown++;
|
||||
}
|
||||
continue;
|
||||
@@ -379,10 +364,8 @@ db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t*
|
||||
sqlite3_finalize(sb);
|
||||
sqlite3_close(da);
|
||||
sqlite3_close(db);
|
||||
if (total_a)
|
||||
*total_a = na;
|
||||
if (total_b)
|
||||
*total_b = nb;
|
||||
if (total_a) *total_a = na;
|
||||
if (total_b) *total_b = nb;
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
@@ -416,23 +399,15 @@ cmd_seed(int argc, char** argv)
|
||||
char* db_path = NULL;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--db=", 5) == 0)
|
||||
db_path = g_strdup(a + 5);
|
||||
else if (strncmp(a, "--rows=", 7) == 0)
|
||||
o.rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--contacts=", 11) == 0)
|
||||
o.contacts = atoi(a + 11);
|
||||
else if (strncmp(a, "--lmc=", 6) == 0)
|
||||
o.lmc_pct = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0)
|
||||
o.avg_body = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--seed=", 7) == 0)
|
||||
o.seed = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--account=", 10) == 0)
|
||||
o.account_jid = a + 10;
|
||||
if (strncmp(a, "--db=", 5) == 0) db_path = g_strdup(a + 5);
|
||||
else if (strncmp(a, "--rows=", 7) == 0) o.rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--contacts=", 11) == 0) o.contacts = atoi(a + 11);
|
||||
else if (strncmp(a, "--lmc=", 6) == 0) o.lmc_pct = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0) o.avg_body = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--seed=", 7) == 0) o.seed = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--account=", 10) == 0) o.account_jid = a + 10;
|
||||
}
|
||||
if (!db_path)
|
||||
db_path = db_path_for(o.account_jid);
|
||||
if (!db_path) db_path = db_path_for(o.account_jid);
|
||||
o.db_path = db_path;
|
||||
int r = do_seed(&o);
|
||||
g_free(db_path);
|
||||
@@ -448,14 +423,10 @@ cmd_export(int argc, char** argv)
|
||||
const char* volume = "export";
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--account=", 10) == 0)
|
||||
account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
if (strncmp(a, "--account=", 10) == 0) account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
}
|
||||
if (!init_sqlite_backend(account))
|
||||
return 2;
|
||||
@@ -490,14 +461,10 @@ cmd_import(int argc, char** argv)
|
||||
const char* volume = "import";
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--account=", 10) == 0)
|
||||
account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
if (strncmp(a, "--account=", 10) == 0) account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
}
|
||||
if (!init_sqlite_backend(account))
|
||||
return 2;
|
||||
@@ -540,36 +507,24 @@ cmd_roundtrip(int argc, char** argv)
|
||||
int do_diff = 0;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--rows=", 7) == 0)
|
||||
rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
else if (strncmp(a, "--lmc=", 6) == 0)
|
||||
lmc = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0)
|
||||
body = strtoull(a + 7, NULL, 10);
|
||||
else if (strcmp(a, "--full-diff") == 0)
|
||||
do_diff = 1;
|
||||
if (strncmp(a, "--rows=", 7) == 0) rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
else if (strncmp(a, "--lmc=", 6) == 0) lmc = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0) body = strtoull(a + 7, NULL, 10);
|
||||
else if (strcmp(a, "--full-diff") == 0) do_diff = 1;
|
||||
}
|
||||
|
||||
const char* account_a = "rt-a@bench.example";
|
||||
const char* account_b = "rt-b@bench.example";
|
||||
auto_gchar gchar* db_a = db_path_for(account_a);
|
||||
auto_gchar gchar* db_b = db_path_for(account_b);
|
||||
unlink(db_a);
|
||||
unlink(db_b);
|
||||
auto_gchar gchar* wal_a = g_strdup_printf("%s-wal", db_a);
|
||||
unlink(wal_a);
|
||||
auto_gchar gchar* shm_a = g_strdup_printf("%s-shm", db_a);
|
||||
unlink(shm_a);
|
||||
auto_gchar gchar* wal_b = g_strdup_printf("%s-wal", db_b);
|
||||
unlink(wal_b);
|
||||
auto_gchar gchar* shm_b = g_strdup_printf("%s-shm", db_b);
|
||||
unlink(shm_b);
|
||||
unlink(db_a); unlink(db_b);
|
||||
auto_gchar gchar* wal_a = g_strdup_printf("%s-wal", db_a); unlink(wal_a);
|
||||
auto_gchar gchar* shm_a = g_strdup_printf("%s-shm", db_a); unlink(shm_a);
|
||||
auto_gchar gchar* wal_b = g_strdup_printf("%s-wal", db_b); unlink(wal_b);
|
||||
auto_gchar gchar* shm_b = g_strdup_printf("%s-shm", db_b); unlink(shm_b);
|
||||
|
||||
fprintf(stderr, "===== roundtrip: rows=%" PRId64 " account_a=%s account_b=%s =====\n",
|
||||
rows, account_a, account_b);
|
||||
@@ -584,13 +539,11 @@ cmd_roundtrip(int argc, char** argv)
|
||||
so.seed = 42;
|
||||
so.account_jid = account_a;
|
||||
double seed_ms_t0 = bench_now_ms();
|
||||
if (do_seed(&so) != 0)
|
||||
return 2;
|
||||
if (do_seed(&so) != 0) return 2;
|
||||
double seed_ms = bench_now_ms() - seed_ms_t0;
|
||||
|
||||
// 2) Export DB_A → flatlog under account_a
|
||||
if (!init_sqlite_backend(account_a))
|
||||
return 2;
|
||||
if (!init_sqlite_backend(account_a)) return 2;
|
||||
double export_t0 = bench_now_ms();
|
||||
int exported = log_database_export_to_flatfile(NULL);
|
||||
double export_ms = bench_now_ms() - export_t0;
|
||||
@@ -599,8 +552,7 @@ cmd_roundtrip(int argc, char** argv)
|
||||
|
||||
// 3) Cross-mount the flatlog tree at account_b's expected location
|
||||
const char* base = getenv("BENCH_DATA_DIR");
|
||||
if (!base || !base[0])
|
||||
base = "/tmp/cproof-bench-export";
|
||||
if (!base || !base[0]) base = "/tmp/cproof-bench-export";
|
||||
auto_gchar gchar* dir_a = g_strdup_printf("%s/flatlog/%s", base, ff_jid_to_dir(account_a));
|
||||
auto_gchar gchar* dir_b = g_strdup_printf("%s/flatlog/%s", base, ff_jid_to_dir(account_b));
|
||||
auto_gchar gchar* parent_b = g_path_get_dirname(dir_b);
|
||||
@@ -613,8 +565,7 @@ cmd_roundtrip(int argc, char** argv)
|
||||
}
|
||||
|
||||
// 4) Import flatlog → DB_B
|
||||
if (!init_sqlite_backend(account_b))
|
||||
return 2;
|
||||
if (!init_sqlite_backend(account_b)) return 2;
|
||||
double import_t0 = bench_now_ms();
|
||||
int imported = log_database_import_from_flatfile(NULL);
|
||||
double import_ms = bench_now_ms() - import_t0;
|
||||
@@ -632,7 +583,8 @@ cmd_roundtrip(int argc, char** argv)
|
||||
int64_t na = 0, nb = 0;
|
||||
mismatches = db_diff_full(db_a, db_b, &na, &nb);
|
||||
diff_ms = bench_now_ms() - diff_t0;
|
||||
fprintf(stderr, " full content diff: a_rows=%" PRId64 " b_rows=%" PRId64 " mismatches=%" PRId64 " (%.2fs)\n",
|
||||
fprintf(stderr, " full content diff: a_rows=%" PRId64 " b_rows=%" PRId64
|
||||
" mismatches=%" PRId64 " (%.2fs)\n",
|
||||
na, nb, mismatches, diff_ms / 1000.0);
|
||||
}
|
||||
|
||||
@@ -662,19 +614,13 @@ cmd_roundtrip(int argc, char** argv)
|
||||
static int
|
||||
cmd_verify(int argc, char** argv)
|
||||
{
|
||||
const char* a = NULL;
|
||||
const char* b = NULL;
|
||||
const char* a = NULL; const char* b = NULL;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* x = argv[i];
|
||||
if (strncmp(x, "--db-a=", 7) == 0)
|
||||
a = x + 7;
|
||||
else if (strncmp(x, "--db-b=", 7) == 0)
|
||||
b = x + 7;
|
||||
}
|
||||
if (!a || !b) {
|
||||
usage();
|
||||
return 2;
|
||||
if (strncmp(x, "--db-a=", 7) == 0) a = x + 7;
|
||||
else if (strncmp(x, "--db-b=", 7) == 0) b = x + 7;
|
||||
}
|
||||
if (!a || !b) { usage(); return 2; }
|
||||
int64_t na = 0, nb = 0;
|
||||
int64_t m = db_diff_full(a, b, &na, &nb);
|
||||
fprintf(stderr, "verify: a_rows=%" PRId64 " b_rows=%" PRId64 " mismatches=%" PRId64 "\n",
|
||||
@@ -685,23 +631,15 @@ cmd_verify(int argc, char** argv)
|
||||
int
|
||||
main(int argc, char** argv)
|
||||
{
|
||||
if (argc < 2) {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
if (argc < 2) { usage(); return 2; }
|
||||
const char* sub = argv[1];
|
||||
int sub_argc = argc - 2;
|
||||
char** sub_argv = argv + 2;
|
||||
if (g_strcmp0(sub, "seed") == 0)
|
||||
return cmd_seed(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "export") == 0)
|
||||
return cmd_export(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "import") == 0)
|
||||
return cmd_import(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "roundtrip") == 0)
|
||||
return cmd_roundtrip(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "verify") == 0)
|
||||
return cmd_verify(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "seed") == 0) return cmd_seed(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "export") == 0) return cmd_export(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "import") == 0) return cmd_import(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "roundtrip") == 0) return cmd_roundtrip(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "verify") == 0) return cmd_verify(sub_argc, sub_argv);
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* bench_failure_modes.c
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*
|
||||
* F1–F17 failure-injection tests. Each test crafts a deliberately corrupt
|
||||
* F1–F15 failure-injection tests. Each test crafts a deliberately corrupt
|
||||
* or pathological flat-file and asserts how the backend handles it:
|
||||
*
|
||||
* F1 Truncated last line (crash mid-fwrite simulation)
|
||||
@@ -21,8 +21,6 @@
|
||||
* F12 Empty body — receipt-only carbon equivalent
|
||||
* F14 mtime/inode flip — file replaced under us, ensure_fresh must rebuild
|
||||
* F15 Empty file (0 bytes) — verify reports INFO and continues
|
||||
* F16 Page-Up cursor must not stick on entries[0] (regression guard)
|
||||
* F17 Forward iteration via start_time covers every message (no gaps)
|
||||
*
|
||||
* Each test prints PASS/FAIL with detail and writes a CSV row:
|
||||
* F#, "failure", file_size, line_count, wall_ms, peak_rss_kb, note
|
||||
@@ -48,11 +46,10 @@
|
||||
|
||||
#include "bench_common.h"
|
||||
#include "bench_csv.h"
|
||||
#include "config/account.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
#define ACCOUNT_JID "fail@bench.example"
|
||||
#define CONTACT_JID "peer@bench.example"
|
||||
#define ACCOUNT_JID "fail@bench.example"
|
||||
#define CONTACT_JID "peer@bench.example"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test framework
|
||||
@@ -109,8 +106,7 @@ typedef struct
|
||||
static void
|
||||
verify_summary_free(verify_summary_t* s)
|
||||
{
|
||||
if (!s)
|
||||
return;
|
||||
if (!s) return;
|
||||
g_free(s->first_err);
|
||||
g_free(s->first_warn);
|
||||
}
|
||||
@@ -125,18 +121,15 @@ run_verify(verify_summary_t* out)
|
||||
out->total = g_slist_length(issues);
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* i = (integrity_issue_t*)l->data;
|
||||
if (!i)
|
||||
continue;
|
||||
if (!i) continue;
|
||||
switch (i->level) {
|
||||
case INTEGRITY_ERROR:
|
||||
out->errors++;
|
||||
if (!out->first_err)
|
||||
out->first_err = g_strdup(i->message ? i->message : "");
|
||||
if (!out->first_err) out->first_err = g_strdup(i->message ? i->message : "");
|
||||
break;
|
||||
case INTEGRITY_WARNING:
|
||||
out->warnings++;
|
||||
if (!out->first_warn)
|
||||
out->first_warn = g_strdup(i->message ? i->message : "");
|
||||
if (!out->first_warn) out->first_warn = g_strdup(i->message ? i->message : "");
|
||||
break;
|
||||
case INTEGRITY_INFO:
|
||||
out->infos++;
|
||||
@@ -152,10 +145,7 @@ report(fail_ctx_t* ctx, const char* tag, gboolean ok, double wall_ms,
|
||||
{
|
||||
fprintf(stderr, " %s %-3s %.1fms %s\n",
|
||||
ok ? "PASS" : "FAIL", tag, wall_ms, note ? note : "");
|
||||
if (ok)
|
||||
ctx->passed++;
|
||||
else
|
||||
ctx->failed++;
|
||||
if (ok) ctx->passed++; else ctx->failed++;
|
||||
if (ctx->csv_path) {
|
||||
struct stat st;
|
||||
uint64_t sz = (path && stat(path, &st) == 0) ? (uint64_t)st.st_size : 0;
|
||||
@@ -189,8 +179,7 @@ write_normal_line(FILE* fp, int seq, const char* body)
|
||||
static void
|
||||
test_F1(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F1"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F1")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F1");
|
||||
|
||||
// Write 10 normal lines; chop the trailing \n off the last.
|
||||
@@ -235,8 +224,7 @@ test_F1(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F2(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F2"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F2")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F2");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -271,17 +259,14 @@ test_F2(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F3(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F3"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F3")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F3");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
write_normal_line(fp, 0, "before bom");
|
||||
// Inject BOM bytes at the start of a line in the middle of the file.
|
||||
fputc(0xEF, fp);
|
||||
fputc(0xBB, fp);
|
||||
fputc(0xBF, fp);
|
||||
fputc(0xEF, fp); fputc(0xBB, fp); fputc(0xBF, fp);
|
||||
fprintf(fp,
|
||||
"2025-06-15T12:00:01Z [chat|none|id:after-bom] peer@bench.example/phone: after bom\n");
|
||||
write_normal_line(fp, 2, "trailing");
|
||||
@@ -308,8 +293,7 @@ test_F3(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F7(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F7"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F7")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F7");
|
||||
|
||||
// A: stanza_id="A", no replace
|
||||
@@ -350,8 +334,7 @@ test_F7(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F8(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F8"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F8")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F8");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -390,8 +373,7 @@ test_F8(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F9(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F9"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F9")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F9");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -427,8 +409,7 @@ test_F9(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F10(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F10"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F10")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F10");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -466,8 +447,7 @@ test_F10(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F11(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F11"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F11")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F11");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -505,8 +485,7 @@ test_F11(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F12(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F12"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F12")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F12");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -536,8 +515,7 @@ test_F12(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F14(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F14"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F14")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F14");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -582,8 +560,7 @@ test_F14(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F15(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F15"))
|
||||
return;
|
||||
if (!test_enabled(ctx->tests, "F15")) return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F15");
|
||||
FILE* fp = fopen(path, "w");
|
||||
fclose(fp);
|
||||
@@ -601,226 +578,6 @@ test_F15(fail_ctx_t* ctx)
|
||||
verify_summary_free(&s);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F16 — Page-Up cursor pagination must not get stuck at file start.
|
||||
//
|
||||
// Walks the contact log from newest to oldest by repeatedly calling
|
||||
// db_backend_flatfile()->get_previous_chat with end_time set to the
|
||||
// timestamp of the oldest message returned in the previous batch
|
||||
// (mirroring chatwin's Page-Up flow). Stops on DB_RESPONSE_EMPTY.
|
||||
//
|
||||
// Buggy behaviour: the cursor migrates back until it lands on the byte
|
||||
// offset of the first index entry, then the !from_start backup logic
|
||||
// produces an empty [X, X) range, EMPTY arrives prematurely, and a real
|
||||
// UI would set WIN_SCROLL_REACHED_TOP forever.
|
||||
//
|
||||
// Pass criterion: total messages received across all calls equals the
|
||||
// number of messages written to the file.
|
||||
|
||||
static void
|
||||
test_F16(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F16"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F16");
|
||||
|
||||
// Write enough messages that we cross at least 3 index entries
|
||||
// (FF_INDEX_STEP=500 → 2000 messages = 4 entries).
|
||||
const int total_msgs = 2000;
|
||||
FILE* fp = fopen(path, "w");
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
for (int i = 0; i < total_msgs; i++)
|
||||
write_normal_line(fp, i, "page-up regression payload");
|
||||
fclose(fp);
|
||||
|
||||
g_free(g_flatfile_account_jid);
|
||||
g_flatfile_account_jid = g_strdup(ACCOUNT_JID);
|
||||
setenv("BENCH_ACCOUNT_JID", ACCOUNT_JID, 1);
|
||||
|
||||
db_backend_t* be = db_backend_flatfile();
|
||||
if (!be || !be->init || !be->get_previous_chat || !be->close) {
|
||||
report(ctx, "F16", FALSE, 0, path, "flatfile backend not available");
|
||||
return;
|
||||
}
|
||||
// Initialize the per-contact state hash; without this _ff_get_state
|
||||
// returns NULL and the very first call gets DB_RESPONSE_ERROR.
|
||||
ProfAccount fake = { 0 };
|
||||
fake.jid = (gchar*)ACCOUNT_JID;
|
||||
if (!be->init(&fake)) {
|
||||
report(ctx, "F16", FALSE, 0, path, "flatfile init failed");
|
||||
return;
|
||||
}
|
||||
|
||||
int total_received = 0;
|
||||
int empty_responses = 0;
|
||||
int iterations = 0;
|
||||
auto_gchar gchar* end_time = NULL;
|
||||
|
||||
double t0 = bench_now_ms();
|
||||
|
||||
// Loop with a hard ceiling — if the bug is present we'd otherwise
|
||||
// stop after one page anyway, but the ceiling protects us against
|
||||
// a hypothetical infinite loop.
|
||||
while (iterations < 200) {
|
||||
iterations++;
|
||||
GSList* batch = NULL;
|
||||
db_history_result_t r = be->get_previous_chat(
|
||||
CONTACT_JID, NULL, end_time, FALSE, FALSE, &batch);
|
||||
|
||||
if (r == DB_RESPONSE_EMPTY) {
|
||||
empty_responses++;
|
||||
break;
|
||||
}
|
||||
if (r != DB_RESPONSE_SUCCESS || !batch) {
|
||||
empty_responses++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Count + find the oldest timestamp (front of list, since the
|
||||
// backend returns oldest-first by file order).
|
||||
ProfMessage* oldest = batch->data;
|
||||
if (!oldest || !oldest->timestamp) {
|
||||
g_slist_free_full(batch, (GDestroyNotify)message_free);
|
||||
break;
|
||||
}
|
||||
int batch_n = (int)g_slist_length(batch);
|
||||
total_received += batch_n;
|
||||
|
||||
g_free(end_time);
|
||||
end_time = g_date_time_format_iso8601(oldest->timestamp);
|
||||
|
||||
g_slist_free_full(batch, (GDestroyNotify)message_free);
|
||||
}
|
||||
|
||||
double dt = bench_now_ms() - t0;
|
||||
|
||||
// Two regression classes are caught here:
|
||||
// 1. The original "cursor stuck on entries[0]" symptom (early EMPTY
|
||||
// after 2-3 iterations, ~200 received) — capped page-up at the top.
|
||||
// 2. The deeper "cursor jumping" bug — cursor was set to the oldest
|
||||
// line of the read window rather than the oldest line returned to
|
||||
// the caller after pagination, so each subsequent page-up skipped
|
||||
// past entire ranges of messages. With both fixed, every written
|
||||
// message must be reachable through sequential page-up.
|
||||
gboolean ok = (total_received == total_msgs)
|
||||
&& (empty_responses == 1)
|
||||
&& (iterations < 200);
|
||||
|
||||
auto_gchar gchar* note = g_strdup_printf(
|
||||
"wrote=%d received=%d iters=%d empty=%d "
|
||||
"(without fix: ~200 received in ~3 iters; cursor-jumping mode: ~600 in ~7)",
|
||||
total_msgs, total_received, iterations, empty_responses);
|
||||
report(ctx, "F16", ok, dt, path, note);
|
||||
be->close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F17 — Forward iteration (start_time + from_start=TRUE) reaches every
|
||||
// message without gaps.
|
||||
//
|
||||
// Symmetric counterpart to F16: instead of paging from newest backwards
|
||||
// via end_time, page from oldest forwards via start_time. Each iteration
|
||||
// passes start_time = timestamp of the newest message returned in the
|
||||
// previous batch; the backend should return the next batch of strictly
|
||||
// newer messages until the file is exhausted.
|
||||
//
|
||||
// This exercises a different branch of _flatfile_get_previous_chat:
|
||||
// - read_from = ff_state_offset_for_time(start_time) (not bom_len)
|
||||
// - read_to = EOF (no end_time bisect)
|
||||
// - filter drops msgs with timestamp <= start_dt
|
||||
// - pagination keeps FIRST MESSAGES_TO_RETRIEVE (not last)
|
||||
// The path bypasses cursor entirely (start_time triggers cursor=-1 reset).
|
||||
//
|
||||
// Pass criterion: total messages received == total written.
|
||||
|
||||
static void
|
||||
test_F17(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F17"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F17");
|
||||
|
||||
const int total_msgs = 2000;
|
||||
FILE* fp = fopen(path, "w");
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
for (int i = 0; i < total_msgs; i++)
|
||||
write_normal_line(fp, i, "forward-iter regression payload");
|
||||
fclose(fp);
|
||||
|
||||
g_free(g_flatfile_account_jid);
|
||||
g_flatfile_account_jid = g_strdup(ACCOUNT_JID);
|
||||
setenv("BENCH_ACCOUNT_JID", ACCOUNT_JID, 1);
|
||||
|
||||
db_backend_t* be = db_backend_flatfile();
|
||||
if (!be || !be->init || !be->get_previous_chat || !be->close) {
|
||||
report(ctx, "F17", FALSE, 0, path, "flatfile backend not available");
|
||||
return;
|
||||
}
|
||||
ProfAccount fake = { 0 };
|
||||
fake.jid = (gchar*)ACCOUNT_JID;
|
||||
if (!be->init(&fake)) {
|
||||
report(ctx, "F17", FALSE, 0, path, "flatfile init failed");
|
||||
return;
|
||||
}
|
||||
|
||||
int total_received = 0;
|
||||
int empty_responses = 0;
|
||||
int iterations = 0;
|
||||
// Initial start_time before the first written message so the first
|
||||
// call returns msgs starting at index 0.
|
||||
auto_gchar gchar* start_time = g_strdup("2025-06-15T11:00:00Z");
|
||||
|
||||
double t0 = bench_now_ms();
|
||||
|
||||
while (iterations < 200) {
|
||||
iterations++;
|
||||
GSList* batch = NULL;
|
||||
db_history_result_t r = be->get_previous_chat(
|
||||
CONTACT_JID, start_time, NULL, TRUE, FALSE, &batch);
|
||||
|
||||
if (r == DB_RESPONSE_EMPTY) {
|
||||
empty_responses++;
|
||||
break;
|
||||
}
|
||||
if (r != DB_RESPONSE_SUCCESS || !batch) {
|
||||
empty_responses++;
|
||||
break;
|
||||
}
|
||||
|
||||
// pre_result is sorted oldest-first; with from_start=TRUE we keep
|
||||
// the first MESSAGES_TO_RETRIEVE. The newest in the returned batch
|
||||
// is therefore the LAST element of the list.
|
||||
ProfMessage* newest = NULL;
|
||||
int batch_n = 0;
|
||||
for (GSList* l = batch; l; l = l->next) {
|
||||
batch_n++;
|
||||
newest = l->data;
|
||||
}
|
||||
if (!newest || !newest->timestamp) {
|
||||
g_slist_free_full(batch, (GDestroyNotify)message_free);
|
||||
break;
|
||||
}
|
||||
total_received += batch_n;
|
||||
|
||||
g_free(start_time);
|
||||
start_time = g_date_time_format_iso8601(newest->timestamp);
|
||||
|
||||
g_slist_free_full(batch, (GDestroyNotify)message_free);
|
||||
}
|
||||
|
||||
double dt = bench_now_ms() - t0;
|
||||
|
||||
gboolean ok = (total_received == total_msgs)
|
||||
&& (empty_responses == 1)
|
||||
&& (iterations < 200);
|
||||
|
||||
auto_gchar gchar* note = g_strdup_printf(
|
||||
"wrote=%d received=%d iters=%d empty=%d (forward-iter regression)",
|
||||
total_msgs, total_received, iterations, empty_responses);
|
||||
report(ctx, "F17", ok, dt, path, note);
|
||||
be->close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Driver
|
||||
|
||||
@@ -834,12 +591,9 @@ main(int argc, char** argv)
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--tmp=", 6) == 0)
|
||||
ctx.tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
ctx.csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0)
|
||||
ctx.tests = a + 8;
|
||||
if (strncmp(a, "--tmp=", 6) == 0) ctx.tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) ctx.csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0) ctx.tests = a + 8;
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return 2;
|
||||
@@ -863,8 +617,6 @@ main(int argc, char** argv)
|
||||
test_F12(&ctx);
|
||||
test_F14(&ctx);
|
||||
test_F15(&ctx);
|
||||
test_F16(&ctx);
|
||||
test_F17(&ctx);
|
||||
|
||||
fprintf(stderr, "\nfailure-modes summary: %d passed, %d failed\n",
|
||||
ctx.passed, ctx.failed);
|
||||
|
||||
@@ -68,12 +68,9 @@ parse_args(int argc, char** argv, cli_t* c)
|
||||
c->tests = "all";
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--tmp=", 6) == 0)
|
||||
c->tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0)
|
||||
c->tests = a + 8;
|
||||
if (strncmp(a, "--tmp=", 6) == 0) c->tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0) c->tests = a + 8;
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return -1;
|
||||
@@ -98,7 +95,8 @@ test_enabled(const char* list, const char* name)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body factories
|
||||
|
||||
typedef enum {
|
||||
typedef enum
|
||||
{
|
||||
PAT_FILLER, // deterministic alpha-num
|
||||
PAT_EMBEDDED_LF, // filler with newlines every 100 bytes
|
||||
PAT_EMBEDDED_PIPE, // filler with '|' sprinkled
|
||||
@@ -157,8 +155,7 @@ typedef struct
|
||||
static void
|
||||
roundtrip_cleanup(roundtrip_result_t* r)
|
||||
{
|
||||
if (!r)
|
||||
return;
|
||||
if (!r) return;
|
||||
if (r->path && r->path[0])
|
||||
unlink(r->path);
|
||||
g_free(r->path);
|
||||
@@ -170,8 +167,7 @@ write_n_messages(const char* path, int n, size_t body_len, pattern_t pat,
|
||||
int64_t* bytes_written_out)
|
||||
{
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (!fp)
|
||||
return -1;
|
||||
if (!fp) return -1;
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
|
||||
GDateTime* base = g_date_time_new_utc(2025, 6, 15, 12, 0, 0.0);
|
||||
@@ -180,7 +176,7 @@ write_n_messages(const char* path, int n, size_t body_len, pattern_t pat,
|
||||
auto_gchar gchar* iso = g_date_time_format_iso8601(t);
|
||||
g_date_time_unref(t);
|
||||
auto_gchar gchar* sid = g_strdup_printf("long-%d-%08x", i,
|
||||
(unsigned)(rand_r(&(unsigned){ i + 1 })));
|
||||
(unsigned)(rand_r(&(unsigned){i + 1})));
|
||||
auto_gchar gchar* body = make_body(body_len, pat);
|
||||
ff_write_line(fp, iso, "chat", "none",
|
||||
sid, NULL, NULL,
|
||||
@@ -204,8 +200,7 @@ read_and_validate(const char* path, size_t expected_body_len, int n,
|
||||
int64_t* parsed_out, int64_t* mismatched_out, int64_t* parse_fail_out)
|
||||
{
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp)
|
||||
return -1;
|
||||
if (!fp) return -1;
|
||||
ff_skip_bom(fp);
|
||||
|
||||
int64_t parsed = 0, mismatched = 0, failures = 0;
|
||||
@@ -229,12 +224,9 @@ read_and_validate(const char* path, size_t expected_body_len, int n,
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
fclose(fp);
|
||||
if (parsed_out)
|
||||
*parsed_out = parsed;
|
||||
if (mismatched_out)
|
||||
*mismatched_out = mismatched;
|
||||
if (parse_fail_out)
|
||||
*parse_fail_out = failures;
|
||||
if (parsed_out) *parsed_out = parsed;
|
||||
if (mismatched_out) *mismatched_out = mismatched;
|
||||
if (parse_fail_out) *parse_fail_out = failures;
|
||||
(void)n;
|
||||
return 0;
|
||||
}
|
||||
@@ -265,8 +257,7 @@ static void
|
||||
csv_emit(const char* csv_path, const char* tag, size_t body_len, int n,
|
||||
const roundtrip_result_t* r, const char* note)
|
||||
{
|
||||
if (!csv_path)
|
||||
return;
|
||||
if (!csv_path) return;
|
||||
auto_gchar gchar* full_note = g_strdup_printf(
|
||||
"n=%d body=%zu write=%.1fms read=%.1fms parsed=%" PRId64
|
||||
" mismatch=%" PRId64 " parse_fail=%" PRId64 " %s",
|
||||
@@ -346,11 +337,8 @@ run_oversized(const char* tmp, const char* csv)
|
||||
}
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) {
|
||||
parsed++;
|
||||
ff_parsed_line_free(pl);
|
||||
} else
|
||||
failures++;
|
||||
if (pl) { parsed++; ff_parsed_line_free(pl); }
|
||||
else failures++;
|
||||
}
|
||||
fclose(fp);
|
||||
double read_ms = bench_now_ms() - t0;
|
||||
@@ -385,8 +373,7 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
int huge_indices[5] = { 950, 970, 985, 992, 998 }; // five 1MB bodies near end
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (!fp)
|
||||
return;
|
||||
if (!fp) return;
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
GDateTime* base = g_date_time_new_utc(2025, 6, 15, 12, 0, 0.0);
|
||||
for (int i = 0; i < total; i++) {
|
||||
@@ -394,11 +381,7 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
auto_gchar gchar* iso = g_date_time_format_iso8601(t);
|
||||
g_date_time_unref(t);
|
||||
size_t blen = 100;
|
||||
for (int k = 0; k < 5; k++)
|
||||
if (huge_indices[k] == i) {
|
||||
blen = MB;
|
||||
break;
|
||||
}
|
||||
for (int k = 0; k < 5; k++) if (huge_indices[k] == i) { blen = MB; break; }
|
||||
auto_gchar gchar* sid = g_strdup_printf("page-%d", i);
|
||||
auto_gchar gchar* body = make_body(blen, PAT_FILLER);
|
||||
ff_write_line(fp, iso, "chat", "none",
|
||||
@@ -430,17 +413,12 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
int64_t parsed = 0;
|
||||
char* buf;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
if (buf[0] == '\0' || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
if (buf[0] == '\0' || buf[0] == '#') { free(buf); continue; }
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl)
|
||||
continue;
|
||||
if (!pl) continue;
|
||||
parsed++;
|
||||
if (ring[rpos])
|
||||
g_free(ring[rpos]);
|
||||
if (ring[rpos]) g_free(ring[rpos]);
|
||||
ring[rpos] = g_strdup(pl->message ? pl->message : "");
|
||||
rpos = (rpos + 1) % 100;
|
||||
ff_parsed_line_free(pl);
|
||||
@@ -448,8 +426,7 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
fclose(fp);
|
||||
double dt = bench_now_ms() - t0;
|
||||
long rss = bench_peak_rss_kb();
|
||||
for (int i = 0; i < 100; i++)
|
||||
g_free(ring[i]);
|
||||
for (int i = 0; i < 100; i++) g_free(ring[i]);
|
||||
|
||||
fprintf(stderr,
|
||||
" L12 scrollback w/ 5×1MB in last 100 read=%.1fms parsed=%" PRId64 " rss=%ldKB\n",
|
||||
|
||||
@@ -58,7 +58,7 @@ typedef struct
|
||||
const char* csv_path;
|
||||
const char* scenarios; // comma-separated list, or "all"
|
||||
const char* account_jid;
|
||||
int extend_lines; // for S5
|
||||
int extend_lines; // for S5
|
||||
} cli_t;
|
||||
|
||||
static void
|
||||
@@ -77,16 +77,11 @@ parse_args(int argc, char** argv, cli_t* c)
|
||||
cli_default(c);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--data=", 7) == 0)
|
||||
c->data_dir = a + 7;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--scenarios=", 12) == 0)
|
||||
c->scenarios = a + 12;
|
||||
else if (strncmp(a, "--account=", 10) == 0)
|
||||
c->account_jid = a + 10;
|
||||
else if (strncmp(a, "--extend-lines=", 15) == 0)
|
||||
c->extend_lines = atoi(a + 15);
|
||||
if (strncmp(a, "--data=", 7) == 0) c->data_dir = a + 7;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--scenarios=", 12) == 0) c->scenarios = a + 12;
|
||||
else if (strncmp(a, "--account=", 10) == 0) c->account_jid = a + 10;
|
||||
else if (strncmp(a, "--extend-lines=", 15) == 0) c->extend_lines = atoi(a + 15);
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return -1;
|
||||
@@ -126,8 +121,7 @@ static int64_t
|
||||
count_lines(const char* path)
|
||||
{
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp)
|
||||
return -1;
|
||||
if (!fp) return -1;
|
||||
int64_t n = 0;
|
||||
int c;
|
||||
while ((c = fgetc(fp)) != EOF) {
|
||||
@@ -197,8 +191,7 @@ run_tail_access(const char* path, int drop_cache, long* peak_rss_kb, int64_t* li
|
||||
ff_contact_state_t* state = ff_state_new(path);
|
||||
if (!ff_state_ensure_fresh(state)) {
|
||||
ff_state_free(state);
|
||||
if (lines_read)
|
||||
*lines_read = 0;
|
||||
if (lines_read) *lines_read = 0;
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
@@ -236,8 +229,7 @@ run_tail_access(const char* path, int drop_cache, long* peak_rss_kb, int64_t* li
|
||||
if (!pl)
|
||||
continue;
|
||||
parsed++;
|
||||
if (ring[ring_pos])
|
||||
g_free(ring[ring_pos]);
|
||||
if (ring[ring_pos]) g_free(ring[ring_pos]);
|
||||
ring[ring_pos] = g_strdup(pl->message ? pl->message : "");
|
||||
ring_pos = (ring_pos + 1) % TAIL_PAGE;
|
||||
ff_parsed_line_free(pl);
|
||||
@@ -305,10 +297,8 @@ run_first_build(const char* path, long* peak_rss_kb, size_t* idx_entries)
|
||||
ff_contact_state_t* state = ff_state_new(path);
|
||||
int ok = ff_state_ensure_fresh(state);
|
||||
double dt = bench_now_ms() - t0;
|
||||
if (idx_entries)
|
||||
*idx_entries = ok ? state->n_entries : 0;
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
if (idx_entries) *idx_entries = ok ? state->n_entries : 0;
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
ff_state_free(state);
|
||||
return ok ? dt : -1.0;
|
||||
}
|
||||
@@ -340,8 +330,7 @@ run_incremental_extend(const char* path, int n_lines, long* peak_rss_kb,
|
||||
int64_t before_size = -1;
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(path, &st) == 0)
|
||||
before_size = st.st_size;
|
||||
if (stat(path, &st) == 0) before_size = st.st_size;
|
||||
}
|
||||
GDateTime* now = g_date_time_new_now_utc();
|
||||
for (int i = 0; i < n_lines; i++) {
|
||||
@@ -370,8 +359,7 @@ run_incremental_extend(const char* path, int n_lines, long* peak_rss_kb,
|
||||
else
|
||||
*added_bytes = -1;
|
||||
}
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
|
||||
fprintf(stderr, " S5: lines before=%zu after=%zu (delta=%zu)\n",
|
||||
before_lines, state->total_lines,
|
||||
@@ -392,8 +380,7 @@ _count_level(GSList* issues, integrity_level_t level)
|
||||
int n = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* i = (integrity_issue_t*)l->data;
|
||||
if (i && i->level == level)
|
||||
n++;
|
||||
if (i && i->level == level) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -419,8 +406,7 @@ run_verify(long* peak_rss_kb, int64_t* total_issues, int* errors, int* warnings,
|
||||
int shown_err = 0, shown_warn = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* iss = (integrity_issue_t*)l->data;
|
||||
if (!iss)
|
||||
continue;
|
||||
if (!iss) continue;
|
||||
if (iss->level == INTEGRITY_ERROR && shown_err < 5) {
|
||||
fprintf(stderr, " ERR %s:%d %s\n",
|
||||
iss->file ? iss->file : "?", iss->line, iss->message ? iss->message : "");
|
||||
@@ -433,16 +419,11 @@ run_verify(long* peak_rss_kb, int64_t* total_issues, int* errors, int* warnings,
|
||||
}
|
||||
g_slist_free_full(issues, (GDestroyNotify)integrity_issue_free);
|
||||
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
if (total_issues)
|
||||
*total_issues = total;
|
||||
if (errors)
|
||||
*errors = e;
|
||||
if (warnings)
|
||||
*warnings = w;
|
||||
if (infos)
|
||||
*infos = i;
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
if (total_issues) *total_issues = total;
|
||||
if (errors) *errors = e;
|
||||
if (warnings) *warnings = w;
|
||||
if (infos) *infos = i;
|
||||
return dt;
|
||||
}
|
||||
|
||||
@@ -464,8 +445,7 @@ main(int argc, char** argv)
|
||||
cli.data_dir, account_dir);
|
||||
if (!g_file_test(contacts_root, G_FILE_TEST_IS_DIR)) {
|
||||
fprintf(stderr, "ERROR: %s does not exist. Run gen_history first "
|
||||
"(make sure --account matches).\n",
|
||||
contacts_root);
|
||||
"(make sure --account matches).\n", contacts_root);
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -474,8 +454,7 @@ main(int argc, char** argv)
|
||||
return 2;
|
||||
|
||||
const char* volume_name = getenv("BENCH_VOLUME");
|
||||
if (!volume_name)
|
||||
volume_name = "medium";
|
||||
if (!volume_name) volume_name = "medium";
|
||||
|
||||
fprintf(stderr, "===== bench_runner: data=%s volume=%s =====\n", cli.data_dir, volume_name);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
#include "config/files.h"
|
||||
#include "config/preferences.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h" // for ff_jid_to_dir() in stub
|
||||
#include "database_flatfile.h" // for ff_jid_to_dir() in stub
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/jid.h"
|
||||
#include "xmpp/message.h"
|
||||
@@ -100,50 +100,14 @@ log_error(const char* const msg, ...)
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
void
|
||||
log_init(log_level_t filter, const char* const log_file)
|
||||
{
|
||||
(void)filter;
|
||||
(void)log_file;
|
||||
}
|
||||
void
|
||||
log_close(void)
|
||||
{
|
||||
}
|
||||
void
|
||||
log_msg(log_level_t level, const char* const area, const char* const msg)
|
||||
{
|
||||
(void)level;
|
||||
(void)area;
|
||||
(void)msg;
|
||||
}
|
||||
const char*
|
||||
get_log_file_location(void)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
log_level_t
|
||||
log_get_filter(void)
|
||||
{
|
||||
return PROF_LEVEL_INFO;
|
||||
}
|
||||
int
|
||||
log_level_from_string(char* log_level, log_level_t* level)
|
||||
{
|
||||
(void)log_level;
|
||||
if (level)
|
||||
*level = PROF_LEVEL_INFO;
|
||||
return 0;
|
||||
}
|
||||
void
|
||||
log_stderr_init(log_level_t level)
|
||||
{
|
||||
(void)level;
|
||||
}
|
||||
void
|
||||
log_stderr_handler(void)
|
||||
{
|
||||
}
|
||||
void log_init(log_level_t filter, const char* const log_file) { (void)filter; (void)log_file; }
|
||||
void log_close(void) {}
|
||||
void log_msg(log_level_t level, const char* const area, const char* const msg) { (void)level; (void)area; (void)msg; }
|
||||
const char* get_log_file_location(void) { return ""; }
|
||||
log_level_t log_get_filter(void) { return PROF_LEVEL_INFO; }
|
||||
int log_level_from_string(char* log_level, log_level_t* level) { (void)log_level; if (level) *level = PROF_LEVEL_INFO; return 0; }
|
||||
void log_stderr_init(log_level_t level) { (void)level; }
|
||||
void log_stderr_handler(void) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prefs / files / xmpp / ui — never reached by the harness, but database_flatfile.c
|
||||
|
||||
@@ -57,14 +57,16 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI options
|
||||
|
||||
typedef enum {
|
||||
typedef enum
|
||||
{
|
||||
SID_UUID,
|
||||
SID_LIBPURPLE,
|
||||
SID_CONVERSATIONS,
|
||||
SID_MIXED,
|
||||
} sid_mode_t;
|
||||
|
||||
typedef enum {
|
||||
typedef enum
|
||||
{
|
||||
LEN_SHORT,
|
||||
LEN_MIXED,
|
||||
LEN_LONG,
|
||||
@@ -107,44 +109,20 @@ opts_default(opts_t* o)
|
||||
static int
|
||||
parse_sid_mode(const char* s, sid_mode_t* out)
|
||||
{
|
||||
if (g_strcmp0(s, "uuid") == 0) {
|
||||
*out = SID_UUID;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "libpurple") == 0) {
|
||||
*out = SID_LIBPURPLE;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "conversations") == 0) {
|
||||
*out = SID_CONVERSATIONS;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "mixed") == 0) {
|
||||
*out = SID_MIXED;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "uuid") == 0) { *out = SID_UUID; return 0; }
|
||||
if (g_strcmp0(s, "libpurple") == 0) { *out = SID_LIBPURPLE; return 0; }
|
||||
if (g_strcmp0(s, "conversations") == 0) { *out = SID_CONVERSATIONS; return 0; }
|
||||
if (g_strcmp0(s, "mixed") == 0) { *out = SID_MIXED; return 0; }
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
parse_len_profile(const char* s, len_profile_t* out)
|
||||
{
|
||||
if (g_strcmp0(s, "short") == 0) {
|
||||
*out = LEN_SHORT;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "mixed") == 0) {
|
||||
*out = LEN_MIXED;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "long") == 0) {
|
||||
*out = LEN_LONG;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "extreme") == 0) {
|
||||
*out = LEN_EXTREME;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "short") == 0) { *out = LEN_SHORT; return 0; }
|
||||
if (g_strcmp0(s, "mixed") == 0) { *out = LEN_MIXED; return 0; }
|
||||
if (g_strcmp0(s, "long") == 0) { *out = LEN_LONG; return 0; }
|
||||
if (g_strcmp0(s, "extreme") == 0) { *out = LEN_EXTREME; return 0; }
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -226,26 +204,8 @@ print_help(void)
|
||||
// Body / stanza-id / resource generators (deterministic)
|
||||
|
||||
static const char* MSG_BANK_TINY[] = {
|
||||
"ok",
|
||||
"yes",
|
||||
"no",
|
||||
"ack",
|
||||
"thx",
|
||||
"ttyl",
|
||||
"k",
|
||||
"lol",
|
||||
"+1",
|
||||
"yep",
|
||||
"nope",
|
||||
"sure",
|
||||
"got it",
|
||||
"great",
|
||||
"afk",
|
||||
"brb",
|
||||
"bye",
|
||||
"hi",
|
||||
"hello",
|
||||
"hey",
|
||||
"ok", "yes", "no", "ack", "thx", "ttyl", "k", "lol", "+1", "yep", "nope",
|
||||
"sure", "got it", "great", "afk", "brb", "bye", "hi", "hello", "hey",
|
||||
};
|
||||
static const int MSG_BANK_TINY_N = sizeof(MSG_BANK_TINY) / sizeof(MSG_BANK_TINY[0]);
|
||||
|
||||
@@ -305,35 +265,24 @@ pick_body_len(uint64_t* rng, len_profile_t profile)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
}
|
||||
if (profile == LEN_LONG) {
|
||||
if (r < 200)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 600)
|
||||
return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 850)
|
||||
return 1000 + (xorshift64(rng) % 9000); // 1-10 KB
|
||||
if (r < 980)
|
||||
return 10000 + (xorshift64(rng) % 90000); // 10-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 200) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 600) return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 850) return 1000 + (xorshift64(rng) % 9000); // 1-10 KB
|
||||
if (r < 980) return 10000 + (xorshift64(rng) % 90000); // 10-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
}
|
||||
if (profile == LEN_EXTREME) {
|
||||
if (r < 100)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 400)
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 800)
|
||||
return 1000000 + (xorshift64(rng) % 4000000); // 1-5 MB
|
||||
return 5000000 + (xorshift64(rng) % 4000000); // 5-9 MB
|
||||
if (r < 100) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 400) return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 800) return 1000000 + (xorshift64(rng) % 4000000); // 1-5 MB
|
||||
return 5000000 + (xorshift64(rng) % 4000000); // 5-9 MB
|
||||
}
|
||||
// LEN_MIXED — realistic distribution
|
||||
if (r < 500)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 900)
|
||||
return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 980)
|
||||
return 500 + (xorshift64(rng) % 4500); // 500B-5KB
|
||||
if (r < 995)
|
||||
return 5000 + (xorshift64(rng) % 95000); // 5-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 500) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 900) return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 980) return 500 + (xorshift64(rng) % 4500); // 500B-5KB
|
||||
if (r < 995) return 5000 + (xorshift64(rng) % 95000); // 5-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
}
|
||||
|
||||
static char*
|
||||
@@ -392,15 +341,13 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
sid_mode_t effective = mode;
|
||||
if (mode == SID_MIXED) {
|
||||
int r = (int)(xorshift64(rng) % 3);
|
||||
effective = (r == 0) ? SID_UUID : (r == 1) ? SID_LIBPURPLE
|
||||
: SID_CONVERSATIONS;
|
||||
effective = (r == 0) ? SID_UUID : (r == 1) ? SID_LIBPURPLE : SID_CONVERSATIONS;
|
||||
}
|
||||
switch (effective) {
|
||||
case SID_LIBPURPLE:
|
||||
// Per-contact incremental — collides across contacts (intentional, mirrors libpurple).
|
||||
return g_strdup_printf("%" PRId64, global_seq);
|
||||
case SID_CONVERSATIONS:
|
||||
{
|
||||
case SID_CONVERSATIONS: {
|
||||
// Conversations-style: 22 base32-ish chars
|
||||
static const char alpha[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
char buf[24];
|
||||
@@ -411,8 +358,7 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
}
|
||||
case SID_UUID:
|
||||
case SID_MIXED:
|
||||
default:
|
||||
{
|
||||
default: {
|
||||
// RFC4122-ish UUID v4 (deterministic from RNG, not cryptographic)
|
||||
uint64_t a = xorshift64(rng);
|
||||
uint64_t b = xorshift64(rng);
|
||||
@@ -433,12 +379,12 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
typedef struct
|
||||
{
|
||||
char* jid;
|
||||
char** resources; // n = opts.resources_per_contact
|
||||
char** resources; // n = opts.resources_per_contact
|
||||
int n_resources;
|
||||
char* dir;
|
||||
char* path;
|
||||
FILE* fp;
|
||||
int64_t seq; // libpurple-style counter
|
||||
int64_t seq; // libpurple-style counter
|
||||
int64_t lines_written;
|
||||
int64_t bytes_written;
|
||||
char* last_stanza_id; // for LMC corrections
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* ai_http_stub.c
|
||||
*
|
||||
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
|
||||
* functional tests that exercise the AI client's HTTP code path. The
|
||||
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
|
||||
* as its first line; we read it back and hand the port to the caller.
|
||||
*/
|
||||
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
|
||||
#define READLINE_MAX 64
|
||||
|
||||
static pid_t stub_pid = 0;
|
||||
|
||||
int
|
||||
ai_http_stub_start(const char* mode, const char* reply)
|
||||
{
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/* Child: redirect stdout to the pipe write end, then exec the stub. */
|
||||
close(pipefd[0]);
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
close(pipefd[1]);
|
||||
|
||||
if (mode) {
|
||||
setenv("AI_STUB_MODE", mode, 1);
|
||||
}
|
||||
if (reply) {
|
||||
setenv("AI_STUB_REPLY", reply, 1);
|
||||
}
|
||||
/* Argument "0" -> let the OS pick a free port. */
|
||||
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
|
||||
/* Falls through on exec failure. */
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: read "PORT=<n>\n" from the child's stdout. */
|
||||
close(pipefd[1]);
|
||||
|
||||
char buf[READLINE_MAX] = { 0 };
|
||||
ssize_t total = 0;
|
||||
while (total < (ssize_t)sizeof(buf) - 1) {
|
||||
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
if (memchr(buf, '\n', total)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(pipefd[0]);
|
||||
|
||||
int port = 0;
|
||||
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
|
||||
kill(pid, SIGTERM);
|
||||
waitpid(pid, NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
stub_pid = pid;
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_stub_stop(void)
|
||||
{
|
||||
if (stub_pid <= 0) {
|
||||
return;
|
||||
}
|
||||
kill(stub_pid, SIGTERM);
|
||||
/* Give the stub a moment to exit cleanly, then reap. */
|
||||
int status = 0;
|
||||
pid_t waited = waitpid(stub_pid, &status, 0);
|
||||
(void)waited;
|
||||
(void)status;
|
||||
stub_pid = 0;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#ifndef __H_AI_HTTP_STUB
|
||||
#define __H_AI_HTTP_STUB
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
|
||||
*
|
||||
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
|
||||
* script). Selects the response body and HTTP status.
|
||||
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
|
||||
* the default ("MOCK-REPLY").
|
||||
*
|
||||
* Returns the listening port (>0) on success, 0 on failure. The child's
|
||||
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
|
||||
*/
|
||||
int ai_http_stub_start(const char* mode, const char* reply);
|
||||
|
||||
/*
|
||||
* Send SIGTERM to the most recently spawned stub and reap it.
|
||||
* No-op if no stub is running.
|
||||
*/
|
||||
void ai_http_stub_stop(void);
|
||||
|
||||
#endif
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal HTTP stub for AI functional tests.
|
||||
|
||||
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
|
||||
Behaviour is selected via the URL path and the AI_STUB_MODE environment
|
||||
variable, set by the test runner before exec().
|
||||
|
||||
Modes:
|
||||
- "ok" : 200 with a valid OpenAI Responses-API body
|
||||
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
|
||||
- "openai" : 200 with a legacy OpenAI Chat Completions body
|
||||
({"choices":[{"message":{"content":"..."}}]})
|
||||
- "401" : 401 with an OpenAI-style error envelope
|
||||
- "500" : 500 with a body that triggers the parse-error fallback
|
||||
- "models" : 200 with a /v1/models list body
|
||||
|
||||
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
|
||||
|
||||
The stub writes its actual listening port to stdout as a single line
|
||||
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
|
||||
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
|
||||
MODE = os.environ.get("AI_STUB_MODE", "ok")
|
||||
|
||||
|
||||
def _dumps(body: object) -> bytes:
|
||||
"""Produce compact JSON without whitespace — profanity's hand-rolled
|
||||
parser does strstr for `"key":"`, which fails when json.dumps inserts
|
||||
a space after the colon by default."""
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _body_for(mode: str) -> tuple[int, bytes]:
|
||||
if mode == "ok":
|
||||
body = {
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{"text": REPLY, "type": "output_text"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "openai":
|
||||
body = {
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": REPLY}}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "401":
|
||||
body = {
|
||||
"error": {
|
||||
"message": REPLY,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
}
|
||||
return 401, _dumps(body)
|
||||
if mode == "500":
|
||||
return 500, b"<html><body>Internal Server Error</body></html>"
|
||||
if mode == "models":
|
||||
body = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "model-a", "object": "model"},
|
||||
{"id": "model-b", "object": "model"},
|
||||
{"id": "model-c", "object": "model"},
|
||||
],
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
return 200, b"{}"
|
||||
|
||||
|
||||
class StubHandler(BaseHTTPRequestHandler):
|
||||
def _send(self):
|
||||
status, body = _body_for(MODE)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
self._send()
|
||||
|
||||
def do_GET(self):
|
||||
self._send()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
srv = HTTPServer(("127.0.0.1", port), StubHandler)
|
||||
actual = srv.server_address[1]
|
||||
print(f"PORT={actual}", flush=True)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -59,22 +59,9 @@
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
#include "test_ai_http.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
{ \
|
||||
#test, test, init_prof_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
/* AI tests use a custom init that primes stabber via prof_connect so
|
||||
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
|
||||
* server thread. See ai_init_test() in test_ai.c. */
|
||||
#define PROF_FUNC_TEST_AI(test) \
|
||||
{ \
|
||||
#test, test, ai_init_test, close_prof_test, #test \
|
||||
}
|
||||
#define PROF_FUNC_TEST(test) { #test, test, init_prof_test, close_prof_test, #test }
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
||||
@@ -94,9 +81,9 @@ main(int argc, char* argv[])
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 5) {
|
||||
if (group < 1 || group > 4) {
|
||||
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
||||
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
||||
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -317,39 +304,6 @@ main(int argc, char* argv[])
|
||||
#endif
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 5: AI command surface (console + HTTP stub)
|
||||
* Standalone because AI tests don't use stabber's XMPP path; mixing
|
||||
* them with XMPP-bound tests in the same group poisons stabber state
|
||||
* between fixture init/teardown cycles and hangs subsequent
|
||||
* prof_connect() calls.
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group5_tests[] = {
|
||||
/* Tier A — no network calls */
|
||||
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
||||
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
||||
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
||||
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
|
||||
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
|
||||
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
|
||||
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_works),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
||||
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
||||
|
||||
/* Tier B — full request/response through Python HTTP stub */
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
|
||||
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct
|
||||
{
|
||||
@@ -361,7 +315,6 @@ main(int argc, char* argv[])
|
||||
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -20,47 +20,47 @@
|
||||
#include "proftest.h"
|
||||
|
||||
/* Number of parallel test groups for CI builds */
|
||||
#define TEST_GROUPS 5
|
||||
#define TEST_GROUPS 4
|
||||
|
||||
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
/* Base port and fallback scan range for stabber */
|
||||
#define BASE_PORT 5230
|
||||
#define FALLBACK_PORT_RANGE (TEST_GROUPS * PORTS_PER_GROUP)
|
||||
#define BASE_PORT 5230
|
||||
#define FALLBACK_PORT_RANGE (TEST_GROUPS * PORTS_PER_GROUP)
|
||||
|
||||
/* Shutdown polling: interval and maximum attempts before escalating signals */
|
||||
#define MS_TO_US 1000
|
||||
#define SHUTDOWN_POLL_MS 50
|
||||
#define SHUTDOWN_POLL_MAX 100 /* 100 x 50ms = 5s */
|
||||
#define SIGTERM_POLL_MAX 20 /* 20 x 50ms = 1s */
|
||||
#define QUIT_GRACE_MS 100 /* grace for /quit to be read from pty */
|
||||
#define INIT_WAIT_MS 50 /* wait for child process to initialize */
|
||||
#define INPUT_DELAY_MS 10 /* let profanity process input */
|
||||
#define OUTPUT_POLL_MS 50 /* polling interval for output checks */
|
||||
#define READ_TIMEOUT_MS 100 /* select() timeout for pty reads */
|
||||
#define MS_TO_US 1000
|
||||
#define SHUTDOWN_POLL_MS 50
|
||||
#define SHUTDOWN_POLL_MAX 100 /* 100 x 50ms = 5s */
|
||||
#define SIGTERM_POLL_MAX 20 /* 20 x 50ms = 1s */
|
||||
#define QUIT_GRACE_MS 100 /* grace for /quit to be read from pty */
|
||||
#define INIT_WAIT_MS 50 /* wait for child process to initialize */
|
||||
#define INPUT_DELAY_MS 10 /* let profanity process input */
|
||||
#define OUTPUT_POLL_MS 50 /* polling interval for output checks */
|
||||
#define READ_TIMEOUT_MS 100 /* select() timeout for pty reads */
|
||||
|
||||
/* Expect timeouts (seconds) */
|
||||
#define EXPECT_TIMEOUT_DEFAULT 30
|
||||
#define EXPECT_TIMEOUT_CONNECT 60
|
||||
#define EXPECT_TIMEOUT_DEFAULT 30
|
||||
#define EXPECT_TIMEOUT_CONNECT 60
|
||||
|
||||
/* Test duration thresholds (seconds) */
|
||||
#define SLOW_TEST_THRESHOLD_S 20
|
||||
#define FAST_TEST_THRESHOLD_S 0.3
|
||||
#define SLOW_TEST_THRESHOLD_S 20
|
||||
#define FAST_TEST_THRESHOLD_S 0.3
|
||||
|
||||
/* Sentinel "infinity" for min-elapsed tracking (any real test is faster) */
|
||||
#define MIN_ELAPSED_INIT 1e9
|
||||
#define MIN_ELAPSED_INIT 1e9
|
||||
|
||||
/* Terminal dimensions for forkpty */
|
||||
#define PTY_ROWS 24
|
||||
#define PTY_COLS 300
|
||||
#define PTY_ROWS 24
|
||||
#define PTY_COLS 300
|
||||
|
||||
/* Preprocessor stringification (for setenv from numeric #define) */
|
||||
#define STRINGIFY_(x) #x
|
||||
#define STRINGIFY(x) STRINGIFY_(x)
|
||||
|
||||
char* config_orig;
|
||||
char* data_orig;
|
||||
char *config_orig;
|
||||
char *data_orig;
|
||||
|
||||
int fd = 0;
|
||||
int stub_port = BASE_PORT;
|
||||
@@ -87,19 +87,19 @@ static int expect_timeout = EXPECT_TIMEOUT_DEFAULT;
|
||||
|
||||
/* Per-test wall-clock timer */
|
||||
static struct timespec test_start_ts;
|
||||
static const char* current_test_name;
|
||||
static const char *current_test_name;
|
||||
static double min_elapsed = MIN_ELAPSED_INIT;
|
||||
static const char* min_test_name = "(none)";
|
||||
static const char *min_test_name = "(none)";
|
||||
|
||||
/* Per-test threshold overrides (0 = use default) */
|
||||
double prof_test_slow_threshold = 0;
|
||||
double prof_test_fast_threshold = 0;
|
||||
|
||||
/* Timezone for profanity child process (see proftest.h) */
|
||||
const char* prof_test_tz = "UTC";
|
||||
const char *prof_test_tz = "UTC";
|
||||
|
||||
gboolean
|
||||
_create_dir(const char* name)
|
||||
_create_dir(const char *name)
|
||||
{
|
||||
struct stat sb;
|
||||
|
||||
@@ -117,14 +117,14 @@ _create_dir(const char* name)
|
||||
}
|
||||
|
||||
gboolean
|
||||
_mkdir_recursive(const char* dir)
|
||||
_mkdir_recursive(const char *dir)
|
||||
{
|
||||
int i;
|
||||
gboolean result = TRUE;
|
||||
|
||||
for (i = 1; i <= strlen(dir); i++) {
|
||||
if (dir[i] == '/' || dir[i] == '\0') {
|
||||
gchar* next_dir = g_strndup(dir, i);
|
||||
gchar *next_dir = g_strndup(dir, i);
|
||||
result = _create_dir(next_dir);
|
||||
g_free(next_dir);
|
||||
if (!result) {
|
||||
@@ -139,7 +139,7 @@ _mkdir_recursive(const char* dir)
|
||||
void
|
||||
_create_config_dir(void)
|
||||
{
|
||||
GString* profanity_dir = g_string_new(xdg_config_home);
|
||||
GString *profanity_dir = g_string_new(xdg_config_home);
|
||||
g_string_append(profanity_dir, "/profanity");
|
||||
|
||||
if (!_mkdir_recursive(profanity_dir->str)) {
|
||||
@@ -152,7 +152,7 @@ _create_config_dir(void)
|
||||
void
|
||||
_create_data_dir(void)
|
||||
{
|
||||
GString* profanity_dir = g_string_new(xdg_data_home);
|
||||
GString *profanity_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(profanity_dir, "/profanity");
|
||||
|
||||
if (!_mkdir_recursive(profanity_dir->str)) {
|
||||
@@ -165,7 +165,7 @@ _create_data_dir(void)
|
||||
void
|
||||
_create_chatlogs_dir(void)
|
||||
{
|
||||
GString* chatlogs_dir = g_string_new(xdg_data_home);
|
||||
GString *chatlogs_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(chatlogs_dir, "/profanity/chatlogs");
|
||||
|
||||
if (!_mkdir_recursive(chatlogs_dir->str)) {
|
||||
@@ -178,7 +178,7 @@ _create_chatlogs_dir(void)
|
||||
void
|
||||
_create_logs_dir(void)
|
||||
{
|
||||
GString* logs_dir = g_string_new(xdg_data_home);
|
||||
GString *logs_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(logs_dir, "/profanity/logs");
|
||||
|
||||
if (!_mkdir_recursive(logs_dir->str)) {
|
||||
@@ -191,12 +191,12 @@ _create_logs_dir(void)
|
||||
void
|
||||
_cleanup_dirs(void)
|
||||
{
|
||||
const char* group_env = getenv("PROF_TEST_GROUP");
|
||||
const char *group_env = getenv("PROF_TEST_GROUP");
|
||||
int group = group_env ? atoi(group_env) : 0;
|
||||
int dir_id = (group >= 1 && group <= TEST_GROUPS) ? group : stub_port;
|
||||
|
||||
|
||||
printf("[PROF_TEST] Cleaning up directories for group %d (dir_id %d)\n", group, dir_id);
|
||||
|
||||
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf ./test-files/%d", dir_id);
|
||||
int res = system(cmd);
|
||||
@@ -220,26 +220,26 @@ _read_output(int timeout_ms)
|
||||
{
|
||||
fd_set readfds;
|
||||
struct timeval tv;
|
||||
|
||||
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(fd, &readfds);
|
||||
|
||||
|
||||
tv.tv_sec = timeout_ms / MS_TO_US;
|
||||
tv.tv_usec = (timeout_ms % MS_TO_US) * MS_TO_US;
|
||||
|
||||
|
||||
int ret = select(fd + 1, &readfds, NULL, NULL, &tv);
|
||||
if (ret <= 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
size_t space = OUTPUT_BUF_SIZE - output_len - 1;
|
||||
if (space <= 0) {
|
||||
/* Buffer full, shift content */
|
||||
memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE / 2, OUTPUT_BUF_SIZE / 2);
|
||||
output_len = OUTPUT_BUF_SIZE / 2;
|
||||
memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE/2, OUTPUT_BUF_SIZE/2);
|
||||
output_len = OUTPUT_BUF_SIZE/2;
|
||||
space = OUTPUT_BUF_SIZE - output_len - 1;
|
||||
}
|
||||
|
||||
|
||||
ssize_t n = read(fd, output_buffer + output_len, space);
|
||||
if (n > 0) {
|
||||
output_len += n;
|
||||
@@ -260,7 +260,7 @@ prof_start(void)
|
||||
ws.ws_col = PTY_COLS;
|
||||
ws.ws_xpixel = 0;
|
||||
ws.ws_ypixel = 0;
|
||||
|
||||
|
||||
/* Reset output buffer */
|
||||
output_len = 0;
|
||||
output_buffer[0] = '\0';
|
||||
@@ -272,48 +272,48 @@ prof_start(void)
|
||||
tzset();
|
||||
|
||||
child_pid = forkpty(&fd, NULL, NULL, &ws);
|
||||
|
||||
|
||||
if (child_pid < 0) {
|
||||
fd = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (child_pid == 0) {
|
||||
/* Child process */
|
||||
setenv("COLUMNS", STRINGIFY(PTY_COLS), 1);
|
||||
setenv("TERM", "xterm", 1);
|
||||
|
||||
|
||||
execl("./profanity", "./profanity", "-l", "DEBUG", NULL);
|
||||
|
||||
|
||||
/* If exec fails */
|
||||
fprintf(stderr, "execl failed: %s\n", strerror(errno));
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
|
||||
/* Parent process */
|
||||
/* Set non-blocking mode for reading */
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
|
||||
/* Brief wait for process to initialize */
|
||||
sleep_ms(INIT_WAIT_MS);
|
||||
}
|
||||
|
||||
int
|
||||
init_prof_test(void** state)
|
||||
init_prof_test(void **state)
|
||||
{
|
||||
clock_gettime(CLOCK_MONOTONIC, &test_start_ts);
|
||||
current_test_name = state && *state ? (const char*)*state : "unknown";
|
||||
current_test_name = state && *state ? (const char *)*state : "unknown";
|
||||
prof_test_slow_threshold = 0;
|
||||
prof_test_fast_threshold = 0;
|
||||
/* Get test group from environment for static resource allocation */
|
||||
const char* group_env = getenv("PROF_TEST_GROUP");
|
||||
const char *group_env = getenv("PROF_TEST_GROUP");
|
||||
int group = group_env ? atoi(group_env) : 0;
|
||||
|
||||
|
||||
/* Get build index for port offset (for parallel CI builds) */
|
||||
const char* build_env = getenv("PROF_BUILD_INDEX");
|
||||
const char *build_env = getenv("PROF_BUILD_INDEX");
|
||||
int build_idx = build_env ? atoi(build_env) : 0;
|
||||
|
||||
|
||||
/* Calculate port base: each build uses a different range of
|
||||
* TEST_GROUPS * PORTS_PER_GROUP ports.
|
||||
* Build 0 (local/default): 5230-5429, Full: 5230-5429, Minimal: 5430-5629, etc.
|
||||
@@ -321,7 +321,7 @@ init_prof_test(void** state)
|
||||
* or sequential run (no parallel builds), while Full/Minimal/NoEncrypt/Default
|
||||
* are used in CI where they run in parallel. */
|
||||
int port_base = BASE_PORT + ((build_idx > 0 ? build_idx - 1 : 0) * TEST_GROUPS * PORTS_PER_GROUP);
|
||||
|
||||
|
||||
/* Each group gets a dedicated range of ports for parallel execution.
|
||||
* Group 1: port_base..port_base+PORTS_PER_GROUP-1
|
||||
* Group 2: port_base+PORTS_PER_GROUP..port_base+2*PORTS_PER_GROUP-1, etc.
|
||||
@@ -353,7 +353,7 @@ init_prof_test(void** state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!started) {
|
||||
fprintf(stderr, "[PROF_TEST] ERROR: could not start stabber on any port\n");
|
||||
return -1;
|
||||
@@ -366,8 +366,8 @@ init_prof_test(void** state)
|
||||
"./test-files/%d/xdg_config_home", dir_id);
|
||||
snprintf(xdg_data_home, sizeof(xdg_data_home),
|
||||
"./test-files/%d/xdg_data_home", dir_id);
|
||||
|
||||
printf("[PROF_TEST] Group %d using directories: config=%s, data=%s\n",
|
||||
|
||||
printf("[PROF_TEST] Group %d using directories: config=%s, data=%s\n",
|
||||
group, xdg_config_home, xdg_data_home);
|
||||
|
||||
config_orig = getenv("XDG_CONFIG_HOME");
|
||||
@@ -418,7 +418,7 @@ init_prof_test(void** state)
|
||||
}
|
||||
|
||||
int
|
||||
close_prof_test(void** state)
|
||||
close_prof_test(void **state)
|
||||
{
|
||||
if (fd > 0 && child_pid > 0) {
|
||||
prof_input("/quit");
|
||||
@@ -439,10 +439,7 @@ close_prof_test(void** state)
|
||||
if (!exited) {
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < SIGTERM_POLL_MAX; i++) {
|
||||
if (waitpid(child_pid, NULL, WNOHANG) != 0) {
|
||||
exited = 1;
|
||||
break;
|
||||
}
|
||||
if (waitpid(child_pid, NULL, WNOHANG) != 0) { exited = 1; break; }
|
||||
sleep_ms(SHUTDOWN_POLL_MS);
|
||||
}
|
||||
if (!exited) {
|
||||
@@ -465,7 +462,7 @@ close_prof_test(void** state)
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double elapsed = (now.tv_sec - test_start_ts.tv_sec)
|
||||
+ (now.tv_nsec - test_start_ts.tv_nsec) / 1e9;
|
||||
+ (now.tv_nsec - test_start_ts.tv_nsec) / 1e9;
|
||||
if (elapsed < min_elapsed) {
|
||||
min_elapsed = elapsed;
|
||||
min_test_name = current_test_name;
|
||||
@@ -486,14 +483,14 @@ close_prof_test(void** state)
|
||||
}
|
||||
|
||||
void
|
||||
prof_input(const char* input)
|
||||
prof_input(const char *input)
|
||||
{
|
||||
GString* inp_str = g_string_new(input);
|
||||
GString *inp_str = g_string_new(input);
|
||||
g_string_append(inp_str, "\r");
|
||||
ssize_t _wn = write(fd, inp_str->str, inp_str->len);
|
||||
(void)_wn;
|
||||
g_string_free(inp_str, TRUE);
|
||||
|
||||
|
||||
/* Small delay to let profanity process input */
|
||||
sleep_ms(INPUT_DELAY_MS);
|
||||
}
|
||||
@@ -503,24 +500,24 @@ prof_input(const char* input)
|
||||
* Returns 1 if found, 0 if timeout.
|
||||
*/
|
||||
int
|
||||
prof_output_exact(const char* text)
|
||||
prof_output_exact(const char *text)
|
||||
{
|
||||
time_t start = time(NULL);
|
||||
|
||||
|
||||
while (time(NULL) - start < expect_timeout) {
|
||||
/* Read any available output */
|
||||
while (_read_output(READ_TIMEOUT_MS) > 0) {
|
||||
/* Keep reading while data available */
|
||||
}
|
||||
|
||||
|
||||
/* Check if text is in buffer */
|
||||
if (strstr(output_buffer, text) != NULL) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
sleep_ms(OUTPUT_POLL_MS);
|
||||
}
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -529,34 +526,34 @@ prof_output_exact(const char* text)
|
||||
* Returns 1 if found, 0 if timeout.
|
||||
*/
|
||||
int
|
||||
prof_output_regex(const char* pattern)
|
||||
prof_output_regex(const char *pattern)
|
||||
{
|
||||
regex_t regex;
|
||||
int ret;
|
||||
|
||||
|
||||
ret = regcomp(®ex, pattern, REG_EXTENDED | REG_NOSUB);
|
||||
if (ret != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
time_t start = time(NULL);
|
||||
|
||||
|
||||
while (time(NULL) - start < expect_timeout) {
|
||||
/* Read any available output */
|
||||
while (_read_output(READ_TIMEOUT_MS) > 0) {
|
||||
/* Keep reading while data available */
|
||||
}
|
||||
|
||||
|
||||
/* Check if pattern matches */
|
||||
ret = regexec(®ex, output_buffer, 0, NULL, 0);
|
||||
if (ret == 0) {
|
||||
regfree(®ex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
sleep_ms(OUTPUT_POLL_MS);
|
||||
}
|
||||
|
||||
|
||||
/* Timeout reached - log diagnostic info */
|
||||
fprintf(stderr, "Timeout waiting for regex '%s' after %d seconds. Last output:\n", pattern, expect_timeout);
|
||||
size_t len = strlen(output_buffer);
|
||||
@@ -566,21 +563,23 @@ prof_output_regex(const char* pattern)
|
||||
fprintf(stderr, "%s", output_buffer);
|
||||
}
|
||||
fprintf(stderr, "\n");
|
||||
|
||||
|
||||
regfree(®ex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
prof_connect_with_roster(const char* roster)
|
||||
prof_connect_with_roster(const char *roster)
|
||||
{
|
||||
GString* roster_str = g_string_new(
|
||||
GString *roster_str = g_string_new(
|
||||
"<iq type='result' to='stabber@localhost/profanity'>"
|
||||
"<query xmlns='jabber:iq:roster' ver='362'>");
|
||||
"<query xmlns='jabber:iq:roster' ver='362'>"
|
||||
);
|
||||
g_string_append(roster_str, roster);
|
||||
g_string_append(roster_str,
|
||||
"</query>"
|
||||
"</iq>");
|
||||
"</query>"
|
||||
"</iq>"
|
||||
);
|
||||
|
||||
stbbr_for_query("jabber:iq:roster", roster_str->str);
|
||||
g_string_free(roster_str, TRUE);
|
||||
@@ -590,7 +589,7 @@ prof_connect_with_roster(const char* roster)
|
||||
char connect_cmd[128];
|
||||
snprintf(connect_cmd, sizeof(connect_cmd), "/connect stabber@localhost server 127.0.0.1 port %d tls disable auth legacy", stub_port);
|
||||
prof_input(connect_cmd);
|
||||
|
||||
|
||||
assert_true(prof_output_regex("password:"));
|
||||
prof_input("password");
|
||||
|
||||
@@ -598,14 +597,15 @@ prof_connect_with_roster(const char* roster)
|
||||
assert_true(prof_output_regex("Connecting as stabber@localhost"));
|
||||
assert_true(prof_output_regex("logged in successfully"));
|
||||
assert_true(prof_output_regex(".+online.+ \\(priority 0\\)\\."));
|
||||
|
||||
|
||||
expect_timeout = EXPECT_TIMEOUT_CONNECT;
|
||||
// Wait for presence stanza to be sent (content-based, not ID-based)
|
||||
// Match the actual attribute order from stanza_attach_caps
|
||||
assert_true(stbbr_received(
|
||||
"<presence id=\"*\">"
|
||||
"<c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"http://profanity-im.github.io\" ver=\"*\"/>"
|
||||
"</presence>"));
|
||||
"<c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"http://profanity-im.github.io\" ver=\"*\"/>"
|
||||
"</presence>"
|
||||
));
|
||||
}
|
||||
|
||||
void
|
||||
@@ -625,5 +625,6 @@ prof_connect(void)
|
||||
{
|
||||
prof_connect_with_roster(
|
||||
"<item jid='buddy1@localhost' subscription='both' name='Buddy1'/>"
|
||||
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>");
|
||||
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@ extern double prof_test_slow_threshold;
|
||||
extern double prof_test_fast_threshold;
|
||||
|
||||
/* Timezone used for profanity child process (default "UTC") */
|
||||
extern const char* prof_test_tz;
|
||||
extern const char *prof_test_tz;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/*
|
||||
* test_ai.c
|
||||
*
|
||||
* Functional tests for the /ai command surface (Tier A — no network).
|
||||
*
|
||||
* These tests interact with profanity through its PTY console and exercise
|
||||
* paths that do not reach libcurl: command parsing, provider registration,
|
||||
* key/setting/default storage, error messages, and AI window creation.
|
||||
*
|
||||
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
||||
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
||||
* belong in a separate suite backed by a local HTTP stub.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "test_ai.h"
|
||||
|
||||
int
|
||||
ai_init_test(void** state)
|
||||
{
|
||||
int ret = init_prof_test(state);
|
||||
if (ret != 0) {
|
||||
return ret;
|
||||
}
|
||||
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
||||
* stabber's accept loop has no client to disconnect on /quit, and
|
||||
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
||||
* the server thread. */
|
||||
prof_connect();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
ai_no_args_shows_help(void** state)
|
||||
{
|
||||
/* `/ai` with no arguments lists the built-in providers and a usage hint. */
|
||||
prof_input("/ai");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_lists_defaults(void** state)
|
||||
{
|
||||
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
||||
prof_input("/ai providers");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Available AI providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_list_shows_key_status(void** state)
|
||||
{
|
||||
/* `/ai providers list` lists each configured provider with key status. */
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
/* No tokens have been set yet in this fresh session. */
|
||||
assert_true(prof_output_regex("Key: NOT configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_adds_custom(void** state)
|
||||
{
|
||||
/* Adding a custom provider should make it appear in /ai providers list. */
|
||||
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("mock"));
|
||||
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_token_marks_key_set(void** state)
|
||||
{
|
||||
/* Setting a token must flip the provider's key status to "configured". */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
/* openai now shows "Key: configured" while perplexity stays unconfigured. */
|
||||
assert_true(prof_output_regex("Key: configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_unknown_provider_errors(void** state)
|
||||
{
|
||||
/* Unknown provider name should error out without creating a window. */
|
||||
prof_input("/ai start nope_provider somemodel");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_without_key_errors(void** state)
|
||||
{
|
||||
/* Known provider without an API key should refuse to start. */
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No API key set for provider 'openai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_with_key_opens_window(void** state)
|
||||
{
|
||||
/* With a token set, /ai start should create a WIN_AI window. */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
/* /ai start switches focus to the new WIN_AI window; cons_show output
|
||||
* to the console is therefore not the right place to look. The window
|
||||
* itself prints "AI Chat: <provider>/<model>" as its first line. */
|
||||
assert_true(prof_output_regex("AI Chat: openai/gpt-4"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_clear_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai clear from the console (no active AI window) should report nicely. */
|
||||
prof_input("/ai clear");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window to clear"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_works(void** state)
|
||||
{
|
||||
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
||||
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Removing a provider that was never added must report "not found". */
|
||||
prof_input("/ai remove provider nonexistent_xyz");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Setting an unknown default provider should error, not silently accept. */
|
||||
prof_input("/ai set default-provider does_not_exist");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'does_not_exist' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_model_updates_provider(void** state)
|
||||
{
|
||||
/* Setting a default model is acknowledged on the console. */
|
||||
prof_input("/ai set default-model openai gpt-5-preview");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Default model for provider 'openai' set to: gpt-5-preview"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_switch_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai switch with no active AI window should produce an actionable error. */
|
||||
prof_input("/ai switch openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_bad_subcommand_shows_usage(void** state)
|
||||
{
|
||||
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
||||
prof_input("/ai not_a_subcommand");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#ifndef __H_FUNC_TEST_AI
|
||||
#define __H_FUNC_TEST_AI
|
||||
|
||||
/*
|
||||
* AI tests don't exercise the XMPP path, but the test fixture's
|
||||
* stbbr_stop() hangs indefinitely when no client ever connected to
|
||||
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
|
||||
* so stabber sees a graceful disconnect at teardown.
|
||||
*/
|
||||
int ai_init_test(void** state);
|
||||
|
||||
void ai_no_args_shows_help(void** state);
|
||||
void ai_providers_lists_defaults(void** state);
|
||||
void ai_providers_list_shows_key_status(void** state);
|
||||
void ai_set_provider_adds_custom(void** state);
|
||||
void ai_set_token_marks_key_set(void** state);
|
||||
void ai_start_unknown_provider_errors(void** state);
|
||||
void ai_start_without_key_errors(void** state);
|
||||
void ai_start_with_key_opens_window(void** state);
|
||||
void ai_clear_without_window_errors(void** state);
|
||||
void ai_remove_provider_works(void** state);
|
||||
void ai_remove_provider_unknown_errors(void** state);
|
||||
void ai_set_default_provider_unknown_errors(void** state);
|
||||
void ai_set_default_model_updates_provider(void** state);
|
||||
void ai_switch_without_window_errors(void** state);
|
||||
void ai_bad_subcommand_shows_usage(void** state);
|
||||
|
||||
#endif
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* test_ai_http.c
|
||||
*
|
||||
* Functional tests for the /ai command surface that exercise the libcurl
|
||||
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
|
||||
* per-test on a free local port; profanity is configured to point at it
|
||||
* via `/ai set provider`. The stub serves canned JSON whose shape is
|
||||
* controlled via AI_STUB_MODE.
|
||||
*
|
||||
* These tests intentionally do not race the worker thread directly —
|
||||
* they rely on prof_timeout() retries to catch the response once it
|
||||
* propagates back through the AI window.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#define WAIT_AI_RESPONSE_SEC 10
|
||||
|
||||
/*
|
||||
* Configure profanity to use the spawned stub as a provider named "mock"
|
||||
* with a dummy bearer token, then issue /ai start to open the AI window.
|
||||
* Returns the stub port on success, fails the test on stub-spawn failure.
|
||||
*/
|
||||
static int
|
||||
_setup_mock_provider(const char* mode, const char* reply)
|
||||
{
|
||||
int port = ai_http_stub_start(mode, reply);
|
||||
assert_int_not_equal(0, port);
|
||||
|
||||
char cmd[256];
|
||||
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
|
||||
prof_input(cmd);
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token mock fake-test-token");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: mock"));
|
||||
prof_timeout_reset();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_returns_ok(void** state)
|
||||
{
|
||||
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
|
||||
_setup_mock_provider("ok", "STUB-HELLO");
|
||||
|
||||
prof_input("/ai start mock testmodel");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
|
||||
prof_timeout_reset();
|
||||
|
||||
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
|
||||
prof_input("hello stub");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("STUB-HELLO"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_openai_format(void** state)
|
||||
{
|
||||
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
|
||||
_setup_mock_provider("openai", "FROM-CHOICES");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("anything");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("FROM-CHOICES"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_401_shows_error(void** state)
|
||||
{
|
||||
/* 401 with an OpenAI-style error envelope.
|
||||
* ai_parse_error_message() should extract the inner message; the AI
|
||||
* window then displays "HTTP 401: <message>". */
|
||||
_setup_mock_provider("401", "Bad token, try another");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("prompt");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
|
||||
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_500_surfaced(void** state)
|
||||
{
|
||||
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
|
||||
_setup_mock_provider("500", NULL);
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("trigger");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("HTTP 500"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_models_fetch_lists_models(void** state)
|
||||
{
|
||||
/* /v1/models endpoint returns a list of three models. /ai models <p>
|
||||
* caches them silently; /ai models <p> --cached then displays the names. */
|
||||
_setup_mock_provider("models", NULL);
|
||||
|
||||
prof_input("/ai models mock");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai models mock --cached");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("model-a"));
|
||||
assert_true(prof_output_regex("model-b"));
|
||||
assert_true(prof_output_regex("model-c"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#ifndef __H_FUNC_TEST_AI_HTTP
|
||||
#define __H_FUNC_TEST_AI_HTTP
|
||||
|
||||
void ai_http_prompt_returns_ok(void** state);
|
||||
void ai_http_prompt_openai_format(void** state);
|
||||
void ai_http_prompt_401_shows_error(void** state);
|
||||
void ai_http_prompt_500_surfaced(void** state);
|
||||
void ai_http_models_fetch_lists_models(void** state);
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* test_ai_client.h - AI client unit test declarations
|
||||
*/
|
||||
|
||||
int ai_client_setup(void** state);
|
||||
int ai_client_teardown(void** state);
|
||||
|
||||
void test_ai_client_init(void** state);
|
||||
void test_ai_add_provider(void** state);
|
||||
void test_ai_remove_provider(void** state);
|
||||
void test_ai_list_providers(void** state);
|
||||
void test_ai_set_provider_key(void** state);
|
||||
void test_ai_get_provider_key(void** state);
|
||||
void test_ai_session_create(void** state);
|
||||
void test_ai_session_ref_unref(void** state);
|
||||
void test_ai_session_add_message(void** state);
|
||||
void test_ai_session_clear_history(void** state);
|
||||
void test_ai_session_set_model(void** state);
|
||||
void test_ai_json_escape(void** state);
|
||||
void test_ai_json_escape_null(void** state);
|
||||
void test_ai_json_escape_empty(void** state);
|
||||
void test_ai_json_escape_special_chars(void** state);
|
||||
void test_ai_json_escape_percent_signs(void** state);
|
||||
void test_ai_json_escape_backslash_quote(void** state);
|
||||
void test_ai_session_api_key_is_copied(void** state);
|
||||
void test_ai_add_provider_update_existing(void** state);
|
||||
void test_ai_add_provider_null_name_returns_null(void** state);
|
||||
void test_ai_add_provider_null_url_returns_null(void** state);
|
||||
void test_ai_session_create_null_provider_returns_null(void** state);
|
||||
void test_ai_session_create_null_model_returns_null(void** state);
|
||||
void test_ai_session_api_key_null_when_no_key_set(void** state);
|
||||
/* Provider autocomplete tests */
|
||||
void test_ai_providers_find_forward(void** state);
|
||||
void test_ai_providers_find_forward_perplexity(void** state);
|
||||
void test_ai_providers_find_forward_custom(void** state);
|
||||
void test_ai_providers_find_forward_no_match(void** state);
|
||||
void test_ai_providers_find_forward_partial_match(void** state);
|
||||
void test_ai_providers_find_next(void** state);
|
||||
void test_ai_providers_find_previous(void** state);
|
||||
void test_ai_providers_find_null_search_str(void** state);
|
||||
void test_ai_providers_find_empty_search_str(void** state);
|
||||
void test_ai_providers_find_case_insensitive(void** state);
|
||||
/* Provider default model and settings tests */
|
||||
void test_ai_set_provider_default_model(void** state);
|
||||
void test_ai_get_provider_default_model(void** state);
|
||||
void test_ai_set_provider_setting(void** state);
|
||||
void test_ai_get_provider_setting(void** state);
|
||||
/* Model caching tests */
|
||||
void test_ai_models_are_fresh_initial(void** state);
|
||||
/* Model parsing tests */
|
||||
void test_ai_parse_models_openai_format(void** state);
|
||||
void test_ai_parse_models_perplexity_format(void** state);
|
||||
void test_ai_parse_models_array_format(void** state);
|
||||
void test_ai_parse_models_empty_json(void** state);
|
||||
void test_ai_parse_models_null_handling(void** state);
|
||||
void test_ai_parse_models_escaped_quotes(void** state);
|
||||
void test_ai_parse_models_with_whitespace(void** state);
|
||||
/* AI autocomplete integration tests */
|
||||
void test_ai_start_provider_autocomplete_only_on_exact(void** state);
|
||||
void test_ai_models_find_null_session(void** state);
|
||||
void test_ai_models_find_null_provider(void** state);
|
||||
void test_ai_providers_find_cycling(void** state);
|
||||
|
||||
/* Setup that also initializes prefs for round-trip persistence tests. */
|
||||
int ai_client_setup_with_prefs(void** state);
|
||||
int ai_client_teardown_with_prefs(void** state);
|
||||
|
||||
/* Chat response parser tests (ai_parse_response) */
|
||||
void test_ai_parse_response_openai_content(void** state);
|
||||
void test_ai_parse_response_perplexity_text(void** state);
|
||||
void test_ai_parse_response_text_preferred_over_content(void** state);
|
||||
void test_ai_parse_response_escaped_quote(void** state);
|
||||
void test_ai_parse_response_newline_escape(void** state);
|
||||
void test_ai_parse_response_empty_content(void** state);
|
||||
void test_ai_parse_response_missing_field(void** state);
|
||||
void test_ai_parse_response_null_input(void** state);
|
||||
void test_ai_parse_response_empty_input(void** state);
|
||||
void test_ai_parse_response_percent_signs_safe(void** state);
|
||||
void test_ai_parse_response_braces_in_content(void** state);
|
||||
void test_ai_parse_response_multiline_content(void** state);
|
||||
|
||||
/* Error response parser tests (ai_parse_error_message) */
|
||||
void test_ai_parse_error_standard_envelope(void** state);
|
||||
void test_ai_parse_error_with_escapes(void** state);
|
||||
void test_ai_parse_error_no_error_field(void** state);
|
||||
void test_ai_parse_error_no_message_field(void** state);
|
||||
void test_ai_parse_error_null_input(void** state);
|
||||
void test_ai_parse_error_empty_input(void** state);
|
||||
void test_ai_parse_error_tab_escape(void** state);
|
||||
void test_ai_parse_error_backslash_escape(void** state);
|
||||
|
||||
/* Extended JSON escape tests */
|
||||
void test_ai_json_escape_backspace(void** state);
|
||||
void test_ai_json_escape_formfeed(void** state);
|
||||
void test_ai_json_escape_carriage_return(void** state);
|
||||
void test_ai_json_escape_all_specials_combined(void** state);
|
||||
void test_ai_json_escape_passes_utf8_through(void** state);
|
||||
|
||||
/* Autocomplete cycling with many providers */
|
||||
void test_ai_providers_find_cycles_through_many(void** state);
|
||||
void test_ai_providers_find_previous_walks_backwards(void** state);
|
||||
void test_ai_providers_find_wraps_around(void** state);
|
||||
|
||||
/* Session edge cases */
|
||||
void test_ai_session_add_message_null_session_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_role_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_content_no_crash(void** state);
|
||||
void test_ai_session_history_preserves_large_order(void** state);
|
||||
void test_ai_session_clear_empty_history(void** state);
|
||||
void test_ai_session_set_model_null_keeps_old(void** state);
|
||||
void test_ai_session_unref_null_no_crash(void** state);
|
||||
void test_ai_session_ref_null_returns_null(void** state);
|
||||
|
||||
/* Provider edge cases */
|
||||
void test_ai_get_provider_null_returns_null(void** state);
|
||||
void test_ai_remove_provider_null_returns_false(void** state);
|
||||
void test_ai_remove_provider_twice_second_fails(void** state);
|
||||
void test_ai_provider_survives_via_session_after_removal(void** state);
|
||||
|
||||
/* Settings edge cases */
|
||||
void test_ai_settings_multiple_keys_independent(void** state);
|
||||
void test_ai_settings_get_missing_returns_null(void** state);
|
||||
void test_ai_settings_isolated_between_providers(void** state);
|
||||
|
||||
/* Model parsing edge cases */
|
||||
void test_ai_parse_models_data_not_array(void** state);
|
||||
void test_ai_parse_models_empty_data_array(void** state);
|
||||
void test_ai_parse_models_id_outside_data_ignored(void** state);
|
||||
void test_ai_parse_models_multiple_models(void** state);
|
||||
|
||||
/* Prefs round-trip tests */
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
@@ -458,7 +458,7 @@ void
|
||||
test_ff_parse_line_comment(void** state)
|
||||
{
|
||||
assert_null(ff_parse_line("# this is a comment"));
|
||||
assert_null(ff_parse_line("# cproof chat log — UTF-8, LF line endings"));
|
||||
assert_null(ff_parse_line("# profanity chat log — UTF-8, LF line endings"));
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -893,7 +893,7 @@ test_stress_mixed_types_and_encodings(void** state)
|
||||
/* Parse and verify type/enc distribution */
|
||||
fp = fopen(tmppath, "r");
|
||||
assert_non_null(fp);
|
||||
int counts[3] = { 0 }; /* chat, muc, mucpm */
|
||||
int counts[3] = { 0 }; /* chat, muc, mucpm */
|
||||
int enc_counts[5] = { 0 }; /* none, omemo, otr, pgp, ox */
|
||||
int parsed = 0;
|
||||
|
||||
@@ -911,23 +911,15 @@ test_stress_mixed_types_and_encodings(void** state)
|
||||
continue;
|
||||
parsed++;
|
||||
|
||||
if (g_strcmp0(pl->type, "chat") == 0)
|
||||
counts[0]++;
|
||||
else if (g_strcmp0(pl->type, "muc") == 0)
|
||||
counts[1]++;
|
||||
else if (g_strcmp0(pl->type, "mucpm") == 0)
|
||||
counts[2]++;
|
||||
if (g_strcmp0(pl->type, "chat") == 0) counts[0]++;
|
||||
else if (g_strcmp0(pl->type, "muc") == 0) counts[1]++;
|
||||
else if (g_strcmp0(pl->type, "mucpm") == 0) counts[2]++;
|
||||
|
||||
if (g_strcmp0(pl->enc, "none") == 0)
|
||||
enc_counts[0]++;
|
||||
else if (g_strcmp0(pl->enc, "omemo") == 0)
|
||||
enc_counts[1]++;
|
||||
else if (g_strcmp0(pl->enc, "otr") == 0)
|
||||
enc_counts[2]++;
|
||||
else if (g_strcmp0(pl->enc, "pgp") == 0)
|
||||
enc_counts[3]++;
|
||||
else if (g_strcmp0(pl->enc, "ox") == 0)
|
||||
enc_counts[4]++;
|
||||
if (g_strcmp0(pl->enc, "none") == 0) enc_counts[0]++;
|
||||
else if (g_strcmp0(pl->enc, "omemo") == 0) enc_counts[1]++;
|
||||
else if (g_strcmp0(pl->enc, "otr") == 0) enc_counts[2]++;
|
||||
else if (g_strcmp0(pl->enc, "pgp") == 0) enc_counts[3]++;
|
||||
else if (g_strcmp0(pl->enc, "ox") == 0) enc_counts[4]++;
|
||||
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* stub_ai.c - Stubs for AI window functions
|
||||
*/
|
||||
|
||||
#include "ui/window.h"
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
ProfWin*
|
||||
win_create_ai(AISession* session)
|
||||
{
|
||||
/* Return NULL to simulate failure in unit tests */
|
||||
(void)session;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_response(ProfAiWin* win, const char* response)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)win;
|
||||
(void)response;
|
||||
}
|
||||
|
||||
void
|
||||
aiwin_display_error(ProfAiWin* win, const char* error_msg)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)win;
|
||||
(void)error_msg;
|
||||
}
|
||||
|
||||
void
|
||||
win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)window;
|
||||
(void)show_char;
|
||||
(void)id;
|
||||
(void)replace_id;
|
||||
(void)message;
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
#include "test_form.h"
|
||||
#include "test_callbacks.h"
|
||||
#include "test_plugins_disco.h"
|
||||
#include "test_ai_client.h"
|
||||
#include "test_database_export.h"
|
||||
#include "test_database_stress.h"
|
||||
|
||||
@@ -660,121 +659,6 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test_setup_teardown(test_cmd_force_encryption_invalid_policy, load_preferences, close_preferences),
|
||||
cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_invalid_mode, load_preferences, close_preferences),
|
||||
|
||||
// AI client tests
|
||||
cmocka_unit_test_setup_teardown(test_ai_client_init, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_list_providers, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_key, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_key, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_unref, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_api_key_is_copied, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_update_existing, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_name_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_url_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create_null_provider_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_create_null_model_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown),
|
||||
/* Provider autocomplete tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_perplexity, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_case_insensitive, ai_client_setup, ai_client_teardown),
|
||||
/* Provider default model and settings tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
/* Model caching tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing tests */
|
||||
cmocka_unit_test(test_ai_parse_models_openai_format),
|
||||
cmocka_unit_test(test_ai_parse_models_perplexity_format),
|
||||
cmocka_unit_test(test_ai_parse_models_array_format),
|
||||
cmocka_unit_test(test_ai_parse_models_empty_json),
|
||||
cmocka_unit_test(test_ai_parse_models_null_handling),
|
||||
cmocka_unit_test(test_ai_parse_models_escaped_quotes),
|
||||
cmocka_unit_test(test_ai_parse_models_with_whitespace),
|
||||
/* AI autocomplete integration tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_start_provider_autocomplete_only_on_exact, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_null_session, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_null_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycling, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test(test_ai_json_escape),
|
||||
cmocka_unit_test(test_ai_json_escape_null),
|
||||
cmocka_unit_test(test_ai_json_escape_empty),
|
||||
cmocka_unit_test(test_ai_json_escape_special_chars),
|
||||
cmocka_unit_test(test_ai_json_escape_percent_signs),
|
||||
cmocka_unit_test(test_ai_json_escape_backslash_quote),
|
||||
/* Chat response parser */
|
||||
cmocka_unit_test(test_ai_parse_response_openai_content),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_text),
|
||||
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
|
||||
cmocka_unit_test(test_ai_parse_response_escaped_quote),
|
||||
cmocka_unit_test(test_ai_parse_response_newline_escape),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_content),
|
||||
cmocka_unit_test(test_ai_parse_response_missing_field),
|
||||
cmocka_unit_test(test_ai_parse_response_null_input),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
|
||||
cmocka_unit_test(test_ai_parse_response_braces_in_content),
|
||||
cmocka_unit_test(test_ai_parse_response_multiline_content),
|
||||
/* Error response parser */
|
||||
cmocka_unit_test(test_ai_parse_error_standard_envelope),
|
||||
cmocka_unit_test(test_ai_parse_error_with_escapes),
|
||||
cmocka_unit_test(test_ai_parse_error_no_error_field),
|
||||
cmocka_unit_test(test_ai_parse_error_no_message_field),
|
||||
cmocka_unit_test(test_ai_parse_error_null_input),
|
||||
cmocka_unit_test(test_ai_parse_error_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_error_tab_escape),
|
||||
cmocka_unit_test(test_ai_parse_error_backslash_escape),
|
||||
/* Extended JSON escape coverage */
|
||||
cmocka_unit_test(test_ai_json_escape_backspace),
|
||||
cmocka_unit_test(test_ai_json_escape_formfeed),
|
||||
cmocka_unit_test(test_ai_json_escape_carriage_return),
|
||||
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
|
||||
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
|
||||
/* Autocomplete cycling */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
|
||||
/* Session edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
/* Provider edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
|
||||
/* Settings */
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
|
||||
/* Prefs round-trip (uses prefs+ai setup) */
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
|
||||
Reference in New Issue
Block a user