Compare commits

..

36 Commits

Author SHA1 Message Date
e67ae4e7a5 test(ai): expand unit and functional coverage for /ai feature
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Tier A (test_ai.c): 15 cases for the /ai command surface that don't
    need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.
  - Tier B (test_ai_http.c + ai_http_stub.{c,h,py}): 5 cases that
    exercise the libcurl path against a local Python HTTP stub. The
    stub serves canned bodies in five modes (ok, openai, 401, 500,
    models) so each chat/models/error path is hit end-to-end without
    network.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.
  - dist_check_DATA ships ai_http_stub.py with the source tarball.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 20/20 AI functional tests pass (Group 5)
2026-05-11 16:21:45 +03:00
010b2062a4 fix(ai): reset paged flag to display user message
Some checks failed
CI Code / Linux (arch) (pull_request) Failing after 5m11s
CI Code / Code Coverage (pull_request) Failing after 11m1s
CI Code / Check spelling (pull_request) Failing after 11m4s
CI Code / Check coding style (pull_request) Failing after 11m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m16s
CI Code / Linux (debian) (pull_request) Failing after 11m18s
When the user scrolls up to view history, the paged flag is set to 1.
This suppresses new messages in _win_printf(). Reset paged and
unread_msg flags before printing to ensure visibility.
2026-05-10 10:35:57 +00:00
ecb07fd00c fix(ai): improve API error parsing and fallback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m32s
CI Code / Linux (debian) (pull_request) Failing after 3m37s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m48s
CI Code / Linux (arch) (pull_request) Failing after 4m40s
Add early return for empty responses and fallback to console output
when the AI window is closed or invalid. Implement JSON error
parsing to extract readable messages from API failures, improving
debugging and user feedback.
2026-05-09 21:06:09 +00:00
a898cb212d feat(ai): implement robust JSON model parsing
Some checks failed
CI Code / Check coding style (pull_request) Successful in 46s
CI Code / Linux (arch) (pull_request) Failing after 9m46s
CI Code / Code Coverage (pull_request) Failing after 14m8s
CI Code / Check spelling (pull_request) Failing after 14m16s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m24s
CI Code / Linux (debian) (pull_request) Failing after 14m33s
Rewrote internal JSON parsing to correctly handle whitespace, escaped
quotes, and strict field context extraction. This prevents incorrect
field names or provider names from being added to the model list.

Added `ai_parse_models_from_json` public API to facilitate testing.

Changed `/ai models` default behavior to always fetch fresh models.
Replaced `--refresh` flag with `--cached` to display local cache.

Added comprehensive unit tests covering OpenAI and Perplexity formats,
array format, empty/null JSON, escaped quotes, and whitespace handling.
2026-05-09 13:56:59 +00:00
07d267b5bc fix(ai): protect window validation with mutex
Some checks failed
CI Code / Check coding style (pull_request) Successful in 53s
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m49s
CI Code / Linux (arch) (pull_request) Failing after 4m31s
CI Code / Code Coverage (pull_request) Successful in 2m23s
CI Code / Linux (debian) (pull_request) Failing after 5m58s
The existence check accesses shared state without synchronization,
which can cause race conditions when called from background threads.
Explicitly locking the mutex ensures safe access during validation.
2026-05-09 13:20:48 +00:00
acae543057 feat(ai): add model caching, settings, and commands
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 6m18s
CI Code / Linux (arch) (pull_request) Failing after 8m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m4s
- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
2026-05-09 13:15:58 +00:00
731b55fa19 Merge branch 'master' into feat/ai
Some checks failed
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 33s
CI Code / Code Coverage (pull_request) Successful in 2m29s
CI Code / Linux (debian) (pull_request) Failing after 3m35s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m46s
CI Code / Linux (arch) (pull_request) Failing after 4m32s
2026-05-06 13:57:13 +00:00
3f36c303c2 feat(history): flat-file backend with bidirectional SQLite migration
All checks were successful
CI Code / Check spelling (push) Successful in 21s
CI Code / Check coding style (push) Successful in 31s
CI Code / Code Coverage (push) Successful in 2m21s
CI Code / Linux (ubuntu) (push) Successful in 4m30s
CI Code / Linux (debian) (push) Successful in 6m43s
CI Code / Linux (arch) (push) Successful in 10m8s
A flat-file alternative to the SQLite chatlog backend with runtime
switching, full migration tooling, integrity verification, and a
synthetic load harness. SQLite remains the default; both backends share
one dispatch layer (db_backend_t vtable) so callers don't change.

Storage layout
- Per-contact append-only `flatlog/<account>/<contact>/history.log`
  under XDG_DATA_HOME, one line per message
- Single-line file header with embedded format-version marker
  (FLATFILE_FORMAT_VERSION); reader warns on missing or mismatched
  marker, writer and checker stay in sync via preprocessor
  stringification
- Deterministic key=value metadata (`id`, `aid`, `corrects`, `to`,
  `to_res`, `read`) plus escaped body \u2014 `\|`, `\]`, `\\`, `\n`, `\r`
  literals prevent log injection
