Skip to main content

ai_session_search/
models.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-FileCopyrightText: 2026 Nisarg Patel
3// SPDX-License-Identifier: Apache-2.0
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::num::NonZeroUsize;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
10#[serde(rename_all = "lowercase")]
11pub enum Provider {
12    Claude,
13    #[serde(rename = "claude-desktop")]
14    #[clap(name = "claude-desktop", alias = "claude_desktop")]
15    ClaudeDesktop,
16    Codex,
17    Cursor,
18    Antigravity,
19    Pi,
20    #[serde(rename = "prime-agent")]
21    #[clap(name = "prime-agent", alias = "prime_agent")]
22    PrimeAgent,
23    #[serde(rename = "aistudio")]
24    #[clap(name = "aistudio", alias = "ai-studio")]
25    AiStudio,
26    #[serde(rename = "gemini-cli")]
27    #[clap(name = "gemini-cli", alias = "gemini_cli")]
28    GeminiCli,
29}
30
31impl Provider {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Claude => "claude",
35            Self::ClaudeDesktop => "claude-desktop",
36            Self::Codex => "codex",
37            Self::Cursor => "cursor",
38            Self::Antigravity => "antigravity",
39            Self::Pi => "pi",
40            Self::PrimeAgent => "prime-agent",
41            Self::AiStudio => "aistudio",
42            Self::GeminiCli => "gemini-cli",
43        }
44    }
45
46    /// Human-readable session-source name for CLI, MCP, and documentation surfaces.
47    pub const fn display_name(self) -> &'static str {
48        match self {
49            Self::Claude => "Claude Code",
50            Self::ClaudeDesktop => "Claude Desktop local agent",
51            Self::Codex => "Codex",
52            Self::Cursor => "Cursor",
53            Self::Antigravity => "Antigravity",
54            Self::Pi => "Pi coding agent",
55            Self::PrimeAgent => "Prime Agent",
56            Self::AiStudio => "Google AI Studio",
57            Self::GeminiCli => "Gemini CLI",
58        }
59    }
60
61    /// Whether this source has a native CLI command that reopens a recorded session.
62    pub const fn supports_native_resume(self) -> bool {
63        matches!(
64            self,
65            Self::Claude | Self::Codex | Self::Pi | Self::PrimeAgent
66        )
67    }
68
69    /// Parse a `provider` value read back from the index. These columns are written from
70    /// [`Provider::as_str`], so a value this build cannot parse was written by a newer aise whose
71    /// provider set is larger (a variant ships without a schema-generation bump). The message
72    /// carries the recovery because only this decoder knows it: upgrading, never a reindex, which
73    /// enumerates the same rows and fails identically. Callers must surface the failure rather
74    /// than silently changing row ownership; the open path normally refuses such an index first
75    /// (`SchemaState::UnknownProviders`).
76    pub fn from_db_str(value: &str) -> Result<Self, String> {
77        value.parse().map_err(|_| {
78            format!(
79                "unrecognized provider in index: {value:?}; this aise {} does not recognize it, \
80                 so a newer aise wrote the index: upgrade aise (`aise package update`) and retry",
81                env!("CARGO_PKG_VERSION")
82            )
83        })
84    }
85}
86
87impl std::fmt::Display for Provider {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93impl std::str::FromStr for Provider {
94    type Err = String;
95
96    fn from_str(value: &str) -> Result<Self, Self::Err> {
97        match value.to_ascii_lowercase().as_str() {
98            "claude" => Ok(Self::Claude),
99            "claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(Self::ClaudeDesktop),
100            "codex" => Ok(Self::Codex),
101            "cursor" => Ok(Self::Cursor),
102            "antigravity" => Ok(Self::Antigravity),
103            "pi" => Ok(Self::Pi),
104            "prime-agent" | "prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
105            "aistudio" | "ai-studio" | "ai_studio" => Ok(Self::AiStudio),
106            "gemini-cli" | "gemini_cli" | "geminicli" => Ok(Self::GeminiCli),
107            // Canonical spellings only: the hyphen/underscore aliases accepted above are
108            // conveniences, so listing them here would imply four names for one provider.
109            other => Err(format!(
110                "unsupported provider: {other} — must be one of \"claude\", \"claude-desktop\", \"codex\", \"cursor\", \"antigravity\", \"pi\", \"prime-agent\", \"aistudio\", \"gemini-cli\""
111            )),
112        }
113    }
114}
115
116/// Normalized, closed message-role vocabulary shared by every provider adapter.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
118#[serde(rename_all = "lowercase")]
119#[clap(rename_all = "lowercase")]
120pub enum Role {
121    User,
122    Assistant,
123    Tool,
124    Slash,
125    Compaction,
126}
127
128impl Role {
129    pub fn as_str(self) -> &'static str {
130        match self {
131            Self::User => "user",
132            Self::Assistant => "assistant",
133            Self::Tool => "tool",
134            Self::Slash => "slash",
135            Self::Compaction => "compaction",
136        }
137    }
138
139    /// Parse a `role` value read back from the index. Written from [`Role::as_str`], so a failure
140    /// means index corruption or a variant added without a migration; the recovery is a full
141    /// reparse, and the message carries it because only this decoder knows that. Callers must
142    /// surface the failure rather than silently changing message authorship.
143    pub fn from_db_str(value: &str) -> Result<Self, String> {
144        value.parse().map_err(|_| {
145            format!(
146                "unrecognized role in index: {value:?}; stop AISE processes, then run \
147                 `aise reindex --full` and retry"
148            )
149        })
150    }
151}
152
153impl std::fmt::Display for Role {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.write_str(self.as_str())
156    }
157}
158
159impl std::str::FromStr for Role {
160    type Err = String;
161
162    fn from_str(value: &str) -> Result<Self, Self::Err> {
163        match value.to_ascii_lowercase().as_str() {
164            "user" => Ok(Self::User),
165            "assistant" => Ok(Self::Assistant),
166            "tool" => Ok(Self::Tool),
167            "slash" => Ok(Self::Slash),
168            "compaction" => Ok(Self::Compaction),
169            // Name the accepted values: this error reaches Python callers directly, where there is
170            // no schema enum or clap suggestion to fall back on.
171            other => Err(format!(
172                "unknown role: {other} — must be one of \"user\", \"assistant\", \"tool\", \"slash\", \"compaction\""
173            )),
174        }
175    }
176}
177
178/// How a request selects message classes in SQL. Two shapes rather than one because they are
179/// not equivalent over rows whose stored `kind` is outside the current enum: `AllExcept`
180/// keeps them, `Only` does not. See [`MessageFilters::kind_predicate`].
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum KindPredicate {
183    /// Caller named no set: return everything except these classes.
184    AllExcept(Vec<MessageKind>),
185    /// Caller named a set: return exactly these classes.
186    Only(Vec<MessageKind>),
187}
188
189/// A single conversation turn persisted per session (the unit of message-level analytics).
190///
191/// PATTERN: adding a variant here is the whole cost of adding a message class. It reaches the
192/// MCP schema through `mcp_server::message_kind_values`, the CLI through clap, and the default
193/// result set through [`MessageKind::default_search_set`], all derived from these variants.
194/// Do not pair a new variant with an `include_<variant>` flag or an `is_<variant>` column.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
196#[serde(rename_all = "snake_case")]
197#[clap(rename_all = "kebab-case")]
198pub enum MessageKind {
199    Conversation,
200    Compaction,
201    ToolCall,
202    ToolResult,
203    /// A message the harness injected into the transcript, addressed to the agent rather
204    /// than written by the user or the model: Stop-hook feedback, PreToolUse blocks,
205    /// local-command caveats and stdout, task notifications. Not user prose, so it is
206    /// excluded from results by default, but it is the ONLY record of what a hook told an
207    /// agent and what the agent did next, so it is stored rather than discarded.
208    HarnessNotice,
209    Unknown,
210}
211
212/// Content-matching strategy shared by programmatic message-search clients.
213#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
214#[serde(rename_all = "lowercase")]
215#[clap(rename_all = "lowercase")]
216pub enum MessageSearchMode {
217    #[default]
218    Literal,
219    Regex,
220    Fuzzy,
221}
222
223impl MessageSearchMode {
224    pub const fn as_str(self) -> &'static str {
225        match self {
226            Self::Literal => "literal",
227            Self::Regex => "regex",
228            Self::Fuzzy => "fuzzy",
229        }
230    }
231}
232
233impl std::str::FromStr for MessageSearchMode {
234    type Err = String;
235
236    fn from_str(value: &str) -> Result<Self, Self::Err> {
237        match value.to_ascii_lowercase().as_str() {
238            "literal" => Ok(Self::Literal),
239            "regex" => Ok(Self::Regex),
240            "fuzzy" => Ok(Self::Fuzzy),
241            other => Err(format!(
242                "unknown message search mode: {other}; expected literal, regex, or fuzzy"
243            )),
244        }
245    }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
249#[serde(rename_all = "snake_case")]
250#[clap(rename_all = "kebab-case")]
251pub enum SearchField {
252    Content,
253    ToolName,
254    ToolArgument,
255}
256
257impl SearchField {
258    pub const fn as_str(self) -> &'static str {
259        match self {
260            Self::Content => "content",
261            Self::ToolName => "tool_name",
262            Self::ToolArgument => "tool_argument",
263        }
264    }
265}
266
267impl std::str::FromStr for SearchField {
268    type Err = String;
269
270    fn from_str(value: &str) -> Result<Self, Self::Err> {
271        match value.to_ascii_lowercase().replace('-', "_").as_str() {
272            "content" => Ok(Self::Content),
273            "tool_name" => Ok(Self::ToolName),
274            "tool_argument" => Ok(Self::ToolArgument),
275            other => Err(format!(
276                "unknown message search field: {other} — must be one of \"content\", \"tool_name\", \"tool_argument\""
277            )),
278        }
279    }
280}
281
282impl MessageKind {
283    pub fn as_str(&self) -> &'static str {
284        match self {
285            Self::Conversation => "conversation",
286            Self::Compaction => "compaction",
287            Self::ToolCall => "tool_call",
288            Self::ToolResult => "tool_result",
289            Self::HarnessNotice => "harness_notice",
290            Self::Unknown => "unknown",
291        }
292    }
293
294    /// Classes returned when a caller names no set: everything the user or the model wrote,
295    /// plus tool traffic. `HarnessNotice` is excluded because it is the harness talking to the
296    /// agent, so including it by default would change every existing result and skew the
297    /// user-prose analytics (`corrections`, `repeats`, `vocab`) built on message content.
298    pub fn default_search_set() -> Vec<Self> {
299        use clap::ValueEnum;
300        Self::value_variants()
301            .iter()
302            .copied()
303            .filter(|kind| *kind != Self::HarnessNotice)
304            .collect()
305    }
306
307    /// Every class [`MessageFilters::kind_predicate`] can leave out when the caller names no
308    /// set: `HarnessNotice` always, `Compaction` under `no_compaction`. Together a small share of
309    /// any real index (2 % of 2.69 M rows measured), which is what lets a count over the default
310    /// predicate be written as `count(everything) - count(these)`; the storage layer keeps a
311    /// partial index over exactly this set for the second term. Kept in ascending name order so
312    /// the SQL rendered from it is stable.
313    pub const EXCLUDABLE_BY_DEFAULT: &'static [Self] = &[Self::Compaction, Self::HarnessNotice];
314
315    /// Parse a `kind` value read back from the index, mapping anything unrecognized to
316    /// [`MessageKind::Unknown`].
317    ///
318    /// DELIBERATELY UNLIKE [`Provider::from_db_str`] and [`Role::from_db_str`], which
319    /// `debug_assert!` on an unrecognized spelling because theirs can only come from corruption.
320    /// This column is different in kind: `Unknown` is a real variant that exists precisely to
321    /// absorb spellings this build does not know, and older index versions legitimately store
322    /// values outside the current [`MessageKind::as_str`] set — reading them is exactly what the
323    /// self-healing migration path does. Asserting here turns "opened an older index" into a
324    /// panic; a proven case is `cli_search_self_heals_v4_hybrid_missing_trigram_from_intact_messages`
325    /// against rows storing `kind = 'message'`.
326    ///
327    /// What this still fixes is the duplication: delegating to [`std::str::FromStr`] removes the second,
328    /// hand-maintained match list where a newly added variant could silently fall through to
329    /// `Unknown` forever. The round-trip test over every variant is what guards that.
330    pub fn from_db_str(value: &str) -> Self {
331        value.parse().unwrap_or(Self::Unknown)
332    }
333}
334
335impl std::fmt::Display for MessageKind {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        f.write_str(self.as_str())
338    }
339}
340
341impl std::str::FromStr for MessageKind {
342    type Err = String;
343
344    fn from_str(value: &str) -> Result<Self, Self::Err> {
345        match value.to_ascii_lowercase().replace('-', "_").as_str() {
346            "conversation" => Ok(Self::Conversation),
347            "compaction" => Ok(Self::Compaction),
348            "tool_call" => Ok(Self::ToolCall),
349            "tool_result" => Ok(Self::ToolResult),
350            "harness_notice" => Ok(Self::HarnessNotice),
351            "unknown" => Ok(Self::Unknown),
352            other => Err(format!(
353                "unknown message kind: {other} — must be one of \"conversation\", \"compaction\", \"tool_call\", \"tool_result\", \"harness_notice\", \"unknown\""
354            )),
355        }
356    }
357}
358
359/// Who authored message content, independently of its transcript [`Role`] and [`MessageKind`].
360///
361/// A provider may record an agent delegation prompt as `role = user`, or a harness may inject a
362/// user-role notice. Keeping authorship orthogonal prevents those storage roles from becoming
363/// incorrect evidence about a person's behavior.
364#[derive(
365    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum,
366)]
367#[serde(rename_all = "snake_case")]
368#[clap(rename_all = "kebab-case")]
369pub enum MessageAuthorship {
370    Human,
371    Agent,
372    Harness,
373    Generated,
374    Mixed,
375    #[default]
376    Unknown,
377}
378
379impl MessageAuthorship {
380    pub const fn as_str(self) -> &'static str {
381        match self {
382            Self::Human => "human",
383            Self::Agent => "agent",
384            Self::Harness => "harness",
385            Self::Generated => "generated",
386            Self::Mixed => "mixed",
387            Self::Unknown => "unknown",
388        }
389    }
390
391    pub fn from_db_str(value: &str) -> Self {
392        value.parse().unwrap_or(Self::Unknown)
393    }
394}
395
396impl std::str::FromStr for MessageAuthorship {
397    type Err = String;
398
399    fn from_str(value: &str) -> Result<Self, Self::Err> {
400        match value.to_ascii_lowercase().replace('-', "_").as_str() {
401            "human" => Ok(Self::Human),
402            "agent" => Ok(Self::Agent),
403            "harness" => Ok(Self::Harness),
404            "generated" => Ok(Self::Generated),
405            "mixed" => Ok(Self::Mixed),
406            "unknown" => Ok(Self::Unknown),
407            other => Err(format!(
408                "unknown message authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"mixed\", \"unknown\""
409            )),
410        }
411    }
412}
413
414/// Authorship of one structurally identified region of a mixed message.
415///
416/// `Mixed` is deliberately absent: nested ambiguity is represented by `Unknown`, keeping
417/// validation and match attribution linear in the number of parts.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum ContentPartAuthorship {
421    Human,
422    Agent,
423    Harness,
424    Generated,
425    Unknown,
426}
427
428impl ContentPartAuthorship {
429    pub const fn as_str(self) -> &'static str {
430        match self {
431            Self::Human => "human",
432            Self::Agent => "agent",
433            Self::Harness => "harness",
434            Self::Generated => "generated",
435            Self::Unknown => "unknown",
436        }
437    }
438
439    pub fn from_db_str(value: &str) -> Self {
440        value.parse().unwrap_or(Self::Unknown)
441    }
442}
443
444impl std::str::FromStr for ContentPartAuthorship {
445    type Err = String;
446
447    fn from_str(value: &str) -> Result<Self, Self::Err> {
448        match value.to_ascii_lowercase().replace('-', "_").as_str() {
449            "human" => Ok(Self::Human),
450            "agent" => Ok(Self::Agent),
451            "harness" => Ok(Self::Harness),
452            "generated" => Ok(Self::Generated),
453            "unknown" => Ok(Self::Unknown),
454            other => Err(format!(
455                "unknown content-part authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"unknown\""
456            )),
457        }
458    }
459}
460
461/// Structured origin of a mixed-message region; this is not a display label or an arbitrary
462/// provider string.
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
464#[serde(rename_all = "snake_case")]
465pub enum ContentPartOrigin {
466    DirectInput,
467    QuotedContent,
468    HarnessContext,
469    GeneratedSummary,
470    ToolPayload,
471    Unknown,
472}
473
474impl ContentPartOrigin {
475    pub const fn as_str(self) -> &'static str {
476        match self {
477            Self::DirectInput => "direct_input",
478            Self::QuotedContent => "quoted_content",
479            Self::HarnessContext => "harness_context",
480            Self::GeneratedSummary => "generated_summary",
481            Self::ToolPayload => "tool_payload",
482            Self::Unknown => "unknown",
483        }
484    }
485
486    pub fn from_db_str(value: &str) -> Self {
487        value.parse().unwrap_or(Self::Unknown)
488    }
489}
490
491impl std::str::FromStr for ContentPartOrigin {
492    type Err = String;
493
494    fn from_str(value: &str) -> Result<Self, Self::Err> {
495        match value.to_ascii_lowercase().replace('-', "_").as_str() {
496            "direct_input" => Ok(Self::DirectInput),
497            "quoted_content" => Ok(Self::QuotedContent),
498            "harness_context" => Ok(Self::HarnessContext),
499            "generated_summary" => Ok(Self::GeneratedSummary),
500            "tool_payload" => Ok(Self::ToolPayload),
501            "unknown" => Ok(Self::Unknown),
502            other => Err(format!(
503                "unknown content-part origin: {other} — must be one of \"direct_input\", \"quoted_content\", \"harness_context\", \"generated_summary\", \"tool_payload\", \"unknown\""
504            )),
505        }
506    }
507}
508
509/// One contiguous region of a mixed message.
510///
511/// Offsets are half-open Unicode-scalar positions, matching the public message-search character
512/// coordinates rather than UTF-8 byte indexes. Parts are stored without another copy of the text.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct MessageContentPart {
515    pub ordinal: u32,
516    pub start_char: usize,
517    pub end_char: usize,
518    pub authorship: ContentPartAuthorship,
519    pub origin: ContentPartOrigin,
520}
521
522/// Closed authority that owns a provider-native event identity.
523///
524/// Related harness surfaces share an authority only when their source format can carry the same
525/// native identity. The additional identity `scope` prevents unrelated ID domains owned by that
526/// authority from colliding.
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
528#[serde(rename_all = "snake_case")]
529pub enum MessageCorrelationAuthority {
530    Anthropic,
531    OpenAi,
532    Cursor,
533    Google,
534    Pi,
535    PrimeAgent,
536}
537
538impl MessageCorrelationAuthority {
539    pub const fn as_str(self) -> &'static str {
540        match self {
541            Self::Anthropic => "anthropic",
542            Self::OpenAi => "open_ai",
543            Self::Cursor => "cursor",
544            Self::Google => "google",
545            Self::Pi => "pi",
546            Self::PrimeAgent => "prime_agent",
547        }
548    }
549
550    pub fn from_db_str(value: &str) -> Option<Self> {
551        value.parse().ok()
552    }
553}
554
555impl std::str::FromStr for MessageCorrelationAuthority {
556    type Err = String;
557
558    fn from_str(value: &str) -> Result<Self, Self::Err> {
559        match value.to_ascii_lowercase().replace('-', "_").as_str() {
560            "anthropic" => Ok(Self::Anthropic),
561            "open_ai" | "openai" => Ok(Self::OpenAi),
562            "cursor" => Ok(Self::Cursor),
563            "google" => Ok(Self::Google),
564            "pi" => Ok(Self::Pi),
565            "prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
566            other => Err(format!(
567                "unknown message correlation authority: {other} — must be one of \"anthropic\", \"open_ai\", \"cursor\", \"google\", \"pi\", \"prime_agent\""
568            )),
569        }
570    }
571}
572
573/// Stable identity shared by an original source event and every known rendered/mirrored copy.
574///
575/// Hash/equality deliberately cover only identity. Whether a stored row is the original or a copy
576/// is separate metadata and must never split one correlation group.
577#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
578pub struct MessageCorrelationIdentity {
579    pub authority: MessageCorrelationAuthority,
580    pub scope: String,
581    pub id: String,
582}
583
584/// How this stored row relates to its correlation group.
585#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum MessageRecordRelation {
588    Original,
589    Mirror,
590    #[default]
591    Unknown,
592}
593
594impl MessageRecordRelation {
595    pub const fn as_str(self) -> &'static str {
596        match self {
597            Self::Original => "original",
598            Self::Mirror => "mirror",
599            Self::Unknown => "unknown",
600        }
601    }
602
603    pub fn from_db_str(value: &str) -> Option<Self> {
604        value.parse().ok()
605    }
606}
607
608impl std::str::FromStr for MessageRecordRelation {
609    type Err = String;
610
611    fn from_str(value: &str) -> Result<Self, Self::Err> {
612        match value.to_ascii_lowercase().as_str() {
613            "original" => Ok(Self::Original),
614            "mirror" => Ok(Self::Mirror),
615            "unknown" => Ok(Self::Unknown),
616            other => Err(format!(
617                "unknown correlated record relation: {other} — must be one of \"original\", \"mirror\", \"unknown\""
618            )),
619        }
620    }
621}
622
623#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
624pub struct MessageProvenance {
625    pub authorship: MessageAuthorship,
626    pub record_relation: MessageRecordRelation,
627    pub correlation_identity: Option<MessageCorrelationIdentity>,
628    pub content_parts: Vec<MessageContentPart>,
629}
630
631impl MessageProvenance {
632    /// Validate in one pass: `O(S)` time and `O(1)` additional memory for `S` content parts.
633    pub fn validate(&self, content: &str) -> Result<(), String> {
634        if let Some(identity) = &self.correlation_identity {
635            if identity.scope.trim().is_empty() {
636                return Err("message correlation scope must not be empty".to_string());
637            }
638            if identity.id.trim().is_empty() {
639                return Err("message correlation id must not be empty".to_string());
640            }
641        }
642
643        if self.authorship != MessageAuthorship::Mixed {
644            if !self.content_parts.is_empty() {
645                return Err(
646                    "content_parts are allowed only when message authorship is \"mixed\""
647                        .to_string(),
648                );
649            }
650            return Ok(());
651        }
652        if self.content_parts.is_empty() {
653            return Err("mixed message authorship requires content_parts".to_string());
654        }
655
656        let content_chars = content.chars().count();
657        let mut expected_start = 0;
658        let mut first_authorship = None;
659        let mut has_distinct_authorship = false;
660        for (index, part) in self.content_parts.iter().enumerate() {
661            if part.ordinal as usize != index {
662                return Err(format!(
663                    "content part ordinal {} must equal its zero-based position {index}",
664                    part.ordinal
665                ));
666            }
667            if part.start_char != expected_start {
668                return Err(format!(
669                    "content part {index} must start at character {expected_start}, not {}",
670                    part.start_char
671                ));
672            }
673            if part.end_char <= part.start_char {
674                return Err(format!(
675                    "content part {index} must have a non-empty half-open character range"
676                ));
677            }
678            if part.end_char > content_chars {
679                return Err(format!(
680                    "content part {index} ends at character {}, beyond message length {content_chars}",
681                    part.end_char
682                ));
683            }
684            if let Some(first) = first_authorship {
685                has_distinct_authorship |= first != part.authorship;
686            } else {
687                first_authorship = Some(part.authorship);
688            }
689            expected_start = part.end_char;
690        }
691        if expected_start != content_chars {
692            return Err(format!(
693                "content parts end at character {expected_start}, before message length {content_chars}; represent gaps as explicit \"unknown\" parts"
694            ));
695        }
696        if !has_distinct_authorship {
697            return Err(
698                "mixed message authorship requires at least two distinct part authorships"
699                    .to_string(),
700            );
701        }
702        Ok(())
703    }
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct Message {
708    pub seq: i64,
709    pub role: Role,
710    pub ts: Option<DateTime<Utc>>,
711    pub tool_name: Option<String>,
712    pub kind: MessageKind,
713    pub tool_call_id: Option<String>,
714    pub is_compaction: bool,
715    pub content: String,
716    #[serde(default)]
717    pub provenance: MessageProvenance,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct SessionRecord {
722    pub id: String,
723    pub provider: Provider,
724    pub provider_session_id: String,
725    pub title: Option<String>,
726    pub summary: Option<String>,
727    pub cwd: Option<String>,
728    pub repo_root: Option<String>,
729    pub created_at: Option<DateTime<Utc>>,
730    pub updated_at: Option<DateTime<Utc>>,
731    pub last_message_at: Option<DateTime<Utc>>,
732    pub preview_text: String,
733    pub source_path: String,
734    pub message_count: Option<i64>,
735    pub parse_version: String,
736    pub raw_metadata_json: Option<String>,
737    pub parse_warning: Option<String>,
738    pub discovery_source: String,
739    /// The [`SessionRecord::id`] of the session that spawned this one, when this session is a
740    /// subagent run.
741    ///
742    /// `None` for an ordinary top-level session. Every provider marks subagent runs
743    /// differently (claude by a `subagents` directory under its parent, codex in a
744    /// `thread_spawn` payload, cursor and pi by directory layout), so detection stays
745    /// provider-specific while this field is the one shape they all produce. Typed rather than
746    /// buried in `raw_metadata_json` so "every subagent of this session" is a query, which is
747    /// what made codex's richer spawn data unusable.
748    ///
749    /// Holds the whole id, provider prefix included, so it reads as the same value the parent
750    /// row carries in `id` and needs no rule about stripping a prefix before comparing.
751    ///
752    /// PATTERN: this names a row that need not exist. Providers keep a spawned run's transcript
753    /// after the parent conversation is rotated away — in one project directory, 549 runs
754    /// referenced 69 distinct parents and not one of those parent transcripts was still on
755    /// disk. Recovering exactly that otherwise-unreachable work is a reason this is indexed, so
756    /// do NOT add `references sessions(id)` to this column: the constraint would reject every
757    /// such run at insert, and a cascade would delete the runs when a parent is reindexed away.
758    pub parent_session_id: Option<String>,
759    /// Human-meaningful name for the spawned agent when the provider records one: claude's
760    /// `agentType` (`Explore`, `general-purpose`), codex's `agent_nickname`, or the agent's
761    /// own directory or file name where that is all a provider records. Display and grouping
762    /// only; the link is `parent_session_id`.
763    pub agent_label: Option<String>,
764}
765
766/// Opaque keyset cursor for a bounded session-analysis document scan.
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768#[serde(transparent)]
769pub struct AnalysisCursor(String);
770
771impl AnalysisCursor {
772    pub fn as_str(&self) -> &str {
773        &self.0
774    }
775
776    pub(crate) fn after(session_id: String) -> Self {
777        Self(session_id)
778    }
779}
780
781/// Provider-normalized input document for outward analysis pipelines.
782///
783/// The historical `user_*` field spelling denotes attributable human input, not every stored row
784/// whose transcript role is `user`: agent delegation, harness, generated, mirrored, and unknown
785/// content is excluded, while only human-authored parts of mixed messages are retained.
786#[derive(Debug, Clone, Serialize)]
787pub struct AnalysisDocument {
788    pub session: SessionRecord,
789    pub user_text: String,
790    pub first_user_text: Option<String>,
791    pub message_count: i64,
792    pub user_message_count: i64,
793}
794
795/// One bounded keyset page of provider-normalized analysis documents.
796#[derive(Debug, Clone, Serialize)]
797pub struct AnalysisDocumentPage {
798    pub documents: Vec<AnalysisDocument>,
799    pub next_cursor: Option<AnalysisCursor>,
800}
801
802/// A single file-mutating tool call (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`)
803/// extracted from an assistant turn. Threaded through [`ParsedSession`] like
804/// [`Message`], persisted to the `file_edits` table, and replayed to reconstruct
805/// historical file content (`files extract`).
806#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct FileEdit {
808    /// Monotonic order within the session (independent of message seq).
809    pub seq: i64,
810    pub ts: Option<DateTime<Utc>>,
811    /// Originating tool name (`Write`|`Edit`|`MultiEdit`|`NotebookEdit`).
812    pub tool: String,
813    pub file_path: String,
814    /// Basename of `file_path`, denormalized for fast glob/search.
815    pub file_name: String,
816    /// Full file content — present only for `Write` (a full snapshot / replay base).
817    pub new_content: Option<String>,
818    /// `old_string`→`new_string` replacements for `Edit`/`MultiEdit`; empty otherwise.
819    pub edits: Vec<EditOp>,
820}
821
822/// One `old_string`→`new_string` replacement from an `Edit`/`MultiEdit` tool call.
823/// `replace_all` mirrors Claude's `Edit` flag: when true the replacement is applied to
824/// every occurrence, otherwise only the first (which is also the only one, since a
825/// non-`replace_all` `Edit` requires `old_string` to be unique).
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct EditOp {
828    pub old: String,
829    pub new: String,
830    /// Replace every occurrence (Claude `Edit`/`MultiEdit` `replace_all: true`).
831    #[serde(default)]
832    pub replace_all: bool,
833}
834
835impl EditOp {
836    /// Construct a first-occurrence (non-`replace_all`) edit.
837    pub fn new(old: impl Into<String>, new: impl Into<String>) -> Self {
838        Self {
839            old: old.into(),
840            new: new.into(),
841            replace_all: false,
842        }
843    }
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct ParsedSession {
848    pub session: SessionRecord,
849    pub transcript_text: String,
850    /// Per-message rows persisted to the `messages` table.
851    pub messages: Vec<Message>,
852    /// File-mutating tool calls persisted to the `file_edits` table (file-version recovery).
853    pub file_edits: Vec<FileEdit>,
854}
855
856#[derive(Debug, Clone)]
857pub struct SourceFile {
858    pub provider: Provider,
859    pub path: std::path::PathBuf,
860    pub mtime_ns: i64,
861    pub size_bytes: i64,
862}
863
864/// Who started a session — the session-level counterpart of [`MessageKind`], selected the same
865/// way through a set rather than a flag per class.
866///
867/// The two spellings are the providers' own. Codex stores this exact enum on its session
868/// metadata as `thread_source`, with the values `user` and `subagent` (167 and 247 of the 414
869/// rollouts this was checked against). `subagent` is what every provider that marks the concept
870/// calls it: codex's `thread_source` value and `source.subagent` key, the `subagents`
871/// directory claude and cursor both write, claude's `subagent_type` Task parameter, and
872/// gemini-cli's `subagent_N` author labels. `user` is the counterpart because it is the one
873/// reading no provider contradicts — and because a subagent IS an agent, so naming the other
874/// class `agent` would make `[agent]` unpredictable, while nothing about a subagent is a user.
875///
876/// Derived from [`SessionRecord::parent_session_id`] rather than stored, so the classes are
877/// exhaustive by construction and no row can fall outside them. That is why selection needs no
878/// `AllExcept` shape the way [`KindPredicate`] does: there are no rows of an unknown class.
879///
880/// PATTERN: adding a variant here is the whole cost of adding a session class. It reaches the
881/// CLI through clap's `ValueEnum`, the MCP schema through `mcp_server::session_kind_values`,
882/// and the default result set through [`SessionKind::default_search_set`], all derived from
883/// these variants. Do not pair a new variant with an `include_<variant>` boolean — one was
884/// tried for `MessageKind::HarnessNotice` and reverted; see [`MessageFilters::kinds`].
885#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
886#[serde(rename_all = "snake_case")]
887#[clap(rename_all = "kebab-case")]
888pub enum SessionKind {
889    /// A session a person started. Codex's `thread_source: "user"`; claude's
890    /// `isSidechain: false`, which held on 143,220 of 143,224 records in top-level transcripts.
891    User,
892    /// A run some other session spawned, with [`SessionRecord::parent_session_id`] naming the
893    /// spawner and [`SessionRecord::agent_label`] naming the kind of agent. Codex's
894    /// `thread_source: "subagent"`; claude's `isSidechain: true`. On this machine they
895    /// outnumber user-started claude sessions roughly five to one, which is why selecting by
896    /// class is worth a parameter.
897    Subagent,
898}
899
900impl SessionKind {
901    pub fn as_str(self) -> &'static str {
902        match self {
903            Self::User => "user",
904            Self::Subagent => "subagent",
905        }
906    }
907
908    /// Every class, in declaration order. Derived from the enum so a new variant is included
909    /// without editing a second list.
910    pub fn all() -> Vec<Self> {
911        use clap::ValueEnum;
912        Self::value_variants().to_vec()
913    }
914
915    /// The classes a request returns when it names none: all of them.
916    ///
917    /// Unlike [`MessageKind::default_search_set`], which drops harness notices because they are
918    /// harness bookkeeping rather than prose, a spawned run holds real work — it is the record
919    /// of what a subagent was asked and what it found. Hiding that by default would answer
920    /// "what did I do about X" with only half the evidence. A class that IS noise by default
921    /// belongs here as an exclusion, the same way `HarnessNotice` is one.
922    pub fn default_search_set() -> Vec<Self> {
923        Self::all()
924    }
925
926    /// SQL that is true for exactly this class, over a `sessions` row bound to `alias`.
927    /// Colocated with the variant so adding a class cannot leave its predicate unwritten.
928    pub fn sql_predicate(self, alias: &str) -> String {
929        match self {
930            Self::User => format!("{alias}.parent_session_id is null"),
931            Self::Subagent => format!("{alias}.parent_session_id is not null"),
932        }
933    }
934}
935
936impl std::fmt::Display for SessionKind {
937    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938        f.write_str(self.as_str())
939    }
940}
941
942impl std::str::FromStr for SessionKind {
943    type Err = String;
944
945    fn from_str(value: &str) -> Result<Self, Self::Err> {
946        match value.to_ascii_lowercase().replace('-', "_").as_str() {
947            "user" => Ok(Self::User),
948            "subagent" => Ok(Self::Subagent),
949            other => Err(format!(
950                "unknown session kind: {other} — must be one of \"user\" (a session you started) or \"subagent\" (a run one of those spawned)"
951            )),
952        }
953    }
954}
955
956#[derive(Debug, Clone, Default)]
957pub struct SearchFilters {
958    pub provider: Option<Provider>,
959    /// Which classes of session to return. `None` selects the default set
960    /// ([`SessionKind::default_search_set`]), which is every class.
961    ///
962    /// PATTERN: this set is the ONLY mechanism for session-class selection, mirroring
963    /// [`MessageFilters::kinds`] one level up. Adding a class means adding a [`SessionKind`]
964    /// variant, never an `include_<class>` boolean or a `<class>_only` flag beside it.
965    pub session_kinds: Option<Vec<SessionKind>>,
966    pub path_prefix: Option<String>,
967    pub exclude_path_prefixes: Vec<String>,
968    pub exclude_session_ids: Vec<String>,
969    /// Return only sessions spawned by this exact session id. The link
970    /// [`SessionRecord::parent_session_id`] stores is typed precisely so "every subagent of
971    /// this session" is an equality match rather than a JSON scan.
972    pub parent_session_id: Option<String>,
973    /// Inclusive query start. Session retrieval keeps rows whose known indexed end
974    /// (`updated_at`, falling back to `created_at`) is on or after this instant.
975    pub since: Option<DateTime<Utc>>,
976    /// Inclusive query end. Session retrieval keeps rows whose known indexed start
977    /// (`created_at`, falling back to `updated_at`) is on or before this instant.
978    pub until: Option<DateTime<Utc>>,
979    pub limit: usize,
980    pub warnings_only: bool,
981}
982
983impl SearchFilters {
984    /// The session classes this request returns, with the default applied. Every reader goes
985    /// through here so a caller-named set and the default cannot diverge.
986    pub fn effective_session_kinds(&self) -> Vec<SessionKind> {
987        self.session_kinds
988            .clone()
989            .unwrap_or_else(SessionKind::default_search_set)
990    }
991
992    /// Reject resolved filter combinations that cannot match any session.
993    pub fn validate(&self) -> anyhow::Result<()> {
994        let only_user_sessions = self.session_kinds.as_ref().is_some_and(|kinds| {
995            !kinds.is_empty() && kinds.iter().all(|kind| *kind == SessionKind::User)
996        });
997        if self.parent_session_id.is_some() && only_user_sessions {
998            anyhow::bail!(
999                "session_kinds cannot contain only \"user\" when parent_session_id is set; pass [\"subagent\"], omit session_kinds to use both classes, or omit parent_session_id to search user-started sessions"
1000            );
1001        }
1002        Ok(())
1003    }
1004}
1005
1006/// Product-level population selection for session graph, taxonomy, and phrase analysis.
1007///
1008/// The bounded strategy names the existing canonical-session-ID order explicitly. It is not a
1009/// recency sample, message limit, or claim of representativeness.
1010#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1011#[serde(tag = "kind", rename_all = "snake_case")]
1012pub enum AnalysisSessionSelection {
1013    /// Analyze every session accepted by the request scope.
1014    #[default]
1015    AllEligible,
1016    /// Analyze the first `max_sessions` accepted sessions in canonical session-ID order.
1017    FirstCanonicalSessions { max_sessions: NonZeroUsize },
1018}
1019
1020impl AnalysisSessionSelection {
1021    pub(crate) const fn max_sessions(self) -> Option<NonZeroUsize> {
1022        match self {
1023            Self::AllEligible => None,
1024            Self::FirstCanonicalSessions { max_sessions } => Some(max_sessions),
1025        }
1026    }
1027}
1028
1029/// Session scope plus an explicit population strategy for one longitudinal analysis run.
1030#[derive(Debug, Clone)]
1031pub struct AnalysisRequest {
1032    scope: SearchFilters,
1033    selection: AnalysisSessionSelection,
1034}
1035
1036impl AnalysisRequest {
1037    /// Construct an analysis request without accepting the generic session-list limit by accident.
1038    pub fn new(scope: SearchFilters, selection: AnalysisSessionSelection) -> anyhow::Result<Self> {
1039        scope.validate()?;
1040        if scope.limit != 0 {
1041            anyhow::bail!(
1042                "analysis does not accept SearchFilters.limit={}; set it to 0 and choose \
1043                 AnalysisSessionSelection::FirstCanonicalSessions {{ max_sessions }} for the \
1044                 explicitly labeled canonical-order prefix, or AllEligible for every session in \
1045                 scope",
1046                scope.limit
1047            );
1048        }
1049        Ok(Self { scope, selection })
1050    }
1051
1052    /// Structural session filters with the generic list limit fixed at zero.
1053    pub const fn scope(&self) -> &SearchFilters {
1054        &self.scope
1055    }
1056
1057    /// Explicit population strategy applied after structural session filtering.
1058    pub const fn selection(&self) -> AnalysisSessionSelection {
1059        self.selection
1060    }
1061}
1062
1063#[derive(Debug, Clone, Serialize)]
1064pub struct SessionWithTranscript {
1065    #[serde(flatten)]
1066    pub session: SessionRecord,
1067    pub transcript_text: String,
1068}
1069
1070#[derive(Debug, Clone, Serialize)]
1071pub struct SearchHit {
1072    #[serde(flatten)]
1073    pub session: SessionRecord,
1074    pub score: i64,
1075    pub match_source: String,
1076    pub match_snippet: String,
1077}
1078
1079/// Filters for message-level search (`messages search`, analytics). Exact/regex `limit == 0`
1080/// means unlimited; fuzzy validation requires a positive finite page.
1081#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1082#[serde(default, deny_unknown_fields)]
1083pub struct MessageFilters {
1084    pub role: Option<Role>,
1085    /// Which semantic message classes to return. `None` selects the default set
1086    /// ([`MessageKind::default_search_set`]): every class except `HarnessNotice`, which is
1087    /// harness output rather than user or model prose.
1088    ///
1089    /// PATTERN: this set is the ONLY mechanism for class selection. Adding a class means
1090    /// adding a `MessageKind` variant, never an `include_<class>` boolean beside it. A
1091    /// boolean was tried and reverted: it duplicated this field, needed tie-breaking code so
1092    /// `kind=harness_notice` would not self-cancel into an empty result, and would have cost
1093    /// one parameter per future class. Related: `no_compaction` below is legacy sugar that
1094    /// resolves through [`MessageFilters::effective_kinds`], and the `is_compaction` column in
1095    /// `db.rs` is the same redundancy left in the schema.
1096    pub kinds: Option<Vec<MessageKind>>,
1097    /// The query searches only `field`: message content, canonical `tool_name`, or the canonical
1098    /// tool argument selected by [`MessageFilters::argument_path`].
1099    pub field: Option<SearchField>,
1100    /// RFC 6901 JSON pointer relative to the canonical tool-call `args` value.
1101    pub argument_path: Option<String>,
1102    /// Restrict to these indexed session sources. `None` selects every provider; callers must not
1103    /// construct `Some(vec![])`, because an empty public provider set is rejected before SQL.
1104    pub providers: Option<Vec<Provider>>,
1105    /// Exact session id, used after CLI commands resolve a user-supplied id/prefix.
1106    /// This avoids substring filters accidentally merging sessions in `messages get`
1107    /// and `messages timeline`.
1108    pub session_id: Option<String>,
1109    /// Restrict to messages whose session's `cwd`, `repo_root`, or source transcript starts with this
1110    /// prefix — the message-level analogue of [`SearchFilters::path_prefix`]. Applied
1111    /// as a subquery against `sessions` in `append_message_filters` (the `sessions`
1112    /// table is tiny relative to `messages`, so no dedicated index is needed).
1113    pub path_prefix: Option<String>,
1114    /// Exclude messages whose session's `cwd`, `repo_root`, or source transcript path starts
1115    /// with any of these normalized prefixes. Applied before limits/context expansion.
1116    pub exclude_path_prefixes: Vec<String>,
1117    /// Restrict by session cwd or repository root only. New typed requests use this instead of
1118    /// the legacy broad `path_prefix` while prerelease adapters are cut over.
1119    #[doc(hidden)]
1120    pub workspace_path_prefix: Option<String>,
1121    /// Restrict by transcript storage path only.
1122    #[doc(hidden)]
1123    pub transcript_path_prefix: Option<String>,
1124    #[doc(hidden)]
1125    pub exclude_workspace_path_prefixes: Vec<String>,
1126    #[doc(hidden)]
1127    pub exclude_transcript_path_prefixes: Vec<String>,
1128    /// Exclude exact session ids. Applied before limits/context expansion.
1129    pub exclude_session_ids: Vec<String>,
1130    pub since: Option<DateTime<Utc>>,
1131    pub until: Option<DateTime<Utc>>,
1132    /// Lower inclusive message sequence bound. Only meaningful within one or more scoped
1133    /// sessions because `seq` is local to each session.
1134    pub seq_from: Option<i64>,
1135    /// Upper inclusive message sequence bound. Only meaningful within one or more scoped
1136    /// sessions because `seq` is local to each session.
1137    pub seq_to: Option<i64>,
1138    /// How the separate query string is interpreted. Literal is case-insensitive; Regex
1139    /// uses Rust regex syntax, and Fuzzy exhaustively scores the structurally filtered corpus with
1140    /// Nucleo's fzf-style sequence score. Fuzzy is sequence matching, not edit distance.
1141    pub match_mode: MessageSearchMode,
1142    /// Optional case-insensitive substring filter on a tool message's canonical `tool_name`,
1143    /// independent of `field` (e.g. `exec` matches Codex `exec_command`; `edit` matches Claude
1144    /// `Edit` and `MultiEdit`).
1145    pub tool: Option<String>,
1146    /// Drop `Compaction` from the effective class set. Retained as a convenience over
1147    /// [`MessageFilters::kinds`]; both resolve through [`MessageFilters::effective_kinds`], so
1148    /// they cannot disagree.
1149    pub no_compaction: bool,
1150    /// Which session classes the messages may come from. `None` = every class, matching what
1151    /// `messages search` and `list` already return by default.
1152    ///
1153    /// Mirrors [`SearchFilters::session_kinds`] rather than introducing a second spelling, and is
1154    /// a SET for the reason stated on [`SessionKind`]: an `include_subagent` boolean was the
1155    /// shape explicitly rejected there. `Some(vec![])` selects no class and therefore matches
1156    /// nothing, exactly as it already does on `search`.
1157    ///
1158    /// `corrections` is the one operation that narrows this by default -- see
1159    /// `crate::db::Db::find_corrections`, which forces `User` for the same reason it forces
1160    /// `Role::User`.
1161    pub session_kinds: Option<Vec<SessionKind>>,
1162    pub limit: usize,
1163    pub offset: usize,
1164}
1165
1166impl MessageFilters {
1167    /// Validate invariants shared by CLI, MCP, Rust, and language bindings.
1168    ///
1169    /// `query` is interpreted according to [`MessageFilters::match_mode`].
1170    pub fn validate(&self, query: &str) -> anyhow::Result<()> {
1171        use anyhow::{bail, ensure};
1172
1173        if self.match_mode == MessageSearchMode::Fuzzy {
1174            ensure!(
1175                query.chars().take(3).count() >= 3,
1176                "fuzzy search requires at least 3 characters; use literal search for shorter text"
1177            );
1178            ensure!(
1179                !query.trim().is_empty(),
1180                "fuzzy query must contain a non-whitespace character"
1181            );
1182            ensure!(
1183                self.limit > 0,
1184                "fuzzy search requires a finite non-zero limit; literal search supports unlimited results"
1185            );
1186        }
1187        ensure!(
1188            !query.is_empty() || self.match_mode == MessageSearchMode::Literal,
1189            "match_mode={} requires a non-empty query",
1190            self.match_mode.as_str()
1191        );
1192        let offset = i64::try_from(self.offset)
1193            .map_err(|_| anyhow::anyhow!("offset exceeds SQLite's signed 64-bit limit"))?;
1194        if self.limit > 0 {
1195            let limit = i64::try_from(self.limit)
1196                .map_err(|_| anyhow::anyhow!("limit exceeds SQLite's signed 64-bit limit"))?;
1197            offset.checked_add(limit).ok_or_else(|| {
1198                anyhow::anyhow!("offset + limit exceeds SQLite's signed 64-bit limit")
1199            })?;
1200        }
1201
1202        if self.seq_from.is_some() || self.seq_to.is_some() {
1203            if self.seq_from.is_some_and(|seq| seq < 0) || self.seq_to.is_some_and(|seq| seq < 0) {
1204                bail!("seq_from and seq_to must be non-negative");
1205            }
1206            if let (Some(from), Some(to)) = (self.seq_from, self.seq_to) {
1207                ensure!(from <= to, "seq_from must be <= seq_to");
1208            }
1209        }
1210
1211        let field = self.field.unwrap_or(SearchField::Content);
1212        if field == SearchField::ToolArgument {
1213            let pointer = self
1214                .argument_path
1215                .as_deref()
1216                .ok_or_else(|| anyhow::anyhow!("tool-argument search requires argument_path"))?;
1217            crate::message_search::JsonPointer::new(pointer.to_string()).map_err(|error| {
1218                anyhow::anyhow!("argument_path must be an RFC 6901 JSON pointer: {error}")
1219            })?;
1220            // A named set may restrict further, but it must leave tool_call reachable:
1221            // searching a tool argument in a set that excludes tool calls matches nothing.
1222            ensure!(
1223                self.kinds
1224                    .as_ref()
1225                    .is_none_or(|kinds| kinds.contains(&MessageKind::ToolCall)),
1226                "tool-argument search requires tool_call among the selected kinds"
1227            );
1228        } else if self.argument_path.is_some() {
1229            bail!("argument_path requires field=tool_argument");
1230        }
1231        Ok(())
1232    }
1233
1234    /// True when at least one structural predicate (role / provider / session ID / path / time window /
1235    /// tool / no-compaction) restricts the SQL row set BEFORE content matching. `regex`, `rank`
1236    /// and `limit` are NOT structural — they filter/order content, not the scanned corpus. Used
1237    /// by `search_messages` to decide whether the content prefilter/scorer is worth querying:
1238    /// when a structural filter already narrows the corpus to a small slice, a direct scan of
1239    /// that slice beats intersecting against the whole-corpus trigram index.
1240    pub fn narrows_corpus(&self) -> bool {
1241        self.role.is_some()
1242            || self.kinds.is_some()
1243            || self.providers.is_some()
1244            || self.session_id.is_some()
1245            || self.path_prefix.is_some()
1246            || !self.exclude_path_prefixes.is_empty()
1247            || self.workspace_path_prefix.is_some()
1248            || self.transcript_path_prefix.is_some()
1249            || !self.exclude_workspace_path_prefixes.is_empty()
1250            || !self.exclude_transcript_path_prefixes.is_empty()
1251            || !self.exclude_session_ids.is_empty()
1252            || self.since.is_some()
1253            || self.until.is_some()
1254            || self.seq_from.is_some()
1255            || self.seq_to.is_some()
1256            || self.tool.is_some()
1257            || self.no_compaction
1258            || self.kinds.is_some()
1259    }
1260
1261    /// The message classes this filter actually returns, resolved once so every caller and the
1262    /// SQL builder agree. `kinds` names the set (defaulting to
1263    /// [`MessageKind::default_search_set`]); `no_compaction` then removes one member. Because
1264    /// both flow through here, a request cannot ask for a class and exclude it at the same
1265    /// time, which is what made the earlier per-class booleans return silently empty results.
1266    pub fn effective_kinds(&self) -> Vec<MessageKind> {
1267        let mut kinds = self
1268            .kinds
1269            .clone()
1270            .unwrap_or_else(MessageKind::default_search_set);
1271        if self.no_compaction {
1272            kinds.retain(|kind| *kind != MessageKind::Compaction);
1273        }
1274        kinds
1275    }
1276
1277    /// How to express class selection in SQL.
1278    ///
1279    /// PATTERN: naming no set must EXCLUDE the unwanted classes, never INCLUDE the known ones.
1280    /// An inclusion list silently drops any row whose stored `kind` is not a current enum
1281    /// variant, which is every row written by an older or newer build. That was shipped
1282    /// briefly and caught by a test inserting `kind = 'message'`: it reintroduced the same
1283    /// silent omission this filter exists to remove. An explicit set is an inclusion because
1284    /// the caller named exactly what they want.
1285    pub fn kind_predicate(&self) -> KindPredicate {
1286        if self.kinds.is_some() {
1287            return KindPredicate::Only(self.effective_kinds());
1288        }
1289        let mut excluded = vec![MessageKind::HarnessNotice];
1290        if self.no_compaction {
1291            excluded.push(MessageKind::Compaction);
1292        }
1293        KindPredicate::AllExcept(excluded)
1294    }
1295}
1296
1297#[derive(Debug, Clone, Serialize)]
1298pub struct MessageHit {
1299    pub session_id: String,
1300    pub provider: Provider,
1301    pub seq: i64,
1302    pub role: Role,
1303    pub kind: MessageKind,
1304    pub ts: Option<DateTime<Utc>>,
1305    /// The tool that produced a `Role::Tool` message (e.g. `Bash`, `exec_command`), else None.
1306    pub tool_name: Option<String>,
1307    #[serde(skip_serializing_if = "Option::is_none")]
1308    pub tool_call_id: Option<String>,
1309    #[serde(skip_serializing_if = "Option::is_none")]
1310    pub fuzzy_score: Option<u32>,
1311    pub content: String,
1312}
1313
1314/// Lightweight per-session metadata used to enrich message hits with human-readable
1315/// context (working dir / repo / title) in the MCP `search_messages` response, so an
1316/// agent can interpret and group results without a follow-up `get_session` per hit.
1317/// Kept off [`MessageHit`] so the CLI table rendering is unchanged; the MCP layer joins
1318/// it on via [`crate::db::Db::session_metadata`].
1319#[derive(Debug, Clone, Default, Serialize)]
1320pub struct SessionMeta {
1321    pub provider_session_id: Option<String>,
1322    pub cwd: Option<String>,
1323    pub repo_root: Option<String>,
1324    pub title: Option<String>,
1325    pub updated_at: Option<DateTime<Utc>>,
1326    pub last_message_at: Option<DateTime<Utc>>,
1327    pub message_count: Option<i64>,
1328    pub parse_warning: Option<String>,
1329    /// The spawning session, mirroring [`SessionRecord::parent_session_id`], or `None` for a
1330    /// root run. Carried here because a hit's session id alone cannot say whether it came from a
1331    /// root conversation, a subagent run, or a compaction fragment, and answering that with a
1332    /// follow-up query per session id defeats the deduplicated join this struct exists to be.
1333    pub parent_session_id: Option<String>,
1334    /// The spawned agent's name, mirroring [`SessionRecord::agent_label`]. Display and grouping
1335    /// only; the link is `parent_session_id`.
1336    pub agent_label: Option<String>,
1337}
1338
1339#[derive(Debug, Clone, Serialize)]
1340pub struct ProviderParserHealth {
1341    pub provider: Provider,
1342    pub expected_parse_version: String,
1343    pub indexed_sessions: i64,
1344    pub current_sessions: i64,
1345    pub stale_sessions: i64,
1346}
1347
1348#[derive(Debug, Clone, Serialize)]
1349pub struct ParserHealth {
1350    pub schema_version: i64,
1351    pub expected_schema_version: i64,
1352    pub schema_current: bool,
1353    pub indexed_sessions: i64,
1354    pub current_sessions: i64,
1355    pub stale_sessions: i64,
1356    pub parse_warnings: i64,
1357    pub providers: Vec<ProviderParserHealth>,
1358}
1359
1360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1361#[serde(rename_all = "snake_case")]
1362pub enum IndexSnapshotAvailability {
1363    Unavailable,
1364    Usable,
1365}
1366
1367impl IndexSnapshotAvailability {
1368    pub const fn as_str(self) -> &'static str {
1369        match self {
1370            Self::Unavailable => "unavailable",
1371            Self::Usable => "usable",
1372        }
1373    }
1374}
1375
1376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1377pub struct IndexSnapshotStatus {
1378    pub availability: IndexSnapshotAvailability,
1379    pub last_successful_refresh_at: Option<DateTime<Utc>>,
1380}
1381
1382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1383#[serde(rename_all = "snake_case")]
1384pub enum IndexRefreshState {
1385    NotStarted,
1386    Indexing,
1387    Fresh,
1388    Postponed,
1389    FailedWithRecovery,
1390}
1391
1392impl IndexRefreshState {
1393    pub const fn as_str(self) -> &'static str {
1394        match self {
1395            Self::NotStarted => "not_started",
1396            Self::Indexing => "indexing",
1397            Self::Fresh => "fresh",
1398            Self::Postponed => "postponed",
1399            Self::FailedWithRecovery => "failed_with_recovery",
1400        }
1401    }
1402}
1403
1404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1405#[serde(rename_all = "snake_case")]
1406pub enum IndexRefreshTrigger {
1407    IntegrationInstall,
1408    CommandLine,
1409    Mcp,
1410}
1411
1412impl IndexRefreshTrigger {
1413    pub const fn as_str(self) -> &'static str {
1414        match self {
1415            Self::IntegrationInstall => "integration_install",
1416            Self::CommandLine => "command_line",
1417            Self::Mcp => "mcp",
1418        }
1419    }
1420}
1421
1422#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1423pub struct IndexRefreshStatus {
1424    pub state: IndexRefreshState,
1425    pub started_by: Option<IndexRefreshTrigger>,
1426    pub started_at: Option<DateTime<Utc>>,
1427    pub finished_at: Option<DateTime<Utc>>,
1428    pub files_discovered: Option<usize>,
1429    pub files_processed: Option<usize>,
1430    pub sessions_updated: Option<usize>,
1431    pub retry_after_ms: Option<u64>,
1432    pub message: Option<String>,
1433    pub next_command: Option<String>,
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1437pub struct IndexReadinessStatus {
1438    pub snapshot: IndexSnapshotStatus,
1439    pub refresh: IndexRefreshStatus,
1440}
1441
1442impl IndexReadinessStatus {
1443    pub const fn not_started() -> Self {
1444        Self {
1445            snapshot: IndexSnapshotStatus {
1446                availability: IndexSnapshotAvailability::Unavailable,
1447                last_successful_refresh_at: None,
1448            },
1449            refresh: IndexRefreshStatus {
1450                state: IndexRefreshState::NotStarted,
1451                started_by: None,
1452                started_at: None,
1453                finished_at: None,
1454                files_discovered: None,
1455                files_processed: None,
1456                sessions_updated: None,
1457                retry_after_ms: None,
1458                message: None,
1459                next_command: None,
1460            },
1461        }
1462    }
1463}
1464
1465#[derive(Debug, Clone, Serialize)]
1466pub struct IndexStatus {
1467    pub parser_health: ParserHealth,
1468    /// Stale sessions whose source files are currently enabled and discoverable.
1469    pub repairable_stale_sessions: i64,
1470    /// Stale sessions retained in the index whose source files are not currently discoverable.
1471    pub unavailable_stale_sessions: i64,
1472    /// Discovered files that produced no session row at all, counted by set difference against
1473    /// the indexed source paths. This is deliberately NOT `discovered_files - indexed_sessions`:
1474    /// retained sessions make indexed exceed discovered, so that subtraction measures something
1475    /// else. A non-zero value means search results are incomplete for those sources.
1476    pub unindexed_files: i64,
1477    pub repair_commands: Vec<String>,
1478    /// Orthogonal snapshot usability and refresh lifecycle state. A usable snapshot can be served
1479    /// while refresh is indexing, postponed, or failed.
1480    pub readiness: IndexReadinessStatus,
1481}
1482
1483#[derive(Debug, Clone, Serialize)]
1484pub struct SessionTimeProfile {
1485    pub messages: i64,
1486    pub timestamped_messages: i64,
1487    pub undated_messages: i64,
1488    pub first_timestamp: Option<DateTime<Utc>>,
1489    pub last_timestamp: Option<DateTime<Utc>>,
1490    pub observed_span_seconds: Option<i64>,
1491    pub max_message_gap_seconds: Option<i64>,
1492    pub tool_calls: i64,
1493    pub tool_results: i64,
1494}
1495
1496/// Cost breakdown for `messages search --receipt-level summary|full` (MCP `receipt_level`,
1497/// serialized as `search_explanation`). For literal/regex search, `candidates` is the
1498/// prefilter output that requires verification. For fuzzy search, it is every row that matched
1499/// during complete-corpus scoring, independent of the requested result page.
1500#[derive(Debug, Clone, Serialize)]
1501pub struct SearchExplain {
1502    /// Trigram query derived from literal text or regex literals. `None` means the query has no
1503    /// >=3-char literal anchor, so it must scan the structurally-filtered corpus.
1504    pub prefilter: Option<String>,
1505    /// Rows requiring literal/regex verification after prefiltering, or rows that matched complete
1506    /// fuzzy scoring. `None` when no candidate count is available.
1507    pub candidates: Option<i64>,
1508    /// Why a prefilter was skipped or was not applicable, including complete fuzzy scoring.
1509    pub prefilter_skipped: Option<String>,
1510    /// Rows matching the structural filters (role/provider/session/date) — the
1511    /// selectivity denominator.
1512    pub corpus: i64,
1513}
1514
1515impl SearchExplain {
1516    /// One-line (two for content search) human-readable selectivity summary the CLI writes to
1517    /// stderr for `messages search --receipt-level summary|full`, so it never pollutes the
1518    /// parseable stdout. It is a presentation of the four structured fields, which are what
1519    /// `--format json`, Python, and MCP carry (the MCP output schema deliberately has no
1520    /// `summary`). `has_content_query` distinguishes a query with no usable >=3-char anchor from
1521    /// an empty search (structural filters only).
1522    pub fn summary(&self, has_content_query: bool) -> String {
1523        match (&self.prefilter, self.candidates) {
1524            (None, Some(candidates)) if has_content_query && self.prefilter_skipped.is_some() => {
1525                format!(
1526                    "[explain] {} / {} corpus rows matched after {}",
1527                    candidates,
1528                    self.corpus,
1529                    self.prefilter_skipped
1530                        .as_deref()
1531                        .unwrap_or("content scoring")
1532                )
1533            }
1534            (Some(prefilter), Some(candidates)) => {
1535                let pct = if self.corpus > 0 {
1536                    100.0 * candidates as f64 / self.corpus as f64
1537                } else {
1538                    0.0
1539                };
1540                let hint = if pct >= 50.0 {
1541                    "  — low selectivity; anchor the regex on a rarer literal substring"
1542                } else {
1543                    ""
1544                };
1545                format!(
1546                    "[explain] trigram prefilter: {prefilter}\n\
1547                     [explain] candidates: {candidates} / {} corpus rows ({pct:.1}%) to verify{hint}",
1548                    self.corpus
1549                )
1550            }
1551            (Some(prefilter), None) if has_content_query && self.prefilter_skipped.is_some() => {
1552                format!(
1553                    "[explain] trigram prefilter available: {prefilter}\n\
1554                 [explain] skipped trigram prefilter: {}; direct scan of {} corpus rows",
1555                    self.prefilter_skipped.as_deref().unwrap_or("not used"),
1556                    self.corpus
1557                )
1558            }
1559            // A field verified in SQLite with no prefilter at all (a literal or regex tool-name
1560            // search): the query scanned the corpus, so there is no candidate count to compare it
1561            // against, and the reason is stated rather than the anchor advice, which would point
1562            // at an index that field does not have.
1563            (None, None) if has_content_query && self.prefilter_skipped.is_some() => format!(
1564                "[explain] {}; scanned {} corpus rows",
1565                self.prefilter_skipped.as_deref().unwrap_or("no prefilter"),
1566                self.corpus
1567            ),
1568            _ if has_content_query => format!(
1569                "[explain] query has no >=3-char literal anchor → full scan of {} corpus rows",
1570                self.corpus
1571            ),
1572            _ => format!(
1573                "[explain] {} corpus rows; no content query was provided",
1574                self.corpus
1575            ),
1576        }
1577    }
1578}
1579
1580/// A message classified by one of the selected capability's rules.
1581#[derive(Debug, Clone, Serialize, Deserialize)]
1582pub struct MessageClassificationMatch {
1583    pub session_id: String,
1584    /// Session-local message sequence used with `session_id` for exact focused recovery.
1585    pub message_seq: i64,
1586    pub provider: Provider,
1587    pub ts: Option<DateTime<Utc>>,
1588    /// Which compiled classification policy produced this match. Only the name: version and
1589    /// digest are reported once per run in `MessageClassificationReport::policies` rather than
1590    /// repeated on every row.
1591    pub policy_name: String,
1592    pub category: String,
1593    /// The exact substring that matched, not the rule that matched it: this is
1594    /// `Regex::find(..).as_str()` from the classifier (`db.rs`), so for the rule
1595    /// `\byou forgot\b` the value is `you forgot`.
1596    pub matched_text: String,
1597    /// Exact Unicode-scalar coordinates of `matched_text` in `content`.
1598    pub match_start_char: usize,
1599    pub match_end_char_exclusive: usize,
1600    pub content: String,
1601}
1602
1603/// Aggregate slash-command usage frequency.
1604#[derive(Debug, Clone, Serialize)]
1605pub struct PlanningCount {
1606    pub command: String,
1607    pub count: i64,
1608    pub unique_sessions: i64,
1609    pub unique_projects: i64,
1610}
1611
1612/// Structured filters for the `files` query surface (search / cross-ref).
1613/// `pattern` is a glob (`*`/`?`) over the basename, or over the full path when it
1614/// contains a `/`. `limit == 0` means unlimited; `offset` skips rows in the surface's
1615/// deterministic order.
1616#[derive(Debug, Clone, Default)]
1617pub struct FileQuery {
1618    pub pattern: Option<String>,
1619    /// Restrict to one indexed session source.
1620    pub provider: Option<Provider>,
1621    /// Exact canonical session ID. Prefer this when chaining from session/message search output.
1622    pub session_id: Option<String>,
1623    /// Restrict to sessions whose cwd, repo root, or source transcript starts with this prefix.
1624    pub path_prefix: Option<String>,
1625    /// Exclude edits from sessions whose cwd, repo root, or source transcript starts with a prefix.
1626    /// Applied before grouping, reconstruction selection, and result limits.
1627    pub exclude_path_prefixes: Vec<String>,
1628    /// Exclude exact canonical session IDs before grouping, reconstruction selection, and limits.
1629    pub exclude_session_ids: Vec<String>,
1630    pub since: Option<DateTime<Utc>>,
1631    pub until: Option<DateTime<Utc>>,
1632    pub min_edits: Option<i64>,
1633    pub max_edits: Option<i64>,
1634    /// Maximum rows returned by file search, cross-reference, and history. Zero explicitly means
1635    /// unlimited and may materialize the complete filtered result.
1636    pub limit: usize,
1637    /// Rows skipped after the surface's documented deterministic ordering.
1638    pub offset: usize,
1639}
1640
1641/// One aggregate row per file across the filtered edit set (`files search`).
1642#[derive(Debug, Clone, Serialize)]
1643pub struct FileEditSummary {
1644    pub file_path: String,
1645    pub file_name: String,
1646    pub edits: i64,
1647    pub sessions: i64,
1648    pub last_edited: Option<DateTime<Utc>>,
1649}
1650
1651/// One reconstructed version (edit) of a file within a session (`files history`).
1652#[derive(Debug, Clone, Serialize)]
1653pub struct FileVersion {
1654    pub session_id: String,
1655    pub provider: Provider,
1656    pub version: i64,
1657    pub tool: String,
1658    pub ts: Option<DateTime<Utc>>,
1659    pub lines: i64,
1660    pub file_path: String,
1661}
1662
1663/// A file ↔ session linkage with that pair's edit count (`files cross-ref`).
1664#[derive(Debug, Clone, Serialize)]
1665pub struct FileCrossRef {
1666    pub file_path: String,
1667    pub session_id: String,
1668    pub provider: Provider,
1669    pub edits: i64,
1670}
1671
1672#[derive(Debug, Clone, Serialize)]
1673pub struct ProviderHealth {
1674    pub provider: Provider,
1675    pub enabled: bool,
1676    pub cli_available: bool,
1677    pub roots: Vec<String>,
1678    pub discovered_files: usize,
1679    pub indexed_sessions: i64,
1680    /// Discovered files for this provider that produced no session row. `discovered_files` and
1681    /// `indexed_sessions` above come from different subsystems (filesystem discovery and the
1682    /// index) and are NOT two ends of one subtraction: retained sessions make the second exceed
1683    /// the first. This field is the reconciliation, computed by set difference.
1684    pub unindexed_files: i64,
1685    pub expected_parse_version: String,
1686    pub current_sessions: i64,
1687    pub stale_sessions: i64,
1688    pub repairable_stale_sessions: i64,
1689    pub unavailable_stale_sessions: i64,
1690    pub resume_command: Option<String>,
1691}
1692
1693#[derive(Debug, Clone, Serialize)]
1694pub struct DiagnosticStatus {
1695    pub db_path: String,
1696    #[serde(flatten)]
1697    pub index_status: IndexStatus,
1698    pub discovery_warnings: Vec<crate::source::ProviderDiscoveryWarning>,
1699    pub providers: Vec<ProviderHealth>,
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704    use super::*;
1705
1706    #[test]
1707    fn message_provenance_accepts_complete_unicode_scalar_parts_and_scoped_correlation() {
1708        let provenance = MessageProvenance {
1709            authorship: MessageAuthorship::Mixed,
1710            record_relation: MessageRecordRelation::Mirror,
1711            correlation_identity: Some(MessageCorrelationIdentity {
1712                authority: MessageCorrelationAuthority::Google,
1713                scope: "gemini_conversation_event".to_string(),
1714                id: "event-7".to_string(),
1715            }),
1716            content_parts: vec![
1717                MessageContentPart {
1718                    ordinal: 0,
1719                    start_char: 0,
1720                    end_char: 2,
1721                    authorship: ContentPartAuthorship::Human,
1722                    origin: ContentPartOrigin::DirectInput,
1723                },
1724                MessageContentPart {
1725                    ordinal: 1,
1726                    start_char: 2,
1727                    end_char: 4,
1728                    authorship: ContentPartAuthorship::Generated,
1729                    origin: ContentPartOrigin::QuotedContent,
1730                },
1731            ],
1732        };
1733
1734        // Four Unicode scalars but seven UTF-8 bytes proves these are character, not byte, ranges.
1735        assert_eq!("aé日🙂".chars().count(), 4);
1736        assert!(provenance.validate("aé日🙂").is_ok());
1737    }
1738
1739    #[test]
1740    fn message_provenance_rejects_ambiguous_parts_and_correlation_identities() {
1741        let valid_parts = vec![
1742            MessageContentPart {
1743                ordinal: 0,
1744                start_char: 0,
1745                end_char: 2,
1746                authorship: ContentPartAuthorship::Human,
1747                origin: ContentPartOrigin::DirectInput,
1748            },
1749            MessageContentPart {
1750                ordinal: 1,
1751                start_char: 2,
1752                end_char: 4,
1753                authorship: ContentPartAuthorship::Generated,
1754                origin: ContentPartOrigin::QuotedContent,
1755            },
1756        ];
1757        for (provenance, expected) in [
1758            (
1759                MessageProvenance {
1760                    authorship: MessageAuthorship::Human,
1761                    content_parts: valid_parts.clone(),
1762                    ..Default::default()
1763                },
1764                "only when message authorship is \"mixed\"",
1765            ),
1766            (
1767                MessageProvenance {
1768                    authorship: MessageAuthorship::Mixed,
1769                    ..Default::default()
1770                },
1771                "requires content_parts",
1772            ),
1773            (
1774                MessageProvenance {
1775                    authorship: MessageAuthorship::Mixed,
1776                    content_parts: vec![MessageContentPart {
1777                        ordinal: 0,
1778                        start_char: 0,
1779                        end_char: 4,
1780                        authorship: ContentPartAuthorship::Human,
1781                        origin: ContentPartOrigin::DirectInput,
1782                    }],
1783                    ..Default::default()
1784                },
1785                "at least two distinct",
1786            ),
1787            (
1788                MessageProvenance {
1789                    authorship: MessageAuthorship::Mixed,
1790                    content_parts: vec![
1791                        valid_parts[0].clone(),
1792                        MessageContentPart {
1793                            start_char: 3,
1794                            ..valid_parts[1].clone()
1795                        },
1796                    ],
1797                    ..Default::default()
1798                },
1799                "must start at character 2",
1800            ),
1801            (
1802                MessageProvenance {
1803                    authorship: MessageAuthorship::Unknown,
1804                    correlation_identity: Some(MessageCorrelationIdentity {
1805                        authority: MessageCorrelationAuthority::Google,
1806                        scope: " ".to_string(),
1807                        id: "event".to_string(),
1808                    }),
1809                    content_parts: Vec::new(),
1810                    ..Default::default()
1811                },
1812                "scope must not be empty",
1813            ),
1814        ] {
1815            let error = provenance.validate("aé日🙂").unwrap_err();
1816            assert!(error.contains(expected), "{error}");
1817        }
1818    }
1819
1820    #[test]
1821    fn correlation_authority_prevents_equal_native_ids_from_collapsing() {
1822        let first = MessageCorrelationIdentity {
1823            authority: MessageCorrelationAuthority::OpenAi,
1824            scope: "response_item".to_string(),
1825            id: "shared".to_string(),
1826        };
1827        let second = MessageCorrelationIdentity {
1828            authority: MessageCorrelationAuthority::Google,
1829            ..first.clone()
1830        };
1831        assert_ne!(first, second);
1832    }
1833
1834    #[test]
1835    fn original_and_mirror_rows_share_one_correlation_identity() {
1836        let identity = MessageCorrelationIdentity {
1837            authority: MessageCorrelationAuthority::Google,
1838            scope: "conversation_event".to_string(),
1839            id: "event-7".to_string(),
1840        };
1841        let original = MessageProvenance {
1842            record_relation: MessageRecordRelation::Original,
1843            correlation_identity: Some(identity.clone()),
1844            ..Default::default()
1845        };
1846        let mirror = MessageProvenance {
1847            record_relation: MessageRecordRelation::Mirror,
1848            correlation_identity: Some(identity.clone()),
1849            ..Default::default()
1850        };
1851
1852        assert_ne!(original, mirror);
1853        assert_eq!(original.correlation_identity, mirror.correlation_identity);
1854    }
1855
1856    #[test]
1857    fn known_mirror_relation_does_not_require_a_correlation_identity() {
1858        let provenance = MessageProvenance {
1859            record_relation: MessageRecordRelation::Mirror,
1860            ..Default::default()
1861        };
1862        assert!(provenance.validate("replayed transcript").is_ok());
1863    }
1864
1865    /// Naming no set must EXCLUDE unwanted classes, never INCLUDE the known ones. An inclusion
1866    /// list drops every row whose stored `kind` is outside the current enum, which is any row
1867    /// written by another build. That shipped briefly and was caught by an existing test
1868    /// inserting `kind = 'message'`; this pins the distinction so it cannot return.
1869    #[test]
1870    fn default_class_selection_excludes_rather_than_enumerates() {
1871        let default = MessageFilters::default();
1872        assert_eq!(
1873            default.kind_predicate(),
1874            KindPredicate::AllExcept(vec![MessageKind::HarnessNotice]),
1875            "the default must be an exclusion so unrecognized kinds survive"
1876        );
1877
1878        let no_compaction = MessageFilters {
1879            no_compaction: true,
1880            ..Default::default()
1881        };
1882        assert_eq!(
1883            no_compaction.kind_predicate(),
1884            KindPredicate::AllExcept(vec![MessageKind::HarnessNotice, MessageKind::Compaction]),
1885            "no_compaction narrows the same exclusion rather than switching to an inclusion"
1886        );
1887    }
1888
1889    /// The storage layer keeps a partial index over exactly `EXCLUDABLE_BY_DEFAULT` so a count
1890    /// over the default class predicate never reads message rows. Every class an exclusion
1891    /// predicate can name must be in that set, or the count silently returns to a table scan.
1892    #[test]
1893    fn every_default_exclusion_is_covered_by_the_excludable_set() {
1894        for no_compaction in [false, true] {
1895            let filters = MessageFilters {
1896                no_compaction,
1897                ..Default::default()
1898            };
1899            let KindPredicate::AllExcept(excluded) = filters.kind_predicate() else {
1900                panic!("naming no set must produce an exclusion");
1901            };
1902            for kind in excluded {
1903                assert!(
1904                    MessageKind::EXCLUDABLE_BY_DEFAULT.contains(&kind),
1905                    "{kind:?} is excluded by default but missing from EXCLUDABLE_BY_DEFAULT"
1906                );
1907            }
1908        }
1909        let mut names: Vec<&str> = MessageKind::EXCLUDABLE_BY_DEFAULT
1910            .iter()
1911            .map(MessageKind::as_str)
1912            .collect();
1913        let rendered = names.clone();
1914        names.sort_unstable();
1915        assert_eq!(rendered, names, "kept sorted so rendered SQL is stable");
1916    }
1917
1918    /// The session-class spellings are the providers' own, not invented here, and the
1919    /// alternatives considered are locked out rather than silently accepted.
1920    ///
1921    /// Evidence, gathered from live data before choosing:
1922    /// - codex stores this exact enum as `thread_source` on its session metadata, valued
1923    ///   `user` (167 rollouts) and `subagent` (247) — the same partition, already named.
1924    /// - `subagent` is unanimous across every provider that marks the concept: codex's
1925    ///   `thread_source` value and `source.subagent` key, the `subagents` directory claude and
1926    ///   cursor both write, claude's `subagent_type` Task parameter (in 15 of 401 transcripts
1927    ///   scanned), and gemini-cli's `subagent_N` author labels.
1928    ///
1929    /// `agent` is rejected on purpose: a subagent IS an agent, so `[agent]` could be read as
1930    /// "every session" or "every session that is not a subagent", and the caller cannot tell
1931    /// which from the name. Nothing about a subagent is a user, so `user` has no such reading.
1932    /// `top_level` is rejected as a different vocabulary from its sibling — it answers where a
1933    /// session sits rather than who started it. Asserting they do NOT parse, because a
1934    /// presence-only test would let either come back beside the good spelling.
1935    #[test]
1936    fn session_class_spellings_match_the_providers_and_exclude_the_ambiguous_ones() {
1937        use std::str::FromStr;
1938
1939        assert_eq!(SessionKind::User.as_str(), "user");
1940        assert_eq!(SessionKind::Subagent.as_str(), "subagent");
1941        for kind in SessionKind::all() {
1942            assert_eq!(
1943                SessionKind::from_str(kind.as_str()),
1944                Ok(kind),
1945                "every spelling this emits must be one it accepts"
1946            );
1947        }
1948
1949        for rejected in [
1950            "agent",
1951            "top_level",
1952            "top-level",
1953            "sidechain",
1954            "child",
1955            "spawned",
1956        ] {
1957            let error = SessionKind::from_str(rejected)
1958                .expect_err(&format!("{rejected} must not be an accepted spelling"));
1959            assert!(
1960                error.contains("user") && error.contains("subagent"),
1961                "a rejection must name both accepted values: {error}"
1962            );
1963        }
1964
1965        // Case and separator tolerance, matching MessageKind's parser.
1966        assert_eq!(SessionKind::from_str("SubAgent"), Ok(SessionKind::Subagent));
1967    }
1968
1969    /// `..SearchFilters::default()` is used throughout the db, service, and tail-parse tests,
1970    /// which is only sound while the default filters nothing: a field defaulting to a
1971    /// restrictive value would silently narrow every one of those tests rather than failing
1972    /// one of them.
1973    ///
1974    /// The destructuring is the point. Adding a field to [`SearchFilters`] breaks THIS test to
1975    /// compile — "pattern does not mention field" — which is the signal the explicit
1976    /// field-by-field literals in those tests used to give, concentrated in the one place that
1977    /// can say what to do about it: assert here what the new field's default selects, then
1978    /// leave the other tests alone.
1979    #[test]
1980    fn the_default_session_filter_selects_every_session() {
1981        let SearchFilters {
1982            provider,
1983            session_kinds,
1984            path_prefix,
1985            exclude_path_prefixes,
1986            exclude_session_ids,
1987            parent_session_id,
1988            since,
1989            until,
1990            limit,
1991            warnings_only,
1992        } = SearchFilters::default();
1993
1994        assert_eq!(provider, None, "no provider named means every provider");
1995        assert_eq!(session_kinds, None, "no class named means every class");
1996        assert_eq!(path_prefix, None);
1997        assert!(exclude_path_prefixes.is_empty());
1998        assert!(exclude_session_ids.is_empty());
1999        assert_eq!(parent_session_id, None, "not restricted to one spawner");
2000        assert_eq!(since, None);
2001        assert_eq!(until, None);
2002        assert_eq!(limit, 0, "0 is the unbounded page; callers state their own");
2003        assert!(!warnings_only);
2004    }
2005
2006    /// The default returns both classes. Subagent runs are real work — 4,051 of them against
2007    /// 858 user-started claude sessions here — so unlike `MessageKind::HarnessNotice`, which
2008    /// is harness bookkeeping and stays out by default, hiding these would answer "what did I
2009    /// do about X" with half the evidence.
2010    #[test]
2011    fn the_default_session_class_set_is_every_class() {
2012        assert_eq!(
2013            SearchFilters::default().effective_session_kinds(),
2014            vec![SessionKind::User, SessionKind::Subagent]
2015        );
2016        assert_eq!(SessionKind::default_search_set(), SessionKind::all());
2017
2018        // A named set is honored exactly, including the empty one.
2019        let only_runs = SearchFilters {
2020            session_kinds: Some(vec![SessionKind::Subagent]),
2021            ..SearchFilters::default()
2022        };
2023        assert_eq!(
2024            only_runs.effective_session_kinds(),
2025            vec![SessionKind::Subagent]
2026        );
2027        let none = SearchFilters {
2028            session_kinds: Some(Vec::new()),
2029            ..SearchFilters::default()
2030        };
2031        assert!(
2032            none.effective_session_kinds().is_empty(),
2033            "an empty set is a caller who deselected every class, not an absent one"
2034        );
2035    }
2036
2037    /// A named set is an inclusion, because the caller stated exactly what they want.
2038    #[test]
2039    fn a_named_class_set_selects_exactly_those_classes() {
2040        let notices = MessageFilters {
2041            kinds: Some(vec![MessageKind::HarnessNotice]),
2042            ..Default::default()
2043        };
2044        assert_eq!(
2045            notices.kind_predicate(),
2046            KindPredicate::Only(vec![MessageKind::HarnessNotice]),
2047            "asking for harness notices must return them, not self-cancel against the default"
2048        );
2049
2050        // no_compaction still narrows a named set, so the two cannot disagree.
2051        let conflict = MessageFilters {
2052            kinds: Some(vec![MessageKind::Compaction]),
2053            no_compaction: true,
2054            ..Default::default()
2055        };
2056        assert_eq!(
2057            conflict.kind_predicate(),
2058            KindPredicate::Only(Vec::new()),
2059            "an emptied set is reported as empty so the caller gets an error, not silence"
2060        );
2061    }
2062
2063    /// The default set is derived, so a new variant joins it automatically and a deliberate
2064    /// exclusion is visible in the diff rather than hidden in a hand-written list.
2065    #[test]
2066    fn the_default_search_set_is_every_class_except_harness_notices() {
2067        let set = MessageKind::default_search_set();
2068        assert!(!set.contains(&MessageKind::HarnessNotice));
2069        for kind in [
2070            MessageKind::Conversation,
2071            MessageKind::Compaction,
2072            MessageKind::ToolCall,
2073            MessageKind::ToolResult,
2074            MessageKind::Unknown,
2075        ] {
2076            assert!(
2077                set.contains(&kind),
2078                "{kind:?} must be searchable by default"
2079            );
2080        }
2081    }
2082
2083    /// Every typed value this crate writes as a string parses back as the same value.
2084    ///
2085    /// `as_str` is what the database, JSON output, and CLI display carry, and `from_str` is what
2086    /// reads them back, so the pair is one contract exercised from two directions. Only two of the
2087    /// enums had a round-trip test; the rest were covered from whichever side their feature needed,
2088    /// which leaves a spelling that renders one way and parses another undetected until a stored
2089    /// row or a piped value fails to decode.
2090    #[test]
2091    fn every_typed_value_parses_back_from_the_string_it_renders() {
2092        use std::str::FromStr;
2093
2094        macro_rules! assert_round_trips {
2095            ($($type:ty => [$($variant:expr),+ $(,)?]),+ $(,)?) => {
2096                $($(
2097                    let rendered = $variant.as_str();
2098                    assert_eq!(
2099                        <$type>::from_str(rendered).ok(),
2100                        Some($variant),
2101                        "{} renders as {rendered:?}, which parses back as something else",
2102                        stringify!($variant),
2103                    );
2104                )+)+
2105            };
2106        }
2107
2108        assert_round_trips! {
2109            Provider => [
2110                Provider::Claude, Provider::ClaudeDesktop, Provider::Codex, Provider::Cursor,
2111                Provider::Antigravity, Provider::Pi, Provider::PrimeAgent, Provider::AiStudio,
2112                Provider::GeminiCli,
2113            ],
2114            Role => [Role::User, Role::Assistant, Role::Tool, Role::Slash, Role::Compaction],
2115            MessageSearchMode => [
2116                MessageSearchMode::Literal, MessageSearchMode::Regex, MessageSearchMode::Fuzzy,
2117            ],
2118            SearchField => [
2119                SearchField::Content, SearchField::ToolName, SearchField::ToolArgument,
2120            ],
2121            MessageKind => [
2122                MessageKind::Conversation, MessageKind::Compaction, MessageKind::ToolCall,
2123                MessageKind::ToolResult, MessageKind::HarnessNotice, MessageKind::Unknown,
2124            ],
2125            MessageAuthorship => [
2126                MessageAuthorship::Human, MessageAuthorship::Agent, MessageAuthorship::Harness,
2127                MessageAuthorship::Generated, MessageAuthorship::Mixed, MessageAuthorship::Unknown,
2128            ],
2129            ContentPartAuthorship => [
2130                ContentPartAuthorship::Human, ContentPartAuthorship::Agent,
2131                ContentPartAuthorship::Harness, ContentPartAuthorship::Generated,
2132                ContentPartAuthorship::Unknown,
2133            ],
2134            ContentPartOrigin => [
2135                ContentPartOrigin::DirectInput, ContentPartOrigin::QuotedContent,
2136                ContentPartOrigin::HarnessContext, ContentPartOrigin::GeneratedSummary,
2137                ContentPartOrigin::ToolPayload, ContentPartOrigin::Unknown,
2138            ],
2139            MessageCorrelationAuthority => [
2140                MessageCorrelationAuthority::Anthropic, MessageCorrelationAuthority::OpenAi,
2141                MessageCorrelationAuthority::Cursor, MessageCorrelationAuthority::Google,
2142                MessageCorrelationAuthority::Pi, MessageCorrelationAuthority::PrimeAgent,
2143            ],
2144            MessageRecordRelation => [
2145                MessageRecordRelation::Original, MessageRecordRelation::Mirror,
2146                MessageRecordRelation::Unknown,
2147            ],
2148            SessionKind => [SessionKind::User, SessionKind::Subagent],
2149        }
2150    }
2151
2152    #[test]
2153    fn every_message_kind_round_trips_through_its_database_spelling() {
2154        use clap::ValueEnum;
2155
2156        for kind in MessageKind::value_variants() {
2157            assert_eq!(
2158                MessageKind::from_db_str(kind.as_str()),
2159                *kind,
2160                "{} must not decode as a different kind after a database read",
2161                kind.as_str()
2162            );
2163        }
2164    }
2165
2166    #[test]
2167    fn unrecognized_provider_spelling_is_rejected_in_every_build() {
2168        let error = Provider::from_db_str("not_a_provider").unwrap_err();
2169        assert!(
2170            error.contains("not_a_provider"),
2171            "the error must identify the corrupt stored value: {error}"
2172        );
2173    }
2174
2175    #[test]
2176    fn unrecognized_role_spelling_is_rejected_in_every_build() {
2177        let error = Role::from_db_str("not_a_role").unwrap_err();
2178        assert!(
2179            error.contains("not_a_role"),
2180            "the error must identify the corrupt stored value: {error}"
2181        );
2182    }
2183
2184    // `MessageKind` is the deliberate exception to the rule above, and this test pins the
2185    // exception so nobody "fixes" it into consistency. An earlier attempt to give it the same
2186    // `debug_assert!` broke
2187    // `cli_search_self_heals_v4_hybrid_missing_trigram_from_intact_messages`, which reads rows
2188    // storing `kind = 'message'`: older indexes legitimately hold spellings this build does not
2189    // know, and absorbing them is what `Unknown` is FOR. Panicking would turn "opened an older
2190    // index" into a crash on the very path that exists to heal it.
2191    #[test]
2192    fn unrecognized_message_kind_decodes_quietly_because_older_indexes_hold_other_spellings() {
2193        assert_eq!(
2194            MessageKind::from_db_str("message"),
2195            MessageKind::Unknown,
2196            "a legacy on-disk spelling must decode, not panic"
2197        );
2198        assert_eq!(
2199            MessageKind::from_db_str(MessageKind::Unknown.as_str()),
2200            MessageKind::Unknown,
2201            "\"unknown\" is itself a legitimate stored spelling"
2202        );
2203    }
2204
2205    #[test]
2206    fn programmatic_search_field_accepts_cli_spelling_but_serializes_canonically() {
2207        assert_eq!(
2208            "tool-argument".parse::<SearchField>().unwrap(),
2209            SearchField::ToolArgument
2210        );
2211        assert_eq!(
2212            "tool_argument".parse::<SearchField>().unwrap(),
2213            SearchField::ToolArgument
2214        );
2215        assert_eq!(SearchField::ToolArgument.as_str(), "tool_argument");
2216        assert_eq!(
2217            serde_json::to_string(&SearchField::ToolArgument).unwrap(),
2218            "\"tool_argument\""
2219        );
2220    }
2221
2222    #[test]
2223    fn every_parsed_vocabulary_names_its_accepted_values_when_rejecting_input() {
2224        // These `FromStr` errors surface directly to Python callers, who have neither the MCP
2225        // schema's enum nor clap's `[possible values: ...]` to fall back on, so the accepted set
2226        // has to live in the message itself. Asserting all four together keeps a newly added
2227        // vocabulary from silently reverting to a bare rejection.
2228        for (rejected, accepted) in [
2229            (
2230                "usr".parse::<Role>().unwrap_err(),
2231                vec!["user", "assistant", "tool", "slash", "compaction"],
2232            ),
2233            (
2234                "contnet".parse::<SearchField>().unwrap_err(),
2235                vec!["content", "tool_name", "tool_argument"],
2236            ),
2237            (
2238                "chatgpt".parse::<Provider>().unwrap_err(),
2239                vec![
2240                    "claude",
2241                    "claude-desktop",
2242                    "codex",
2243                    "cursor",
2244                    "antigravity",
2245                    "pi",
2246                    "aistudio",
2247                    "gemini-cli",
2248                ],
2249            ),
2250            (
2251                "converstaion".parse::<MessageKind>().unwrap_err(),
2252                vec![
2253                    "conversation",
2254                    "compaction",
2255                    "tool_call",
2256                    "tool_result",
2257                    "unknown",
2258                ],
2259            ),
2260        ] {
2261            assert!(
2262                rejected.contains("must be one of"),
2263                "no accepted-value list in: {rejected}"
2264            );
2265            for value in accepted {
2266                assert!(
2267                    rejected.contains(&format!("{value:?}")),
2268                    "{value} missing from: {rejected}"
2269                );
2270            }
2271        }
2272    }
2273
2274    #[test]
2275    fn fuzzy_message_validation_rejects_short_queries_before_database_work() {
2276        let filters = MessageFilters {
2277            match_mode: MessageSearchMode::Fuzzy,
2278            limit: 5,
2279            ..Default::default()
2280        };
2281
2282        for query in ["", "a", "éx"] {
2283            let error = filters.validate(query).unwrap_err().to_string();
2284            assert!(
2285                error.contains("at least 3 characters"),
2286                "unexpected error for {query:?}: {error}"
2287            );
2288            assert!(error.contains("literal"), "{error}");
2289        }
2290        assert!(filters.validate("éx!").is_ok());
2291    }
2292
2293    #[test]
2294    fn fuzzy_message_validation_requires_a_finite_page_without_an_arbitrary_window_cap() {
2295        let unlimited = MessageFilters {
2296            match_mode: MessageSearchMode::Fuzzy,
2297            ..Default::default()
2298        };
2299        assert!(unlimited
2300            .validate("query")
2301            .unwrap_err()
2302            .to_string()
2303            .contains("finite non-zero limit"));
2304
2305        let large_valid_page = MessageFilters {
2306            match_mode: MessageSearchMode::Fuzzy,
2307            offset: 10_000,
2308            limit: 1,
2309            ..Default::default()
2310        };
2311        assert!(large_valid_page.validate("query").is_ok());
2312
2313        let overflowing = MessageFilters {
2314            match_mode: MessageSearchMode::Fuzzy,
2315            offset: usize::MAX,
2316            limit: 1,
2317            ..Default::default()
2318        };
2319        assert!(overflowing
2320            .validate("query")
2321            .unwrap_err()
2322            .to_string()
2323            .contains("signed 64-bit"));
2324
2325        let whitespace = MessageFilters {
2326            match_mode: MessageSearchMode::Fuzzy,
2327            limit: 5,
2328            ..Default::default()
2329        };
2330        assert!(whitespace
2331            .validate(" \t\n")
2332            .unwrap_err()
2333            .to_string()
2334            .contains("non-whitespace"));
2335    }
2336
2337    #[test]
2338    fn legacy_tool_argument_filter_rejects_malformed_json_pointer_escapes() {
2339        let filters = MessageFilters {
2340            field: Some(SearchField::ToolArgument),
2341            argument_path: Some("/~bad".into()),
2342            ..Default::default()
2343        };
2344        let error = filters.validate("needle").unwrap_err().to_string();
2345        assert!(error.contains("RFC 6901"), "{error}");
2346        assert!(error.contains("'~' must be followed"), "{error}");
2347    }
2348
2349    #[test]
2350    fn message_page_validation_rejects_values_sqlite_cannot_bind() {
2351        let filters = MessageFilters {
2352            offset: i64::MAX as usize,
2353            limit: 1,
2354            ..Default::default()
2355        };
2356        assert!(filters
2357            .validate("")
2358            .unwrap_err()
2359            .to_string()
2360            .contains("offset + limit"));
2361
2362        if let Some(too_large) = usize::try_from(i64::MAX)
2363            .ok()
2364            .and_then(|maximum| maximum.checked_add(1))
2365        {
2366            let filters = MessageFilters {
2367                offset: too_large,
2368                ..Default::default()
2369            };
2370            assert!(filters
2371                .validate("")
2372                .unwrap_err()
2373                .to_string()
2374                .contains("offset exceeds"));
2375        }
2376    }
2377
2378    #[test]
2379    fn every_provider_has_a_concrete_display_name_and_resume_contract() {
2380        let providers = crate::source::PROVIDERS;
2381        assert!(providers
2382            .iter()
2383            .all(|provider| !provider.display_name().trim().is_empty()));
2384        assert_eq!(
2385            providers
2386                .into_iter()
2387                .filter(|provider| provider.supports_native_resume())
2388                .map(Provider::as_str)
2389                .collect::<Vec<_>>(),
2390            ["claude", "codex", "pi", "prime-agent"]
2391        );
2392    }
2393
2394    #[test]
2395    fn explain_summary_reports_prefilter_and_selectivity_pct() {
2396        let ex = SearchExplain {
2397            prefilter: Some("\"abc\"".to_string()),
2398            candidates: Some(80),
2399            prefilter_skipped: None,
2400            corpus: 100,
2401        };
2402        let s = ex.summary(true);
2403        assert!(s.contains("trigram prefilter: \"abc\""), "{s}");
2404        assert!(s.contains("80 / 100 corpus rows (80.0%)"), "{s}");
2405        // 80% candidates is non-selective → the slow-query hint must fire.
2406        assert!(s.contains("low selectivity"), "{s}");
2407    }
2408
2409    #[test]
2410    fn explain_summary_omits_hint_when_prefilter_is_selective() {
2411        let ex = SearchExplain {
2412            prefilter: Some("\"rareword\"".to_string()),
2413            candidates: Some(2),
2414            prefilter_skipped: None,
2415            corpus: 1000,
2416        };
2417        let s = ex.summary(true);
2418        assert!(s.contains("2 / 1000 corpus rows (0.2%)"), "{s}");
2419        assert!(
2420            !s.contains("low selectivity"),
2421            "selective query gets no hint: {s}"
2422        );
2423    }
2424
2425    #[test]
2426    fn explain_summary_flags_regex_without_literal_anchor() {
2427        let ex = SearchExplain {
2428            prefilter: None,
2429            candidates: None,
2430            prefilter_skipped: None,
2431            corpus: 500,
2432        };
2433        let s = ex.summary(true);
2434        assert!(s.contains("no >=3-char literal anchor"), "{s}");
2435        assert!(s.contains("full scan of 500 corpus rows"), "{s}");
2436    }
2437
2438    #[test]
2439    fn explain_summary_notes_no_content_query_for_empty_searches() {
2440        let ex = SearchExplain {
2441            prefilter: None,
2442            candidates: None,
2443            prefilter_skipped: None,
2444            corpus: 42,
2445        };
2446        let s = ex.summary(false);
2447        assert!(s.contains("42 corpus rows"), "{s}");
2448        assert!(s.contains("no content query was provided"), "{s}");
2449    }
2450
2451    #[test]
2452    fn explain_summary_handles_empty_corpus_without_dividing_by_zero() {
2453        let ex = SearchExplain {
2454            prefilter: Some("\"x\"".to_string()),
2455            candidates: Some(0),
2456            prefilter_skipped: None,
2457            corpus: 0,
2458        };
2459        let s = ex.summary(true);
2460        assert!(s.contains("0 / 0 corpus rows (0.0%)"), "{s}");
2461    }
2462
2463    #[test]
2464    fn explain_summary_reports_intentional_prefilter_skip() {
2465        let ex = SearchExplain {
2466            prefilter: Some("\"rare\"".to_string()),
2467            candidates: None,
2468            prefilter_skipped: Some("corpus below configured threshold".to_string()),
2469            corpus: 25,
2470        };
2471        let s = ex.summary(true);
2472        assert!(s.contains("trigram prefilter available"), "{s}");
2473        assert!(s.contains("skipped trigram prefilter"), "{s}");
2474        assert!(s.contains("direct scan of 25 corpus rows"), "{s}");
2475    }
2476
2477    /// A tool-name literal or regex search verifies in SQLite with no prefilter and no candidate
2478    /// count; the line names that and the corpus, and never the trigram-anchor advice, which
2479    /// would point at an index the field does not have.
2480    #[test]
2481    fn explain_summary_names_a_verified_scan_without_inventing_a_candidate_count() {
2482        let ex = SearchExplain {
2483            prefilter: None,
2484            candidates: None,
2485            prefilter_skipped: Some("tool name verified in SQLite without a prefilter".to_string()),
2486            corpus: 3,
2487        };
2488        let s = ex.summary(true);
2489        assert!(
2490            s.contains("tool name verified in SQLite without a prefilter"),
2491            "{s}"
2492        );
2493        assert!(s.contains("scanned 3 corpus rows"), "{s}");
2494        assert!(!s.contains("anchor"), "{s}");
2495        assert!(!s.contains("%"), "{s}");
2496    }
2497}