mindfork 0.11.0

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
//! Keeping the chat-content search index (`cache.db`) in step with the chat
//! files, and answering content queries. See
//! docs/research/chat-content-search.md §3 (sync) and §7a (stage 1 design).
//!
//! **The writer is already single**, which is what makes this small: the
//! orchestrator is the sole writer of chats (architecture §1), so everything
//! that happens inside the running app has an exact hook — index the chat right
//! after it is saved ([`Orchestrator::index_saved_chat`], called from
//! `flush_saves`). The 800 ms save debounce already coalesces a burst of
//! streaming updates into one write, so it coalesces indexing too, and the
//! message-level diff inside [`CacheDb::index_chat`] reduces that write to a
//! single row.
//!
//! What is left is everything that happens **outside** the app — `mindfork
//! import`, `restore`, a hand-edited file, data synced from another machine, or
//! simply a `cache.db` that was deleted. Those are covered by
//! [`spawn_reconcile`] at startup: a stat-only walk of `chats/` costs ~0.3 ms on
//! the real corpus, so it runs unconditionally on every launch.
//!
//! Everything here is **best effort**. The index is derived data: a failure is
//! logged and the app carries on — a search that misses a chat is a nuisance, a
//! save that fails because of the index would be a bug.

use std::collections::HashMap;
use std::sync::Arc;

use uuid::Uuid;

use crate::app::events::AppEvent;
use crate::entities::chat::Chat;
use crate::entities::message::{Message, MessageRole};
use crate::features::chat_search::{self, SearchGroup, SearchHit};
use crate::features::chat_search_sort::SortMode;
use crate::shared::storage::Storage;
use crate::shared::storage::cache::{IndexScope, IndexedMessage, MessageHit};

use super::{ChatView, Orchestrator};

impl Orchestrator {
    /// Answers a content query from the chat list (`Ctrl+F`).
    ///
    /// Escaping happens **here**, not in the widget and not in `CacheDb`:
    /// `shared/storage` may not depend on `features` (FSD, research §7a), and
    /// keeping the rule in one place is what guarantees raw input never reaches
    /// `MATCH` — where ordinary text like `C++` or `cost-benefit` is a syntax
    /// error (research §4).
    ///
    /// A query that cannot search — nothing survived trigram's 3-character
    /// floor, or the search itself failed — answers `None`, i.e. "do not
    /// filter". A failure is logged, never raised as an `AppEvent::Error`: a
    /// half-typed query is not an error the user should be shown a popup about.
    pub(super) fn handle_search_chats(&self, query: String) {
        let chat_ids = match crate::features::chat_search::to_fts_query(&query) {
            None => None,
            Some(fts) => match self.storage.cache().search_chats(&fts) {
                Ok(ids) => Some(ids),
                Err(err) => {
                    // The length, not the text: what a user searched their
                    // conversations for is message text, which PRIVACY.md §6
                    // keeps out of the log.
                    tracing::warn!(query_chars = query.chars().count(), error = %format!("{err:#}"),
                        "chat content search failed");
                    None
                }
            },
        };
        let _ = self
            .evt_tx
            .send(AppEvent::ChatSearchResults { query, chat_ids });
    }

    /// Answers a message-level content query (`Ctrl+G` in the chat list).
    ///
    /// Escaping happens here for the same reason as in
    /// [`Self::handle_search_chats`], and an unsearchable query answers with no
    /// groups — the screen shows its empty state rather than an error popup.
    ///
    /// Grouping is done **here** and not in `CacheDb` because it needs what the
    /// orchestrator owns and the index does not: chat titles, the chat list's
    /// order, and the real position of a message inside its chat. See
    /// docs/history/chat-search-stage2.md §4 (fork S2).
    pub(super) fn handle_search_messages(&self, query: String, sort: SortMode) {
        let (groups, total) = self.message_search(&query, sort);
        let _ = self.evt_tx.send(AppEvent::MessageSearchResults {
            query,
            groups,
            total,
        });
    }