- Sparse byte-offset index (FF_INDEX_STEP=500) per contact for
  O(log n) time-range lookups; rebuilt on inode / size / mtime
  change, extended in-place when the file just grew
- Per-contact GHashTable caches for archive_id presence and
  stanza_id \u2192 from_jid mapping (O(1) MAM dedup, O(1) LMC sender
  validation)

Hardening
- Path-traversal protection: JID directory name normalisation
  (`@` \u2192 `_at_`, slashes and `..` rejected at construction); every
  per-contact path is anchored under the account's flatlog/
  directory and validated before open
- Symlink-attack protection: every fopen / open uses O_NOFOLLOW; on
  ELOOP the operation aborts with an error rather than following
- Filesystem permissions: log files created with mode 0600,
  directories with mode 0700; both enforced at creation, verified
  on each open and reported on drift by `/history verify`
- Atomic crash-safe export: write to a temp file via mkstemp (mode
  0600, random suffix, no name collisions between concurrent
  exports), fsync, then rename \u2014 partial state never replaces the
  live file
- Concurrency: advisory flock(LOCK_EX) held for the duration of
  every write, including append from live messages and full rewrite
  from export, so two profanity processes can't interleave bytes
  on the same log
- DoS / abuse guards:
    * FF_MAX_LINE_LEN = 10 MB \u2014 lines longer than this are rejected
      at read with a warning; the parser will not allocate
      unbounded memory for a single record
    * FF_MAX_LMC_DEPTH = 100 \u2014 `corrects:` chain walk stops at this
      depth and emits a warning, preventing a malicious correction
      cycle from spinning the apply pass
    * FF_VERSION_SCAN_MAX = 16 \u2014 header version probe never reads
      past 16 leading comment lines, even on garbage input
    * Empty / inverted byte-range early-return in page-up read path
      so a malformed time filter cannot cause an unbounded scan
    * Zero-entry index guard so a file whose every line failed to
      parse cannot cause a NULL deref on later page-up
- LMC sender validation: an incoming correction whose sender does
  not match the original message's sender is rejected at write
  time and surfaced via cons_show_error; a cycle in the apply pass
  is broken via a visited-set
- jid_create_from_bare_and_resource treats NULL, empty string, and
  the literal "(null)" as no resource and returns a bare jid;
  similar normalisation for barejid eliminates the legacy
  "user@host/(null)" artefact that leaked into stored fulljids
  whenever g_strdup_printf("%s", NULL) ran inside create_fulljid

Commands
- `/history switch sqlite|flatfile` \u2014 runtime backend swap, closes
  the old backend and opens the new one without reconnecting
- `/history export [<jid>]` \u2014 SQLite -> flat-file, merging with any
  existing flatlog (dedup keyed on a SHA-256 hash mixing stanza_id,
  timestamp, from_jid, body \u2014 robust against id reuse by older
  clients)
- `/history import [<jid>]` \u2014 flat-file -> SQLite, same merge
  semantics, runs inside a single SQLite transaction with rollback
  on per-contact failure