    /// The search itself (split out so it is testable without the loop).
    fn message_search(&self, query: &str, sort: SortMode) -> (Vec<SearchGroup>, usize) {
        let Some(fts) = chat_search::to_fts_query(query) else {
            return (Vec::new(), 0);
        };
        let cache = self.storage.cache();
        let hits = match cache.search_messages(&fts, chat_search::HIT_CAP) {
            Ok(hits) => hits,
            Err(err) => {
                // The length, not the text — see `handle_search_chats`.
                tracing::warn!(query_chars = query.chars().count(), error = %format!("{err:#}"),
                    "message content search failed");
                return (Vec::new(), 0);
            }
        };
        // Only pay for the count when the cap actually bit — otherwise the
        // rows we have *are* the total.
        let total = if hits.len() < chat_search::HIT_CAP {
            hits.len()
        } else {
            cache.count_matching_messages(&fts).unwrap_or(hits.len())
        };
        (self.group_hits(hits, query, sort), total)
    }

    /// Buckets hits into conversations **in the order the chat list is
    /// currently showing them** (fork S2 — the list's `Tab` toggle carries
    /// over, rather than the results quietly using a different order), and
    /// orders each conversation's hits by their real position in it.
    ///
    /// A conversation is a chat or one of its sub-agent transcripts, keyed by
    /// `(chat_id, sub_id)` (spec §11.2.1): a chat's group comes first, then
    /// one group per matched transcript in call order — the list's tree, in
    /// the results. A parent none of whose *own* messages match still gets a
    /// group, with no hits, so a transcript is never shown orphaned; the
    /// screen draws that header as "0 matches" and navigation skips it.
    ///
    /// A hit whose chat we do not have — deleted or hidden since it was indexed
    /// — is dropped: the index is derived data and may lag by a moment, and
    /// showing a result that cannot be opened is worse than showing one fewer.
    /// The same goes for a transcript the file no longer holds (its exchange
    /// was taken back).
    fn group_hits(&self, hits: Vec<MessageHit>, query: &str, sort: SortMode) -> Vec<SearchGroup> {
        let mut by_scope: HashMap<(Uuid, Option<Uuid>), Vec<MessageHit>> = HashMap::new();
        for hit in hits {
            by_scope
                .entry((hit.chat_id, hit.sub_id))
                .or_default()
                .push(hit);
        }

        let mut chats: Vec<&Chat> = self
            .chats
            .iter()
            .filter(|c| !c.is_hidden)
            .filter(|c| {
                by_scope.contains_key(&(c.id, None))
                    || c.children()
                        .any(|r| by_scope.contains_key(&(c.id, Some(r.id))))
            })
            .collect();
        chats.sort_by_key(|c| {
            std::cmp::Reverse(match sort {
                SortMode::Created => c.created_at,
                SortMode::Modified => c.modified_at,
            })
        });

        let mut groups = Vec::new();
        for chat in chats {
            let own = by_scope.remove(&(chat.id, None)).unwrap_or_default();
            groups.push(make_group(
                chat.id,
                None,
                &chat.title,
                &chat.messages,
                own,
                query,
            ));
            for run in chat.children() {
                if let Some(hits) = by_scope.remove(&(chat.id, Some(run.id))) {
                    groups.push(make_group(
                        run.id,
                        Some(chat.id),
                        &run.title,
                        &run.messages,
                        hits,
                        query,
                    ));
                }
            }
        }
        groups
    }

    /// The earliest message of the conversation `id` matching `query`, in real
    /// order — what `Enter` in the chat list's content mode opens it at. `id`
    /// may name a chat (its own messages — the transcripts are separate rows
    /// with their own first match) or a sub-agent transcript. `None` when the
    /// query is unsearchable, the search fails, or nothing in this
    /// conversation matches (then it opens at its tail, as a plain switch does).
    pub(super) fn first_match_in_chat(&self, id: Uuid, query: &str) -> Option<Uuid> {
        let fts = chat_search::to_fts_query(query)?;
        let (scope, messages) = match self.view(id)? {
            ChatView::Top(chat) => (IndexScope::Chat(id), &chat.messages),
            ChatView::Child { run, .. } => (IndexScope::Transcript(id), &run.messages),
        };
        let ids = self
            .storage
            .cache()
            .matching_messages_in_chat(&fts, scope)
            .inspect_err(|err| {
                tracing::warn!(chat = %id, error = %format!("{err:#}"),
                    "resolving the first match in a chat failed");
            })
            .ok()?;
        let order = message_order(messages);
        ids.into_iter()
            .filter_map(|id| order.get(&id).map(|pos| (*pos, id)))
            .min()
            .map(|(_, id)| id)
    }

    /// Brings one chat's index in line right after it was written to disk.
    ///
    /// Stats the file we have just written so the index records the same
    /// `(mtime_ms, size)` the next startup reconciliation will compare against —
    /// otherwise every launch would re-parse every chat.
    pub(super) fn index_saved_chat(&self, chat: &Chat) {
        if chat.is_hidden {
            self.forget_chat_index(chat.id);
            return;
        }
        let Some(info) = self.storage.json().chat_file_info(chat.id) else {
            tracing::warn!(chat = %chat.id, "could not stat a just-saved chat file for the search index");
            return;
        };
        let messages = indexed_messages(chat);
        if let Err(err) =
            self.storage
                .cache()
                .index_chat(chat.id, info.mtime_ms, info.size, &messages)
        {
            tracing::warn!(chat = %chat.id, error = %format!("{err:#}"),
                "failed to update the chat search index");
        }
    }

    /// Drops a chat from the index (it was hidden or deleted).
    pub(super) fn forget_chat_index(&self, chat_id: Uuid) {
        if let Err(err) = self.storage.cache().forget_chat(chat_id) {
            tracing::warn!(chat = %chat_id, error = %format!("{err:#}"),
                "failed to drop a chat from the search index");
        }
    }
}

/// One results group: the conversation's hits in its real message order,
/// snippets built. `id` is the chat's or the transcript's; `parent` is set for
/// a transcript.
fn make_group(
    id: Uuid,
    parent: Option<Uuid>,
    title: &str,
    messages: &[Message],
    mut hits: Vec<MessageHit>,
    query: &str,
) -> SearchGroup {
    let order = message_order(messages);
    hits.sort_by_key(|h| order.get(&h.message_id).copied().unwrap_or(usize::MAX));
    SearchGroup {
        chat_id: id,
        parent,
        title: title.to_string(),
        hits: hits
            .into_iter()
            .map(|h| SearchHit {
                message_id: h.message_id,
                role: h.role,
                ts: h.ts,
                snippet: chat_search::build_snippet(
                    &h.text,
                    query,
                    chat_search::SNIPPET_BUDGET_CHARS,
                ),
            })
            .collect(),
    }
}

/// The messages of a chat as the index stores them: the chat's own with no
/// `sub_id`, then every sub-agent transcript's with its run id — all under
/// the parent's `chat_id`, since they share its file (spec §9.3.2, §11.2.1).
///
/// Stage 1 indexes `message.text` only (fork F3): `thoughts` and tool-call
/// JSON would inflate the index and match on words the user never wrote.
/// Messages with nothing to index are skipped — a whitespace-only message
/// (a cancelled stream, a tool turn) carries no searchable content.
fn indexed_messages(chat: &Chat) -> Vec<IndexedMessage> {
    let own = chat.messages.iter().map(|m| (None, m));
    let runs = chat
        .children()
        .flat_map(|run| run.messages.iter().map(move |m| (Some(run.id), m)));
    own.chain(runs)
        .filter(|(_, m)| !m.text.trim().is_empty())
        .map(|(sub_id, m)| IndexedMessage {
            id: m.id,
            sub_id,
            role: role_str(m.role).to_string(),
            ts: m.timestamp.to_rfc3339(),
            text: m.text.clone(),
        })
        .collect()
}