- `/history verify [<jid>]` \u2014 integrity check; emits a structured
  list of issues (ERROR / WARNING / INFO) per file:
    * file-level: missing log, wrong permissions (\u2260 0600), UTF-8
      BOM present, CRLF line endings, empty file
    * line-level: invalid UTF-8 (with byte offset), embedded
      control characters, unparsable lines, timestamps out of
      order, duplicate `id:` and `aid:` (tracked separately so a
      stanza/archive id collision isn't double-reported)
    * cross-line: broken `corrects:` references whose target id is
      not present in the file
- `/history backend` \u2014 show currently active backend
- Active backend indicator `[sqlite]` / `[flatfile]` in the status
  bar next to the JID
- Roster-JID autocomplete for verify / export / import
- export and import open a SQLite handle on demand when the
  flatfile backend is currently active, so migration works
  regardless of which backend is live

Tests
- Unit: database_export (parser round-trip, escape/unescape, dedup
  key stability, JID normalisation), database_stress (14 cases
  exercising rapid writes, large messages, deep LMC chains, MAM
  dedup, concurrent contacts)
- Functional: history persistence across reconnects, export /
  import round-trip with content equality, MUC migration,
  timestamp normalisation across timezones
- Bench harness P1\u2013P5 (synthetic load: bulk insert, time-range
  read, page-up scroll, MAM ingest, mixed workload) and failure
  modes F1\u2013F17 (page-up cursor and forward-iteration symmetry,
  oversized lines, MAM dedup, LMC depth and cycles, BOM/CRLF,
  missing log, empty file, mtime+inode flip, broken corrects, etc.)
- All bench tests integrate with the existing make targets and
  emit CSV rows for baseline comparison

Author: jabber.developer2 <jabber.developer2@jabber.space>
Reviewed-by: jabber.developer <jabber.developer@jabber.space>
2026-05-05 19:26:07 +00:00
e229ed1281 fix(ai): return CURL_WRITEFUNC_ERROR on oversized response
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Check spelling (pull_request) Successful in 1m10s
CI Code / Code Coverage (pull_request) Successful in 2m51s
CI Code / Linux (arch) (pull_request) Failing after 3m39s
CI Code / Linux (debian) (pull_request) Failing after 5m13s
CI Code / Linux (ubuntu) (pull_request) Failing after 5m21s
Return CURL_WRITEFUNC_ERROR instead of realsize when the response
exceeds 10MB. This causes curl to abort the transfer immediately
instead of continuing to stream data.
2026-05-01 19:15:46 +00:00
f4221e27ac fix(ai): fix leak from ai_get_provider_key
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Check coding style (pull_request) Successful in 1m54s
CI Code / Linux (debian) (pull_request) Failing after 3m0s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m53s
2026-05-01 18:50:21 +00:00
cdcc45b7c6 ref(pref): add newline at the end of the file 2026-05-01 18:47:46 +00:00
f133d81a05 fix(ai): use atomic operations for refcounting
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m54s
Replace plain ref_count++/-- with g_atomic_int_inc and
g_atomic_int_dec_and_test for both AIProvider and AISession refcounts.
Prevents data races when ref/unref is called from worker threads.
2026-05-01 18:24:57 +00:00
a16237d16b fix(ai): convert \n escape sequences to actual newlines in LLM responses
Some checks failed
CI Code / Check spelling (pull_request) Successful in 51s
CI Code / Check coding style (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m5s
CI Code / Linux (arch) (pull_request) Failing after 3m5s
CI Code / Code Coverage (pull_request) Successful in 2m59s
2026-05-01 18:21:47 +00:00
cfe6ea46e2 feat(ai): disable request storage via store:false parameter
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m22s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Check spelling (pull_request) Successful in 1m50s
CI Code / Linux (debian) (pull_request) Failing after 2m52s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Code Coverage (pull_request) Successful in 2m49s
Add store:false to AI API requests to prevent OpenAI, Perplexity, and
other providers from storing conversation data or using it for training.

Both the OpenAI Responses API and Perplexity API support the store
parameter to control whether requests are persisted. Setting it to false
ensures conversations remain private and are not retained by the provider.

Privacy:
- Requests are not stored by AI providers
- Conversations are not used for model training
- Aligns with CProof's privacy-first design
2026-05-01 17:05:44 +00:00
2fc7f3d672 fix(ai): validate aiwin pointer to prevent UAF when window closed
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m11s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m46s
Prevent use-after-free in the AI request worker thread when the user
closes the AI window during the 60-second HTTP request timeout. Without
this fix, the worker may dereference a dangling pointer and crash or
exhibit undefined behavior.

The _ai_request_thread function in ai_client.c previously cast user_data
to ProfAiWin* and used it directly without verifying the window was still
alive. Added wins_ai_exists() to iterate through all AI windows and check
if the pointer is still valid before use.

Safety:
- wins_ai_exists() iterates all AI windows (handles multiple AI windows)
- Worker skips UI update if window was freed, just cleans up
- Logs warning when dangling pointer is detected
2026-05-01 17:03:47 +00:00
fe0a46da58 refactor(ai): add UI display helpers
Some checks failed
CI Code / Check spelling (pull_request) Successful in 54s
CI Code / Check coding style (pull_request) Successful in 2m4s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (arch) (pull_request) Failing after 3m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m55s
Add static _aiwin_display_error() and
_aiwin_display_response() helpers to reduce mutex repetition and
log warnings when aiwin is NULL or invalid.
2026-05-01 16:36:07 +00:00
cead417e78 refactor(ai): remove redundant callback layer from AI client
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m55s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m42s
The ai_response_cb and ai_error_cb parameters were always passed the
same functions (aiwin_display_response and aiwin_display_error),
making the callback indirection redundant and complicating the call
chain.

Remove ai_callback_data_t, _ai_callback_invoke(), and
_ai_invoke_callback(). Simplify _ai_request_thread and ai_send_prompt
to directly call aiwin_display_error() and aiwin_display_response()
when user_data is a valid ProfAiWin*.
2026-05-01 13:34:00 +00:00
00f11eb704 fix(ai): guard NULL search_str and update tests for new cycling behavior
Some checks failed
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m55s
CI Code / Linux (arch) (pull_request) Failing after 3m45s
Update ai_providers_find() to handle NULL search_str safely by treating it
as an empty string, preventing a segfault in strdup() within autocomplete_complete().

Rename test_ai_providers_find_case_sensitive to test_ai_providers_find_case_insensitive
to reflect the new case-insensitive matching contract. Update all affected tests to
expect cycling behavior (NULL/empty returns first provider) and use auto_gchar for
automatic memory management.

Use `gchar` instead of `char` in ai_providers_find for consistency
2026-05-01 13:08:28 +00:00
4913a3d5a4 ref(ai): remove inaccurate logging 2026-05-01 12:56:57 +00:00
634fb7d7eb fix(ai): standardize lock handling in _ai_invoke_callback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Failing after 1m6s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m11s
CI Code / Linux (arch) (pull_request) Failing after 3m19s
Take the global lock mutex before invoking all callbacks (success
and error) to ensure thread-safety. Previously some paths took the
lock and others did not, creating asymmetric behavior that could
lead to data races when callbacks access UI state.
2026-05-01 11:35:09 +00:00
461c0c32dd refactor(ai): use stateful autocomplete for provider name matching
Some checks failed
CI Code / Check spelling (pull_request) Successful in 57s
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Code Coverage (pull_request) Failing after 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m47s
Replace the manual GHashTable iteration in ai_providers_find() with the
existing stateful autocomplete system. Provider names are now kept in
sync via autocomplete_add/remove during ai_add_provider/ai_remove_provider,
and ai_providers_find delegates to autocomplete_complete() for deterministic
tab-completion cycling through sorted results.

This eliminates manual GList allocation, iteration, and cleanup, while
providing consistent forward/backward cycling behavior shared by all
autocomplete callers.
2026-04-30 23:16:12 +00:00
002a6ed15b refactor(ai): flatten autocomplete into sequential prefix-matching chain
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m5s
CI Code / Check coding style (pull_request) Successful in 2m7s
CI Code / Linux (arch) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m54s
CI Code / Linux (debian) (pull_request) Successful in 4m28s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m41s
Replace the deeply nested conditional tree in _ai_autocomplete() with a
flat sequential chain of autocomplete_param_with_*() calls. Each call
tries a specific command prefix (e.g., "/ai set provider", "/ai start")
and returns immediately on match — a common, reliable pattern for
command-line tab completion.

The previous design nested provider-name autocomplete inside
g_strv_length() and g_strcmp0() checks, meaning /ai<tab> and /ai <tab>
would never reach the subcommand matcher. The new flat structure ensures
every prefix is tried in order, with the most specific prefixes first
and the generic /ai subcommand fallback last.

This pattern — sequential prefix matching with early return — is the
standard approach for autocomplete dispatch: each handler owns its
prefix, no shared state or argument parsing is needed, and adding a
new subcommand is a single append to the chain.
2026-04-30 23:10:14 +00:00
dc75f16221 fix(ai): move generic subcommand autocomplete outside nested conditionals
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (debian) (pull_request) Successful in 4m22s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m35s
The generic `/ai <subcommand>` tab-completion was nested inside conditional
blocks that prevented it from firing in common cases — for example,
`/ai<tab>` or `/ai <tab>` would never match subcommands because the
`autocomplete_param_with_ac()` call was guarded by argument count checks
and nested inside the explicit handler branches.

Move the fallback subcommand autocomplete to execute unconditionally after
all explicit handler branches, ensuring `/ai <tab>` always lists available
subcommands regardless of argument count or which subcommand was typed.

Also move the `/ai set` subcommand fallback outside the inner else-block
so it runs whenever args[0] is "set", not only when num_args >= 2.
2026-04-30 22:51:07 +00:00
da0bf43d73 fix(ai): join multi-word arguments in cmd_ai_correct
Some checks failed
CI Code / Check spelling (pull_request) Successful in 24s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m57s
CI Code / Linux (debian) (pull_request) Successful in 4m24s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m37s
Use g_strjoinv to join all arguments from args[1] onwards into a
single prompt string. Previously only args[1] was used, truncating
multi-word prompts like '/ai correct hello world' to just 'hello'
2026-04-30 19:24:39 +00:00
9e1f9b666e fix(ai): fix provider_name leak and const-cast in cmd_ai_start
Some checks failed
CI Code / Check spelling (pull_request) Successful in 55s
CI Code / Linux (arch) (pull_request) Failing after 3m25s
CI Code / Check coding style (pull_request) Successful in 1m38s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Code Coverage (pull_request) Successful in 2m50s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m12s
Use auto_gchar temporary for g_strndup result in cmd_ai_start to
prevent memory leak. Fix const-cast warning by using a proper
non-const temporary variable.
2026-04-30 19:14:49 +00:00
93ad7379e2 fix(ai): align ai_list_providers with documented contract
All checks were successful
CI Code / Check coding style (pull_request) Successful in 48s
CI Code / Check spelling (pull_request) Successful in 33s
CI Code / Linux (debian) (pull_request) Successful in 4m47s
CI Code / Linux (arch) (pull_request) Successful in 5m29s
CI Code / Code Coverage (pull_request) Successful in 2m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m29s
Remove ai_provider_ref() from ai_list_providers() so the returned
providers do not need to be unref'd by the caller. This matches the
header docstring which states "caller must not free the list or
providers". Also update all callers (cmd_ai_providers, tests) to
remove redundant ai_provider_unref() calls.
2026-04-30 19:09:46 +00:00
caa0b3ccba fix(ai): remove redundant g_strdup on api_key assignment
Some checks failed
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Code Coverage (pull_request) Successful in 2m47s
CI Code / Linux (debian) (pull_request) Successful in 4m21s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m53s
ai_get_provider_key() already returns a freshly g_strdup'd copy of
the API key. The outer g_strdup in ai_session_create() created an
unnecessary second copy, leaking the original pointer.
2026-04-30 19:06:49 +00:00
c9a5239117 fix(ai): remove API key from debug log
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m59s
Remove the log_debug line in ai_client.c that leaked the first 10
characters of the API key into logs. This is also contained a memory leak (g_strndup
return value was never freed).
2026-04-30 19:05:33 +00:00
25e0459979 fix(ai): persist API keys to disk after set/remove operations
API keys set via /ai set token were only stored in memory and lost
on client restart. prefs_ai_set_token() and prefs_ai_remove_token()
mutated the in-memory prefs but never called _save_prefs(), unlike
other preference setters in the same file.

Both functions now call _save_prefs() after mutating the keyfile,
ensuring API keys persist across client restarts as advertised.
2026-04-30 19:01:10 +00:00
bccd3ecded fix(ai): remove duplicate assistant response in the message history
Some checks failed
CI Code / Check coding style (pull_request) Successful in 44s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Code Coverage (pull_request) Successful in 2m46s
2026-04-30 18:05:51 +00:00
cff05ca802 ref(ai): move unfitting functions out of window.c; create aiwin.c
Some checks failed
CI Code / Check coding style (pull_request) Failing after 34s
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Linux (arch) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m16s
CI Code / Linux (debian) (pull_request) Failing after 6m16s
CI Code / Code Coverage (pull_request) Successful in 7m12s
2026-04-30 18:01:24 +00:00
9c8ad57b59 ref(ai): add stub, fix includes, move aiwin_send_message location
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Linux (debian) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m13s
CI Code / Code Coverage (pull_request) Successful in 2m54s
2026-04-30 17:41:36 +00:00
1f1770bd58 fix(ai): set correct links in tests, fix headers 2026-04-30 17:02:54 +00:00
e43e8378b0 feat(ai): add AI client with multi-provider support and UI
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom providers) to provide
AI-assisted responses within the profanity client.

The implementation includes:

- src/ai/ai_client.c/h: Core AI client with provider management,
  session handling, and async HTTP request handling via libcurl.
  Supports per-provider API keys stored in preferences, reference-
  counted sessions, and conversation history tracking.

- src/ui/window.c/window_list.c: New AI window type (ProfAiWin) for
  displaying AI conversations, with response streaming and error
  display capabilities.

- Command integration: New `/ai` command (cmd_defs.c, cmd_funcs.c)
  for creating sessions, sending prompts, and managing providers.
  Provider autocomplete support in cmd_ac.c.

- Preferences integration: API keys for providers are persisted in
  the preferences system (config/preferences.c).

- Unit tests: 472 lines of comprehensive tests covering provider
  management, session lifecycle, JSON escaping, and autocomplete
  (tests/unittests/test_ai_client.c).

Architecture decisions:
- Asynchronous design: HTTP requests run on a separate thread to
  avoid blocking the main UI loop. Callbacks are invoked on the main
  thread via direct function call (profanity uses ncurses, not GLib
  main loop).
- Reference counting: Both AIProvider and AISession use ref counting
  for safe shared ownership.
- Response size limit: 10MB cap on HTTP responses to prevent OOM.
2026-04-29 19:23:33 +00:00
0feacbc9da ci: simulate Pikaur flag duplication in Arch Linux CI
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 30s
CI Code / Code Coverage (pull_request) Successful in 3m1s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m31s
CI Code / Linux (debian) (pull_request) Successful in 7m36s
CI Code / Linux (arch) (pull_request) Successful in 9m52s
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 32s
CI Code / Linux (debian) (push) Successful in 4m51s
CI Code / Linux (arch) (push) Successful in 5m35s
CI Code / Linux (ubuntu) (push) Successful in 6m36s
CI Code / Code Coverage (push) Successful in 7m21s
Inject system flags from /etc/makepkg.conf into the CI environment to
detect build collisions caused by Pikaur's configuration bug.

Pikaur's cascading logic causes flags from /etc/makepkg.conf to be
merged into the build environment. This creates collisions with flags
defined in the project's Makefile.am (e.g., duplicate -D_FORTIFY_SOURCE
definitions), which can cause builds to fail for users.

By exporting these flags in the CI environment, we ensure that any
code change that is sensitive to flag duplication will trigger a
failure in our Arch Linux CI matrix, preventing broken builds from
reaching users.

Implementation details:
- Detects Arch Linux via /etc/os-release.
- Uses a sed-based flattener to handle multi-line variables and
  trailing backslashes in makepkg.conf.
- Exports the flags to the shell environment so that 'configure'
  and 'make' inherit them naturally, maintaining parity with a
  real Pikaur session.
2026-04-21 10:17:44 +00:00
0722dc9e36 build(pikaur): Fix failure due to duplicated flag and warnings
All checks were successful
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m42s
CI Code / Linux (debian) (pull_request) Successful in 6m47s
CI Code / Code Coverage (pull_request) Successful in 7m3s
CI Code / Linux (arch) (pull_request) Successful in 9m33s
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 39s
CI Code / Code Coverage (push) Successful in 2m53s
CI Code / Linux (debian) (push) Successful in 4m8s
CI Code / Linux (ubuntu) (push) Successful in 4m35s
CI Code / Linux (arch) (push) Successful in 10m28s
2026-04-13 19:52:24 +00:00
53 changed files with 6465 additions and 387 deletions

View File

@@ -37,6 +37,7 @@ 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 \
@@ -78,7 +79,8 @@ 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/ui/tray.h src/ui/tray.c \
src/ai/ai_client.h src/ai/ai_client.c
unittest_sources = \
src/xmpp/contact.c src/xmpp/contact.h src/common.c \
@@ -89,6 +91,7 @@ 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 \
@@ -138,6 +141,7 @@ 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 \
@@ -174,6 +178,7 @@ 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
@@ -198,8 +203,13 @@ 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 = \