/// `message_id → position in the conversation`, the only authority on the order
/// hits are shown in: the index's rowids only approximate it, since a message
/// whose text changed is deleted and re-inserted with a fresh one.
fn message_order(messages: &[Message]) -> HashMap<Uuid, usize> {
    messages
        .iter()
        .enumerate()
        .map(|(i, m)| (m.id, i))
        .collect()
}

/// The role as the index stores it. Unused by stage 1's chat-list filter;
/// stage 2's message-level screen shows it (see [`IndexedMessage`]).
fn role_str(role: MessageRole) -> &'static str {
    match role {
        MessageRole::System => "system",
        MessageRole::User => "user",
        MessageRole::Assistant => "assistant",
        MessageRole::Tool => "tool",
    }
}

/// Reconciles the index against `chats/` in the background (research §3).
///
/// Runs in `spawn_blocking`: this is synchronous file + SQLite I/O and must not
/// occupy the async runtime. Writes **per chat** (one transaction each), so
/// search stays usable while the pass runs — results are simply "everything
/// indexed so far", and a first run on an empty index takes ~350 ms.
///
/// One chat's failure is logged and skipped, never aborting the pass: a single
/// corrupt file must not cost the index of all the others.
pub(super) fn spawn_reconcile(storage: Arc<Storage>) {
    tokio::task::spawn_blocking(move || reconcile(&storage));
}

/// The reconciliation pass itself (sync — see [`spawn_reconcile`]). Returns
/// `(indexed, forgotten, unchanged)` for the summary log and for tests.
fn reconcile(storage: &Storage) -> (usize, usize, usize) {
    // Bookkeeping **first**, then the directory. The order matters for the
    // "indexed but the file is gone" pass below: a chat created between the two
    // reads must not look like one that vanished. Read this way it appears in
    // `files` but not in `indexed` (harmless — the guarded write below skips it,
    // the app having already indexed it); read the other way round it would
    // appear in `indexed` but not in `files`, and be forgotten.
    let indexed = match storage.cache().indexed_state() {
        Ok(state) => state,
        Err(err) => {
            tracing::warn!(error = %format!("{err:#}"), "search index: cannot read its bookkeeping");
            return (0, 0, 0);
        }
    };
    let files = match storage.json().chat_files() {
        Ok(files) => files,
        Err(err) => {
            tracing::warn!(error = %format!("{err:#}"), "search index: cannot list chat files");
            return (0, 0, 0);
        }
    };

    let (mut reindexed, mut forgotten, mut unchanged) = (0, 0, 0);
    for file in &files {
        let was = indexed.get(&file.id).copied();
        // Unchanged since we indexed it — the whole point of the stat walk.
        if was == Some((file.mtime_ms, file.size)) {
            unchanged += 1;
            continue;
        }
        reconcile_file(
            storage,
            file,
            was,
            &mut reindexed,
            &mut forgotten,
            &mut unchanged,
        );
    }

    // Indexed, but the file is gone (deleted outside the app, or a restore that
    // rolled the data back).
    for id in indexed.keys() {
        if !files.iter().any(|f| f.id == *id) && forget(storage, *id) {
            forgotten += 1;
        }
    }

    tracing::info!(
        indexed = reindexed,
        forgotten,
        unchanged,
        "chat search index reconciled"
    );
    (reindexed, forgotten, unchanged)
}