View File

@@ -204,6 +204,13 @@ 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

View File

@@ -396,12 +396,11 @@ 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 -Wnull-dereference -Wpointer-arith"
AM_CFLAGS="$AM_CFLAGS -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 Normal file

File diff suppressed because it is too large Load Diff

275
src/ai/ai_client.h Normal file
View File

@@ -0,0 +1,275 @@
#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 */

View File

@@ -66,8 +66,11 @@
#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);
@@ -137,6 +140,7 @@ 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);
@@ -280,11 +284,16 @@ 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;
@@ -456,7 +465,11 @@ static Autocomplete* all_acs[] = {
&vcard_togglable_param_ac,
&vcard_address_type_ac,
&force_encryption_ac,
&force_encryption_policy_ac
&force_encryption_policy_ac,
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_remove_subcommands_ac
};
static GHashTable* ac_funcs = NULL;
@@ -1146,6 +1159,7 @@ 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");
@@ -1168,6 +1182,27 @@ 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");
@@ -1443,6 +1478,7 @@ 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
@@ -1806,6 +1842,14 @@ _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;
@@ -4441,4 +4485,167 @@ _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;
}

View File

@@ -1882,6 +1882,7 @@ 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>]",
@@ -1889,12 +1890,14 @@ 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." },
@@ -2784,6 +2787,71 @@ 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)
};

View File

@@ -81,7 +81,6 @@
#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"
@@ -117,6 +116,8 @@
#include "tools/clipboard.h"
#endif
#include "ai/ai_client.h"
#ifdef HAVE_PYTHON
#include "plugins/python_plugins.h"
#endif
@@ -6785,6 +6786,15 @@ 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) {
@@ -8457,7 +8467,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) {
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) {
cons_show("Unknown command: %s", inp);
cons_alert(NULL);
return TRUE;
@@ -8503,6 +8513,12 @@ _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;
}
@@ -10771,6 +10787,500 @@ 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)
{

View File

@@ -166,6 +166,14 @@ 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);

View File

@@ -66,6 +66,7 @@
#define PREF_GROUP_MUC "muc"
#define PREF_GROUP_PLUGINS "plugins"
#define PREF_GROUP_EXECUTABLES "executables"
#define PREF_GROUP_AI "ai"
#define INPBLOCK_DEFAULT 1000
@@ -1706,6 +1707,162 @@ _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.
@@ -1865,6 +2022,9 @@ _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;
}
@@ -2142,6 +2302,10 @@ _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:
@@ -2319,6 +2483,10 @@ _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;
}

View File

@@ -183,6 +183,9 @@ 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,
@@ -356,4 +359,17 @@ 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

View File

@@ -93,6 +93,7 @@ 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);

View File

@@ -30,10 +30,12 @@
#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"
@@ -46,23 +48,63 @@
// 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);
@@ -429,6 +471,15 @@ 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) {
@@ -437,6 +488,7 @@ 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));
@@ -452,6 +504,7 @@ 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;
}
@@ -478,6 +531,12 @@ 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) {
@@ -486,6 +545,7 @@ 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;
}
}
@@ -613,6 +673,7 @@ 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;
}

View File

@@ -54,7 +54,6 @@ 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;
@@ -145,7 +144,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: unparseable timestamp in %s at offset %ld: %s",
log_warning("flatfile: unparsable timestamp in %s at offset %ld: %s",
state->filepath, (long)pos, ts_str);
g_free(ts_str);
return;
@@ -206,6 +205,15 @@ _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;
@@ -294,7 +302,6 @@ 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);
}
@@ -719,20 +726,28 @@ _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;
// Reset cursor for filtered (non-Page-Up) queries
if (start_time)
state->cursor_offset = -1;
// 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;
// Determine read byte-range using the sparse index
off_t read_from = state->bom_len;
off_t read_to = state->stamp.size;
// 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
// 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) {
GDateTime* edt = g_date_time_new_from_iso8601(end_time, NULL);
if (edt) {
gint64 end_epoch = g_date_time_to_unix(edt);
@@ -745,8 +760,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
@@ -777,6 +792,15 @@ _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)
@@ -849,10 +873,6 @@ _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)) {

View File

@@ -24,10 +24,14 @@
// --- Constants ---
#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 */
#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 */
// --- Shared global ---
@@ -64,8 +68,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
{
@@ -88,7 +92,6 @@ 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))
@@ -126,6 +129,11 @@ 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,