/// One file's reconciliation step: reads the chat and writes it into the index
/// (or drops a hidden one), bumping the counter that matches the outcome. A
/// failure is logged and skipped — one corrupt file must not cost the pass.
fn reconcile_file(
    storage: &Storage,
    file: &crate::shared::storage::json::ChatFileInfo,
    was: Option<(i64, u64)>,
    reindexed: &mut usize,
    forgotten: &mut usize,
    unchanged: &mut usize,
) {
    match storage.json().load_chat(file.id) {
        // A hidden chat is dropped rather than indexed: the chat list never
        // shows it, so neither should search. But it is recorded with *no
        // messages* instead of being forgotten — `forget_chat` also drops the
        // bookkeeping, so the next pass would find no record, parse the file
        // again, and drop it again, on every startup forever. Soft delete is
        // the only delete here (spec §12.3), so that set only grows: measured
        // on the real corpus, 43 of 171 chats were re-parsed every pass.
        // Indexing an empty message set removes any rows it already had and
        // records the file state, so the next pass skips it on the stat alone.
        Ok(Some(chat)) if chat.is_hidden => match write_indexed(storage, file, was, &[]) {
            Ok(true) => *forgotten += 1,
            Ok(false) => *unchanged += 1,
            Err(err) => tracing::warn!(chat = %file.id, error = %format!("{err:#}"),
                    "search index: failed to drop a hidden chat"),
        },
        Ok(Some(chat)) => match write_indexed(storage, file, was, &indexed_messages(&chat)) {
            Ok(true) => *reindexed += 1,
            Ok(false) => {
                *unchanged += 1;
                tracing::debug!(chat = %file.id,
                    "search index: a fresher write won, leaving this chat alone");
            }
            Err(err) => tracing::warn!(chat = %file.id, error = %format!("{err:#}"),
                "search index: failed to index a chat"),
        },
        // The file vanished between the walk and the read — the "file gone"
        // pass in [`reconcile`] will pick it up on the next run.
        Ok(None) => {}
        Err(err) => tracing::warn!(chat = %file.id, error = %format!("{err:#}"),
            "search index: skipped an unreadable chat file"),
    }
}

/// The reconciliation's write step, factored out because the guard on it is the
/// whole correctness argument of the pass.
///
/// `was` is the bookkeeping the pass saw **before** it read the file. Between
/// that read and this write the app may have saved and indexed the very same
/// chat, and our older snapshot must not win — so this is deliberately
/// [`CacheDb::index_chat_if_unchanged`] and never a plain
/// [`CacheDb::index_chat`]. Returns whether the write happened.
fn write_indexed(
    storage: &Storage,
    file: &crate::shared::storage::json::ChatFileInfo,
    was: Option<(i64, u64)>,
    messages: &[IndexedMessage],
) -> anyhow::Result<bool> {
    storage
        .cache()
        .index_chat_if_unchanged(file.id, was, file.mtime_ms, file.size, messages)
}