View File

@@ -16,6 +16,7 @@
#include <sys/stat.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -338,6 +339,52 @@ 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
// =========================================================================
//
@@ -421,11 +468,13 @@ 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) {
if (to_jid && strlen(to_jid) > 0
&& g_strcmp0(to_jid, "(null)") != 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) {
if (to_resource && strlen(to_resource) > 0
&& g_strcmp0(to_resource, "(null)") != 0) {
auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource);
g_string_append_printf(meta, "|to_res:%s", safe_tores);
}
@@ -436,8 +485,11 @@ 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.
GString* sender = g_string_new(from_jid ? from_jid : "unknown");
if (from_resource && strlen(from_resource) > 0) {
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) {
// 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++) {

View File

@@ -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;
}

View File

@@ -65,6 +65,12 @@ 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)

View File

@@ -37,8 +37,10 @@
#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"
@@ -47,7 +49,7 @@
#include "event/common.h"
#include "plugins/plugins.h"
#include "ui/inputwin.h"
#include "ui/window_list.h"
#include "ui/window.h"
#include "xmpp/chat_session.h"
#include "xmpp/session.h"
#include "xmpp/xmpp.h"
@@ -284,3 +286,30 @@ 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);
}

View File

@@ -51,6 +51,7 @@ 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.

View File

@@ -69,6 +69,7 @@
#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"
@@ -249,6 +250,7 @@ _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();

61
src/ui/aiwin.c Normal file
View File