/// `forget_chat` with the failure logged; `true` if it succeeded (so the caller
/// counts only what actually left the index).
fn forget(storage: &Storage, id: Uuid) -> bool {
    match storage.cache().forget_chat(id) {
        Ok(()) => true,
        Err(err) => {
            tracing::warn!(chat = %id, error = %format!("{err:#}"),
                "search index: failed to forget a chat");
            false
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::entities::profile::Profile;
    use crate::shared::paths::Paths;

    fn storage() -> (tempfile::TempDir, Storage) {
        let dir = tempfile::tempdir().unwrap();
        let storage = Storage::open(Paths::with_root(dir.path())).unwrap();
        (dir, storage)
    }

    fn chat_with(texts: &[&str]) -> Chat {
        let profile = Profile::new("P", "sys");
        let mut chat = Chat::from_profile(&profile, "заголовок");
        for t in texts {
            chat.push_message(Message::user(*t));
        }
        chat
    }

    #[test]
    fn reconcile_indexes_new_files_and_skips_unchanged_ones() {
        // The property the design rests on: a second pass over untouched files
        // re-indexes nothing, so startup costs a stat walk rather than a parse.
        let (_d, storage) = storage();
        let chat = chat_with(&["содержимое про кошек"]);
        storage.json().save_chat(&chat).unwrap();

        let (indexed, forgotten, unchanged) = reconcile(&storage);
        assert_eq!((indexed, forgotten, unchanged), (1, 0, 0));
        assert_eq!(
            storage.cache().search_chats("\"кош\"").unwrap(),
            vec![chat.id]
        );

        let (indexed, forgotten, unchanged) = reconcile(&storage);
        assert_eq!(
            (indexed, forgotten, unchanged),
            (0, 0, 1),
            "an unchanged file must not be re-parsed"
        );
    }

    #[test]
    fn reconcile_reindexes_a_changed_file_and_forgets_a_deleted_one() {
        let (_d, storage) = storage();
        let mut chat = chat_with(&["первая версия текста"]);
        storage.json().save_chat(&chat).unwrap();
        reconcile(&storage);

        // Edited outside the app (import/restore/sync — the case the startup
        // pass exists for).
        chat.messages.clear();
        chat.push_message(Message::user("вторая версия текста"));
        storage.json().save_chat(&chat).unwrap();
        let (indexed, ..) = reconcile(&storage);
        assert_eq!(indexed, 1);
        assert_eq!(
            storage.cache().search_chats("\"вторая\"").unwrap(),
            vec![chat.id]
        );
        assert!(
            storage
                .cache()
                .search_chats("\"первая\"")
                .unwrap()
                .is_empty()
        );

        // The file is gone — so is its index entry.
        std::fs::remove_file(_d.path().join("chats").join(format!("{}.json", chat.id))).unwrap();
        let (_, forgotten, _) = reconcile(&storage);
        assert_eq!(forgotten, 1);
        assert!(
            storage
                .cache()
                .search_chats("\"вторая\"")
                .unwrap()
                .is_empty()
        );
        assert!(storage.cache().indexed_state().unwrap().is_empty());
    }

    #[test]
    fn reconcile_drops_a_hidden_chat() {
        let (_d, storage) = storage();
        let chat = chat_with(&["секретное содержимое"]);
        storage.json().save_chat(&chat).unwrap();
        reconcile(&storage);
        assert!(
            !storage
                .cache()
                .search_chats("\"секрет\"")
                .unwrap()
                .is_empty()
        );

        storage.json().hide_chat(chat.id).unwrap();
        let (_, forgotten, _) = reconcile(&storage);
        assert_eq!(forgotten, 1);
        assert!(
            storage
                .cache()
                .search_chats("\"секрет\"")
                .unwrap()
                .is_empty(),
            "a hidden chat must not show up in results"
        );
    }

    /// A hidden chat must be *recorded* as processed, not forgotten — otherwise
    /// every later pass finds no bookkeeping for it, parses the file again and
    /// drops it again. Soft delete is the only delete (spec §12.3), so that set
    /// only grows; the real corpus had 43 of 171 chats re-parsed on every single
    /// startup before this. Synthetic single-pass tests cannot see it, so this
    /// asserts on the *second* pass.
    #[test]
    fn a_hidden_chat_is_not_re_examined_on_every_pass() {
        let (_d, storage) = storage();
        let chat = chat_with(&["секретное содержимое"]);
        storage.json().save_chat(&chat).unwrap();
        reconcile(&storage);
        storage.json().hide_chat(chat.id).unwrap();
        assert_eq!(reconcile(&storage).1, 1, "the pass that drops it");

        // The pass after that must skip it on the stat alone.
        let (indexed, forgotten, unchanged) = reconcile(&storage);
        assert_eq!(
            (indexed, forgotten, unchanged),
            (0, 0, 1),
            "a hidden chat must count as unchanged, not be dropped again"
        );
        assert!(
            storage
                .cache()
                .indexed_state()
                .unwrap()
                .contains_key(&chat.id),
            "and it must stay in the bookkeeping — that is what stops the re-parse"
        );
    }

    #[test]
    fn reconcile_survives_a_corrupt_chat_file() {
        // One broken JSON file must not cost the index of all the others.
        let (_d, storage) = storage();
        let good = chat_with(&["исправное содержимое"]);
        storage.json().save_chat(&good).unwrap();
        let broken = Uuid::new_v4();
        std::fs::write(
            _d.path().join("chats").join(format!("{broken}.json")),
            "{ не json",
        )
        .unwrap();

        let (indexed, ..) = reconcile(&storage);
        assert_eq!(indexed, 1, "the healthy chat is still indexed");
        assert_eq!(
            storage.cache().search_chats("\"исправ\"").unwrap(),
            vec![good.id]
        );
    }

    #[test]
    fn the_pass_does_not_clobber_a_write_that_landed_while_it_read() {
        // The observed bug, in the order it actually happened: the pass stats
        // and reads a chat while it is still empty; the app then saves the real
        // conversation and indexes it; the pass only gets round to writing
        // afterwards. Its stale, empty snapshot must not win — unguarded it
        // does, and the chat disappears from search until the next launch.
        //
        // Driven through `write_indexed` rather than `reconcile`, because the
        // two halves have to be interleaved and a single synchronous pass
        // cannot be: `reconcile` reads its bookkeeping and writes in one go.
        let (_d, storage) = storage();
        let mut chat = chat_with(&[]);
        storage.json().save_chat(&chat).unwrap();

        // What the pass saw when it looked: nothing indexed, an empty file.
        let seen_by_the_pass = storage
            .cache()
            .indexed_state()
            .unwrap()
            .get(&chat.id)
            .copied();
        let file_as_read = storage.json().chat_file_info(chat.id).unwrap();
        let messages_as_read = indexed_messages(&chat);
        assert!(messages_as_read.is_empty());

        // Meanwhile the app saves the real conversation and indexes it.
        chat.push_message(Message::user("живая переписка"));
        storage.json().save_chat(&chat).unwrap();
        let now = storage.json().chat_file_info(chat.id).unwrap();
        storage
            .cache()
            .index_chat(chat.id, now.mtime_ms, now.size, &indexed_messages(&chat))
            .unwrap();

        // Only now does the pass write.
        let wrote =
            write_indexed(&storage, &file_as_read, seen_by_the_pass, &messages_as_read).unwrap();
        assert!(!wrote, "the stale snapshot must be refused");
        assert_eq!(
            storage.cache().search_chats("\"живая\"").unwrap(),
            vec![chat.id],
            "the live index survived the reconciliation"
        );
    }

    #[test]
    fn the_pass_writes_when_nothing_moved_underneath_it() {
        // The other half — the guard must not turn the pass into a no-op.
        let (_d, storage) = storage();
        let chat = chat_with(&["содержимое для индексации"]);
        storage.json().save_chat(&chat).unwrap();

        let seen = storage
            .cache()
            .indexed_state()
            .unwrap()
            .get(&chat.id)
            .copied();
        let file = storage.json().chat_file_info(chat.id).unwrap();
        let wrote = write_indexed(&storage, &file, seen, &indexed_messages(&chat)).unwrap();
        assert!(wrote);
        assert_eq!(
            storage.cache().search_chats("\"индексац\"").unwrap(),
            vec![chat.id]
        );
    }

    #[test]
    fn only_message_text_is_indexed() {
        // Fork F3: `thoughts` and tool-call JSON stay out of the index — they
        // would match on words the user never wrote.
        let profile = Profile::new("P", "sys");
        let mut chat = Chat::from_profile(&profile, "t");
        let mut msg = Message::assistant("видимый ответ");
        msg.thoughts = Some("скрытые рассуждения".into());
        chat.push_message(msg);
        chat.push_message(Message::assistant("   "));

        let indexed = indexed_messages(&chat);
        assert_eq!(indexed.len(), 1, "a blank message carries nothing to index");
        assert_eq!(indexed[0].text, "видимый ответ");
        assert_eq!(indexed[0].role, "assistant");
        assert!(!indexed[0].ts.is_empty());
    }
}