@@ -0,0 +1,61 @@
/*
* 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);
}

View File

@@ -52,6 +52,7 @@
#include "config/theme.h"
#include "config/preferences.h"
#include "database.h"
#include "ui/ui.h"
#include "ui/statusbar.h"
#include "ui/inputwin.h"
@@ -83,6 +84,7 @@ 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);
@@ -313,6 +315,7 @@ 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);
@@ -603,6 +606,17 @@ _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)
{

View File

@@ -386,6 +386,8 @@ 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);
@@ -445,4 +447,9 @@ 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

View File

@@ -62,6 +62,7 @@
#define PROFXMLWIN_MEMCHECK 87333463
#define PROFPLUGINWIN_MEMCHECK 43434777
#define PROFVCARDWIN_MEMCHECK 68947523
#define PROFAIWIN_MEMCHECK 91827364
typedef enum {
FIELD_HIDDEN,
@@ -144,7 +145,8 @@ typedef enum {
WIN_PRIVATE,
WIN_XML,
WIN_PLUGIN,
WIN_VCARD
WIN_VCARD,
WIN_AI
} win_type_t;
typedef enum {
@@ -258,4 +260,16 @@ 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

View File

@@ -36,6 +36,7 @@
#include "config.h"
#include "database.h"
#include "ui/win_types.h"
#include "ui/window_list.h"
#include <stdlib.h>
@@ -62,6 +63,7 @@
#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.";
@@ -329,6 +331,22 @@ 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)
{
@@ -407,6 +425,13 @@ 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);
}
@@ -454,6 +479,10 @@ win_get_tab_identifier(ProfWin* window)
{
return strdup("vcard");
}
case WIN_AI:
{
return strdup("ai");
}
}
assert(FALSE);
}
@@ -536,6 +565,12 @@ 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);
}
@@ -662,6 +697,17 @@ 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;
}

View File

@@ -44,9 +44,11 @@
#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"
@@ -374,6 +376,8 @@ wins_set_current_by_num(int i)
status_bar_active(1, conswin->type, "console");
}
}
} else {
log_error("Window not found for index %d", i);
}
}
@@ -714,6 +718,20 @@ 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)
{
@@ -841,7 +859,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) {
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) {
result = g_slist_append(result, window);
}
curr = g_list_next(curr);
@@ -849,6 +867,45 @@ 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)
{

View File

@@ -63,6 +63,8 @@ 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);

View File

@@ -111,6 +111,17 @@ 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);
}

View File

@@ -70,9 +70,12 @@ 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 "?";
}

View File

@@ -24,8 +24,7 @@ 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

View File

@@ -105,26 +105,25 @@ 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.
@@ -160,14 +159,15 @@ 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,7 +180,8 @@ 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();
@@ -209,10 +210,14 @@ 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",
@@ -309,21 +314,29 @@ 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;
}
@@ -332,14 +345,16 @@ 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;
@@ -364,8 +379,10 @@ 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;
}
@@ -399,15 +416,23 @@ 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);
@@ -423,10 +448,14 @@ 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;
@@ -461,10 +490,14 @@ 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;
@@ -507,24 +540,36 @@ 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);
@@ -539,11 +584,13 @@ 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;
@@ -552,7 +599,8 @@ 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);
@@ -565,7 +613,8 @@ 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;
@@ -583,8 +632,7 @@ 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);
}
@@ -614,13 +662,19 @@ 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 (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 (!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",
@@ -631,15 +685,23 @@ 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;
}

View File

@@ -7,7 +7,7 @@
* bench_failure_modes.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* F1F15 failure-injection tests. Each test crafts a deliberately corrupt
* F1F17 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,6 +21,8 @@
* 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
@@ -46,10 +48,11 @@
#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
@@ -106,7 +109,8 @@ 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);
}
@@ -121,15 +125,18 @@ 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++;
@@ -145,7 +152,10 @@ 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;
@@ -179,7 +189,8 @@ 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.
@@ -224,7 +235,8 @@ 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");
@@ -259,14 +271,17 @@ 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");
@@ -293,7 +308,8 @@ 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
@@ -334,7 +350,8 @@ 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");
@@ -373,7 +390,8 @@ 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");
@@ -409,7 +427,8 @@ 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");
@@ -447,7 +466,8 @@ 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");
@@ -485,7 +505,8 @@ 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");
@@ -515,7 +536,8 @@ 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");
@@ -560,7 +582,8 @@ 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);
@@ -578,6 +601,226 @@ 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
@@ -591,9 +834,12 @@ 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;
@@ -617,6 +863,8 @@ 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);

View File

@@ -68,9 +68,12 @@ 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;
@@ -95,8 +98,7 @@ 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
@@ -155,7 +157,8 @@ 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);
@@ -167,7 +170,8 @@ 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);
@@ -176,7 +180,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,
@@ -200,7 +204,8 @@ 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;
@@ -224,9 +229,12 @@ 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;
}
@@ -257,7 +265,8 @@ 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",
@@ -337,8 +346,11 @@ 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;
@@ -373,7 +385,8 @@ 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++) {
@@ -381,7 +394,11 @@ 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",
@@ -413,12 +430,17 @@ 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);
@@ -426,7 +448,8 @@ 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",

View File

@@ -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,11 +77,16 @@ 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;
@@ -121,7 +126,8 @@ 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) {
@@ -191,7 +197,8 @@ 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;
}
@@ -229,7 +236,8 @@ 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);
@@ -297,8 +305,10 @@ 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;
}
@@ -330,7 +340,8 @@ 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++) {
@@ -359,7 +370,8 @@ 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,
@@ -380,7 +392,8 @@ _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;
}
@@ -406,7 +419,8 @@ 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 : "");
@@ -419,11 +433,16 @@ 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;
}
@@ -445,7 +464,8 @@ 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;
}
@@ -454,7 +474,8 @@ 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);

View File

@@ -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,14 +100,50 @@ 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

View File

@@ -57,16 +57,14 @@
// ---------------------------------------------------------------------------
// 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,
@@ -109,20 +107,44 @@ 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;
}
@@ -204,8 +226,26 @@ 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]);
@@ -265,24 +305,35 @@ 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*
@@ -341,13 +392,15 @@ 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];
@@ -358,7 +411,8 @@ 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);
@@ -379,12 +433,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

View File

@@ -0,0 +1,100 @@
/*
* 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;
}

View File

@@ -0,0 +1,25 @@
#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

View File

@@ -0,0 +1,117 @@
#!/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()

View File

@@ -59,9 +59,22 @@
#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 }
#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 \
}
#ifdef HAVE_SQLITE
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
@@ -81,9 +94,9 @@ main(int argc, char* argv[])
int group = 0; /* 0 = all groups */
if (argc > 1) {
group = atoi(argv[1]);
if (group < 1 || group > 4) {
if (group < 1 || group > 5) {
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
return 1;
}
}
@@ -304,6 +317,39 @@ 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
{
@@ -315,6 +361,7 @@ 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);

View File

@@ -20,47 +20,47 @@
#include "proftest.h"
/* Number of parallel test groups for CI builds */
#define TEST_GROUPS 4
#define TEST_GROUPS 5
/* 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,7 +439,10 @@ 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) {
@@ -462,7 +465,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;
@@ -483,14 +486,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);
}
@@ -500,24 +503,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;
}
@@ -526,34 +529,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(&regex, 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(&regex, output_buffer, 0, NULL, 0);
if (ret == 0) {
regfree(&regex);
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);
@@ -563,23 +566,21 @@ prof_output_regex(const char *pattern)
fprintf(stderr, "%s", output_buffer);
}
fprintf(stderr, "\n");
regfree(&regex);
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);
@@ -589,7 +590,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");
@@ -597,15 +598,14 @@ 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,6 +625,5 @@ 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'/>");
}

View File

@@ -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

View File

@@ -0,0 +1,241 @@
/*
* 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();
}

View File

@@ -0,0 +1,28 @@
#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

View File

@@ -0,0 +1,157 @@
/*
* 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();
}

View File

@@ -0,0 +1,10 @@
#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

View File

@@ -0,0 +1,134 @@
/*
* 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);

View File

@@ -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("# profanity chat log — UTF-8, LF line endings"));
assert_null(ff_parse_line("# cproof chat log — UTF-8, LF line endings"));
}
void

View File

@@ -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,15 +911,23 @@ 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);
}

View File

@@ -0,0 +1,41 @@
/*
* 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;
}

View File

@@ -36,6 +36,7 @@
#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"
@@ -659,6 +660,121 @@ 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),