Skip to main content

ai_session_search/
message_search.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4use std::borrow::Cow;
5use std::collections::BTreeMap;
6use std::num::{NonZeroU32, NonZeroUsize};
7use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
8
9use chrono::{DateTime, Utc};
10use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
11use nucleo_matcher::{Config as NucleoConfig, Matcher as NucleoMatcher, Utf32Str};
12use serde::ser::{SerializeMap, SerializeSeq};
13use serde::{Deserialize, Serialize, Serializer};
14use thiserror::Error;
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::hashing::FramedSha256;
18use crate::models::{MessageKind, MessageSearchMode, Provider, Role, SearchField, SessionMeta};
19use crate::refs::MessageRef;
20
21pub const DEFAULT_MATCH_EVIDENCE_MAX_CHARS: usize = 220;
22/// Version of the cross-surface structured message-search response contract.
23///
24/// This is intentionally independent of the SQLite schema version.
25pub const MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION: u32 = 1;
26
27#[derive(Debug, Clone, PartialEq, Eq, Error)]
28pub enum MessageSearchError {
29    #[error("{kind} query must not be empty")]
30    EmptyQuery { kind: &'static str },
31    #[error("fuzzy query must contain at least 3 Unicode scalar values")]
32    ShortFuzzyQuery,
33    #[error("fuzzy query must contain a non-whitespace character")]
34    BlankFuzzyQuery,
35    #[error("invalid regex: {0}")]
36    InvalidRegex(String),
37    #[error("invalid RFC 6901 JSON pointer: {0}")]
38    InvalidJsonPointer(String),
39    #[error("{parameter}: {reason}")]
40    InvalidParameter {
41        parameter: &'static str,
42        reason: String,
43    },
44    #[error("conflicting message-search parameters: {0}")]
45    Conflict(String),
46}
47
48impl MessageSearchError {
49    pub const fn code(&self) -> &'static str {
50        match self {
51            Self::EmptyQuery { .. }
52            | Self::ShortFuzzyQuery
53            | Self::BlankFuzzyQuery
54            | Self::InvalidRegex(_) => "invalid-query",
55            Self::InvalidJsonPointer(_) => "invalid-json-pointer",
56            Self::InvalidParameter { .. } => "invalid-parameter",
57            Self::Conflict(_) => "parameter-conflict",
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
63#[serde(transparent)]
64pub struct LiteralQuery(String);
65
66impl LiteralQuery {
67    pub fn new(value: impl Into<String>) -> Result<Self, MessageSearchError> {
68        nonempty_query(value, "literal").map(Self)
69    }
70
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
77#[serde(transparent)]
78pub struct FuzzyQuery(String);
79
80impl FuzzyQuery {
81    pub fn new(value: impl Into<String>) -> Result<Self, MessageSearchError> {
82        let value = nonempty_query(value, "fuzzy")?;
83        if value.trim().is_empty() {
84            return Err(MessageSearchError::BlankFuzzyQuery);
85        }
86        if value.chars().take(3).count() < 3 {
87            return Err(MessageSearchError::ShortFuzzyQuery);
88        }
89        Ok(Self(value))
90    }
91
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
98#[serde(transparent)]
99pub struct ValidatedRegex(String);
100
101impl ValidatedRegex {
102    pub fn new(value: impl Into<String>) -> Result<Self, MessageSearchError> {
103        let value = nonempty_query(value, "regex")?;
104        regex::Regex::new(&value)
105            .map_err(|error| MessageSearchError::InvalidRegex(error.to_string()))?;
106        Ok(Self(value))
107    }
108
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112}
113
114fn nonempty_query(
115    value: impl Into<String>,
116    kind: &'static str,
117) -> Result<String, MessageSearchError> {
118    let value = value.into();
119    if value.is_empty() {
120        return Err(MessageSearchError::EmptyQuery { kind });
121    }
122    Ok(value)
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
126#[serde(tag = "mode", content = "query", rename_all = "kebab-case")]
127pub enum MessageQuery {
128    All,
129    Literal(LiteralQuery),
130    Regex(ValidatedRegex),
131    Fuzzy(FuzzyQuery),
132}
133
134impl MessageQuery {
135    pub fn literal(value: impl Into<String>) -> Result<Self, MessageSearchError> {
136        LiteralQuery::new(value).map(Self::Literal)
137    }
138
139    pub fn regex(value: impl Into<String>) -> Result<Self, MessageSearchError> {
140        ValidatedRegex::new(value).map(Self::Regex)
141    }
142
143    pub fn fuzzy(value: impl Into<String>) -> Result<Self, MessageSearchError> {
144        FuzzyQuery::new(value).map(Self::Fuzzy)
145    }
146
147    pub fn text(&self) -> Option<&str> {
148        match self {
149            Self::All => None,
150            Self::Literal(value) => Some(value.as_str()),
151            Self::Regex(value) => Some(value.as_str()),
152            Self::Fuzzy(value) => Some(value.as_str()),
153        }
154    }
155
156    pub const fn mode(&self) -> Option<MessageSearchMode> {
157        match self {
158            Self::All => None,
159            Self::Literal(_) => Some(MessageSearchMode::Literal),
160            Self::Regex(_) => Some(MessageSearchMode::Regex),
161            Self::Fuzzy(_) => Some(MessageSearchMode::Fuzzy),
162        }
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
167#[serde(transparent)]
168pub struct JsonPointer(String);
169
170impl JsonPointer {
171    pub fn new(value: impl Into<String>) -> Result<Self, MessageSearchError> {
172        let value = value.into();
173        if !value.is_empty() && !value.starts_with('/') {
174            return Err(MessageSearchError::InvalidJsonPointer(
175                "a nonempty pointer must start with '/'".into(),
176            ));
177        }
178        let bytes = value.as_bytes();
179        let mut index = 0;
180        while index < bytes.len() {
181            if bytes[index] == b'~' {
182                if !matches!(bytes.get(index + 1), Some(b'0' | b'1')) {
183                    return Err(MessageSearchError::InvalidJsonPointer(
184                        "'~' must be followed by '0' or '1'".into(),
185                    ));
186                }
187                index += 1;
188            }
189            index += 1;
190        }
191        Ok(Self(value))
192    }
193
194    pub fn as_str(&self) -> &str {
195        &self.0
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
200pub struct MessageTarget {
201    field: SearchField,
202    argument_path: Option<JsonPointer>,
203}
204
205impl MessageTarget {
206    pub const fn content() -> Self {
207        Self {
208            field: SearchField::Content,
209            argument_path: None,
210        }
211    }
212
213    pub const fn tool_name() -> Self {
214        Self {
215            field: SearchField::ToolName,
216            argument_path: None,
217        }
218    }
219
220    pub fn tool_argument(path: impl Into<String>) -> Result<Self, MessageSearchError> {
221        Ok(Self {
222            field: SearchField::ToolArgument,
223            argument_path: Some(JsonPointer::new(path)?),
224        })
225    }
226
227    pub const fn field(&self) -> SearchField {
228        self.field
229    }
230
231    pub fn argument_path(&self) -> Option<&JsonPointer> {
232        self.argument_path.as_ref()
233    }
234}
235
236#[derive(
237    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum,
238)]
239#[serde(rename_all = "kebab-case")]
240pub enum MatchWindow {
241    #[default]
242    Earliest,
243    Latest,
244}
245
246#[derive(
247    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum,
248)]
249#[serde(rename_all = "kebab-case")]
250pub enum ReceiptLevel {
251    #[default]
252    None,
253    Summary,
254    Full,
255}
256
257/// Preset for the amount of one selected message value returned to a caller.
258///
259/// This changes presentation only. Result membership, ordering, context, includes, and receipts
260/// are controlled independently.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
262#[serde(rename_all = "kebab-case")]
263pub enum DetailLevel {
264    Compact,
265    Full,
266}
267
268/// Character budget for the boundary/full-value view of one selected message field.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
270#[serde(tag = "kind", rename_all = "snake_case")]
271pub enum FieldViewBudget {
272    NoCharLimit,
273    MaxChars { max_chars: NonZeroUsize },
274}
275
276impl FieldViewBudget {
277    pub fn max_chars(max_chars: usize) -> Result<Self, MessageSearchError> {
278        NonZeroUsize::new(max_chars)
279            .map(|max_chars| Self::MaxChars { max_chars })
280            .ok_or_else(|| MessageSearchError::InvalidParameter {
281                parameter: "field_view",
282                reason: "max_chars must be a positive character count; use kind=no_char_limit to apply no additional character limit after line selection".into(),
283            })
284    }
285}
286
287/// Character budget for the independently match-centered view.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
289#[serde(tag = "kind", rename_all = "snake_case")]
290pub enum MatchViewBudget {
291    MinimalSpan,
292    MaxChars { max_chars: NonZeroUsize },
293}
294
295/// Optional payload groups added to the stable message-search semantic core.
296///
297/// Context and receipt metadata intentionally are not include groups: their dedicated parameters
298/// remain the single owners of those payloads.
299#[derive(
300    Debug,
301    Clone,
302    Copy,
303    PartialEq,
304    Eq,
305    PartialOrd,
306    Ord,
307    Hash,
308    Serialize,
309    Deserialize,
310    clap::ValueEnum,
311)]
312#[serde(rename_all = "snake_case")]
313#[clap(rename_all = "snake_case")]
314pub enum MessageSearchInclude {
315    NormalizedSessionMetadata,
316    ParsedReferences,
317    RawProviderMetadata,
318    RuntimeDiagnostics,
319}
320
321impl MatchViewBudget {
322    pub fn max_chars(max_chars: usize) -> Result<Self, MessageSearchError> {
323        NonZeroUsize::new(max_chars)
324            .map(|max_chars| Self::MaxChars { max_chars })
325            .ok_or_else(|| MessageSearchError::InvalidParameter {
326                parameter: "match_view",
327                reason: "max_chars must be a positive character count; use kind=minimal_span for only the match span".into(),
328            })
329    }
330}
331
332fn decode_view_max_chars(
333    object: &serde_json::Map<String, serde_json::Value>,
334    parameter: &str,
335    maximum: usize,
336) -> Result<usize, MessageSearchError> {
337    let raw = object
338        .get("max_chars")
339        .ok_or_else(|| MessageSearchError::InvalidParameter {
340            parameter: "view_budget",
341            reason: format!("{parameter}.max_chars is required when kind=max_chars"),
342        })?;
343    let raw = raw
344        .as_u64()
345        .ok_or_else(|| MessageSearchError::InvalidParameter {
346            parameter: "view_budget",
347            reason: format!("{parameter}.max_chars must be a positive integer"),
348        })?;
349    let value = usize::try_from(raw).map_err(|_| MessageSearchError::InvalidParameter {
350        parameter: "view_budget",
351        reason: format!("{parameter}.max_chars exceeds this platform's supported integer range"),
352    })?;
353    if value == 0 || value > maximum {
354        return Err(MessageSearchError::InvalidParameter {
355            parameter: "view_budget",
356            reason: format!(
357                "{parameter}.max_chars must be an integer from 1 through {maximum}; got {value}"
358            ),
359        });
360    }
361    Ok(value)
362}
363
364fn reject_view_unknown_fields(
365    object: &serde_json::Map<String, serde_json::Value>,
366    parameter: &str,
367    accepts_max_chars: bool,
368) -> Result<(), MessageSearchError> {
369    if let Some(key) = object
370        .keys()
371        .find(|key| *key != "kind" && !(accepts_max_chars && *key == "max_chars"))
372    {
373        return Err(MessageSearchError::InvalidParameter {
374            parameter: "view_budget",
375            reason: format!(
376                "{parameter} contains unknown field {key:?}; accepted fields are {}",
377                if accepts_max_chars {
378                    "kind and max_chars"
379                } else {
380                    "kind"
381                }
382            ),
383        });
384    }
385    Ok(())
386}
387
388/// Decode the object-shaped field-view contract shared by MCP and Python adapters.
389pub fn decode_field_view_budget(
390    value: &serde_json::Value,
391    maximum: usize,
392) -> Result<FieldViewBudget, MessageSearchError> {
393    let object = value
394        .as_object()
395        .ok_or_else(|| MessageSearchError::InvalidParameter {
396            parameter: "field_view",
397            reason: "must be an object with a kind field".into(),
398        })?;
399    match object.get("kind").and_then(serde_json::Value::as_str) {
400        Some("no_char_limit") => {
401            reject_view_unknown_fields(object, "field_view", false)?;
402            Ok(FieldViewBudget::NoCharLimit)
403        }
404        Some("max_chars") => {
405            reject_view_unknown_fields(object, "field_view", true)?;
406            FieldViewBudget::max_chars(decode_view_max_chars(object, "field_view", maximum)?)
407        }
408        Some(other) => Err(MessageSearchError::InvalidParameter {
409            parameter: "field_view",
410            reason: format!("kind must be no_char_limit or max_chars; got {other:?}"),
411        }),
412        None => Err(MessageSearchError::InvalidParameter {
413            parameter: "field_view",
414            reason: "kind is required".into(),
415        }),
416    }
417}
418
419/// Decode the object-shaped match-view contract shared by MCP and Python adapters.
420pub fn decode_match_view_budget(
421    value: &serde_json::Value,
422    maximum: usize,
423) -> Result<MatchViewBudget, MessageSearchError> {
424    let object = value
425        .as_object()
426        .ok_or_else(|| MessageSearchError::InvalidParameter {
427            parameter: "match_view",
428            reason: "must be an object with a kind field".into(),
429        })?;
430    match object.get("kind").and_then(serde_json::Value::as_str) {
431        Some("minimal_span") => {
432            reject_view_unknown_fields(object, "match_view", false)?;
433            Ok(MatchViewBudget::MinimalSpan)
434        }
435        Some("max_chars") => {
436            reject_view_unknown_fields(object, "match_view", true)?;
437            MatchViewBudget::max_chars(decode_view_max_chars(object, "match_view", maximum)?)
438        }
439        Some(other) => Err(MessageSearchError::InvalidParameter {
440            parameter: "match_view",
441            reason: format!("kind must be minimal_span or max_chars; got {other:?}"),
442        }),
443        None => Err(MessageSearchError::InvalidParameter {
444            parameter: "match_view",
445            reason: "kind is required".into(),
446        }),
447    }
448}
449
450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
451pub struct ContextWindow {
452    messages_before: usize,
453    messages_after: usize,
454}
455
456impl ContextWindow {
457    pub const fn new(messages_before: usize, messages_after: usize) -> Self {
458        Self {
459            messages_before,
460            messages_after,
461        }
462    }
463
464    pub const fn symmetric(count: usize) -> Self {
465        Self::new(count, count)
466    }
467
468    pub const fn messages_before(self) -> usize {
469        self.messages_before
470    }
471
472    pub const fn messages_after(self) -> usize {
473        self.messages_after
474    }
475}
476
477#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
478#[serde(rename_all = "kebab-case")]
479pub enum LineWindow {
480    #[default]
481    Full,
482    Head(NonZeroUsize),
483    Tail(NonZeroUsize),
484}
485
486impl LineWindow {
487    pub fn from_signed(value: i64) -> Result<Self, MessageSearchError> {
488        match value.cmp(&0) {
489            std::cmp::Ordering::Equal => Ok(Self::Full),
490            std::cmp::Ordering::Greater => usize::try_from(value)
491                .ok()
492                .and_then(NonZeroUsize::new)
493                .map(Self::Head)
494                .ok_or_else(|| MessageSearchError::InvalidParameter {
495                    parameter: "lines_per_message",
496                    reason: "positive value exceeds usize".into(),
497                }),
498            std::cmp::Ordering::Less => value
499                .checked_abs()
500                .and_then(|absolute| usize::try_from(absolute).ok())
501                .and_then(NonZeroUsize::new)
502                .map(Self::Tail)
503                .ok_or_else(|| MessageSearchError::InvalidParameter {
504                    parameter: "lines_per_message",
505                    reason: "negative magnitude exceeds usize".into(),
506                }),
507        }
508    }
509
510    pub fn to_signed(self) -> Result<i64, MessageSearchError> {
511        match self {
512            Self::Full => Ok(0),
513            Self::Head(lines) => {
514                i64::try_from(lines.get()).map_err(|_| MessageSearchError::InvalidParameter {
515                    parameter: "lines_per_message",
516                    reason: "head line count exceeds i64".into(),
517                })
518            }
519            Self::Tail(lines) => i64::try_from(lines.get()).map(|value| -value).map_err(|_| {
520                MessageSearchError::InvalidParameter {
521                    parameter: "lines_per_message",
522                    reason: "tail line count exceeds i64".into(),
523                }
524            }),
525        }
526    }
527}
528
529/// Requested message-hit extent before surface defaults are resolved.
530///
531/// `Page { limit: None, .. }` deliberately has surface-specific meaning: Rust, CLI, and Python
532/// resolve it to all literal, regex, or no-text matches, while MCP resolves it to its configured
533/// finite page. Fuzzy search always requires a finite resolved page. `AllResults` is the explicit
534/// cross-surface override and is never silently converted into a page.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
536pub enum RequestedExtent {
537    Page {
538        limit: Option<NonZeroUsize>,
539        offset: usize,
540    },
541    AllResults {
542        offset: usize,
543    },
544}
545
546impl Default for RequestedExtent {
547    fn default() -> Self {
548        Self::Page {
549            limit: None,
550            offset: 0,
551        }
552    }
553}
554
555impl RequestedExtent {
556    pub fn page(limit: Option<usize>, offset: usize) -> Result<Self, MessageSearchError> {
557        let limit = match limit {
558            Some(0) => {
559                return Err(MessageSearchError::InvalidParameter {
560                    parameter: "limit",
561                    reason: "use all_results instead of zero".into(),
562                })
563            }
564            Some(value) => NonZeroUsize::new(value),
565            None => None,
566        };
567        Ok(Self::Page { limit, offset })
568    }
569
570    pub const fn all_results() -> Self {
571        Self::AllResults { offset: 0 }
572    }
573
574    pub const fn all_results_from(offset: usize) -> Self {
575        Self::AllResults { offset }
576    }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
580#[serde(transparent)]
581struct NonEmptyValue(String);
582
583impl NonEmptyValue {
584    pub fn new(
585        parameter: &'static str,
586        value: impl Into<String>,
587    ) -> Result<Self, MessageSearchError> {
588        let value = value.into();
589        if value.is_empty() {
590            return Err(MessageSearchError::InvalidParameter {
591                parameter,
592                reason: "must not be empty".into(),
593            });
594        }
595        Ok(Self(value))
596    }
597
598    pub fn as_str(&self) -> &str {
599        &self.0
600    }
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
604pub struct SequenceRange {
605    from: Option<i64>,
606    to: Option<i64>,
607}
608
609impl SequenceRange {
610    pub fn new(from: Option<i64>, to: Option<i64>) -> Result<Self, MessageSearchError> {
611        if from.is_some_and(|value| value < 0) || to.is_some_and(|value| value < 0) {
612            return Err(MessageSearchError::InvalidParameter {
613                parameter: "sequence",
614                reason: "bounds must be nonnegative".into(),
615            });
616        }
617        if let (Some(from), Some(to)) = (from, to) {
618            if from > to {
619                return Err(MessageSearchError::InvalidParameter {
620                    parameter: "sequence",
621                    reason: format!("seq_from {from} exceeds seq_to {to}"),
622                });
623            }
624        }
625        Ok(Self { from, to })
626    }
627
628    pub const fn from(&self) -> Option<i64> {
629        self.from
630    }
631
632    pub const fn to(&self) -> Option<i64> {
633        self.to
634    }
635}
636
637#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
638pub struct RequestedTimeRange {
639    since: Option<DateTime<Utc>>,
640    until: Option<DateTime<Utc>>,
641}
642
643impl RequestedTimeRange {
644    pub fn new(
645        since: Option<DateTime<Utc>>,
646        until: Option<DateTime<Utc>>,
647    ) -> Result<Self, MessageSearchError> {
648        if let (Some(since), Some(until)) = (since, until) {
649            if since > until {
650                return Err(MessageSearchError::InvalidParameter {
651                    parameter: "time",
652                    reason: "since must not be later than until".into(),
653                });
654            }
655        }
656        Ok(Self { since, until })
657    }
658
659    pub const fn since(&self) -> Option<DateTime<Utc>> {
660        self.since
661    }
662
663    pub const fn until(&self) -> Option<DateTime<Utc>> {
664        self.until
665    }
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
669pub struct MessagePredicates {
670    role: Option<Role>,
671    kinds: Option<Vec<MessageKind>>,
672    providers: Option<Vec<Provider>>,
673    session: Option<NonEmptyValue>,
674    workspace_path_prefix: Option<NonEmptyValue>,
675    transcript_path_prefix: Option<NonEmptyValue>,
676    exclude_workspace_path_prefixes: Vec<NonEmptyValue>,
677    exclude_transcript_path_prefixes: Vec<NonEmptyValue>,
678    exclude_session_ids: Vec<NonEmptyValue>,
679    time: RequestedTimeRange,
680    sequence: Option<SequenceRange>,
681    tool_name_contains: Option<NonEmptyValue>,
682    include_compaction: bool,
683}
684
685impl Default for MessagePredicates {
686    fn default() -> Self {
687        Self {
688            role: None,
689            kinds: None,
690            providers: None,
691            session: None,
692            workspace_path_prefix: None,
693            transcript_path_prefix: None,
694            exclude_workspace_path_prefixes: Vec::new(),
695            exclude_transcript_path_prefixes: Vec::new(),
696            exclude_session_ids: Vec::new(),
697            time: RequestedTimeRange::default(),
698            sequence: None,
699            tool_name_contains: None,
700            include_compaction: true,
701        }
702    }
703}
704
705impl MessagePredicates {
706    pub const fn role(&self) -> Option<Role> {
707        self.role
708    }
709
710    pub fn kinds(&self) -> Option<&[MessageKind]> {
711        self.kinds.as_deref()
712    }
713
714    /// Selected session sources in canonical identifier order, or `None` for every source.
715    pub fn providers(&self) -> Option<&[Provider]> {
716        self.providers.as_deref()
717    }
718
719    pub fn session(&self) -> Option<&str> {
720        self.session.as_ref().map(NonEmptyValue::as_str)
721    }
722
723    pub fn workspace_path_prefix(&self) -> Option<&str> {
724        self.workspace_path_prefix
725            .as_ref()
726            .map(NonEmptyValue::as_str)
727    }
728
729    pub fn transcript_path_prefix(&self) -> Option<&str> {
730        self.transcript_path_prefix
731            .as_ref()
732            .map(NonEmptyValue::as_str)
733    }
734
735    pub fn exclude_workspace_path_prefixes(&self) -> impl Iterator<Item = &str> {
736        self.exclude_workspace_path_prefixes
737            .iter()
738            .map(NonEmptyValue::as_str)
739    }
740
741    pub fn exclude_transcript_path_prefixes(&self) -> impl Iterator<Item = &str> {
742        self.exclude_transcript_path_prefixes
743            .iter()
744            .map(NonEmptyValue::as_str)
745    }
746
747    pub fn exclude_session_ids(&self) -> impl Iterator<Item = &str> {
748        self.exclude_session_ids.iter().map(NonEmptyValue::as_str)
749    }
750
751    pub const fn time(&self) -> RequestedTimeRange {
752        self.time
753    }
754
755    pub const fn sequence(&self) -> Option<SequenceRange> {
756        self.sequence
757    }
758
759    pub fn tool_name_contains(&self) -> Option<&str> {
760        self.tool_name_contains.as_ref().map(NonEmptyValue::as_str)
761    }
762
763    pub const fn include_compaction(&self) -> bool {
764        self.include_compaction
765    }
766}
767
768#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
769pub struct MessagePresentation {
770    message_lines: Option<LineWindow>,
771    detail: Option<DetailLevel>,
772    field_view: Option<FieldViewBudget>,
773    match_view: Option<MatchViewBudget>,
774}
775
776impl MessagePresentation {
777    pub const fn message_lines(&self) -> Option<LineWindow> {
778        self.message_lines
779    }
780
781    pub const fn detail(&self) -> Option<DetailLevel> {
782        self.detail
783    }
784
785    pub const fn field_view(&self) -> Option<FieldViewBudget> {
786        self.field_view
787    }
788
789    pub const fn match_view(&self) -> Option<MatchViewBudget> {
790        self.match_view
791    }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
795pub struct PurposeSelection {
796    name: NonEmptyValue,
797    version: Option<NonZeroU32>,
798}
799
800impl PurposeSelection {
801    pub fn new(
802        name: impl Into<String>,
803        version: Option<NonZeroU32>,
804    ) -> Result<Self, MessageSearchError> {
805        let name = NonEmptyValue::new("purpose", name)?;
806        if !is_dash_separated_phrase(name.as_str()) {
807            return Err(MessageSearchError::InvalidParameter {
808                parameter: "purpose",
809                reason: "must be a lowercase dash-separated phrase".into(),
810            });
811        }
812        Ok(Self { name, version })
813    }
814
815    pub fn name(&self) -> &str {
816        self.name.as_str()
817    }
818
819    pub const fn version(&self) -> Option<NonZeroU32> {
820        self.version
821    }
822}
823
824pub(crate) fn is_dash_separated_phrase(value: &str) -> bool {
825    !value.is_empty()
826        && value
827            .split('-')
828            .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_lowercase()))
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
832#[serde(rename_all = "kebab-case")]
833pub enum SearchSurface {
834    Rust,
835    Cli,
836    Mcp,
837    Python,
838}
839
840impl SearchSurface {
841    pub const ALL: &'static [Self] = &[Self::Rust, Self::Cli, Self::Mcp, Self::Python];
842}
843
844/// Stable conceptual identity for one message-search input.
845///
846/// Adapters may project a concept into idiomatic syntax, but they must not create a second
847/// semantic owner.
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
849#[serde(rename_all = "snake_case")]
850pub enum MessageSearchParameter {
851    Query,
852    QueryMode,
853    Field,
854    ArgumentPath,
855    Role,
856    Kinds,
857    Providers,
858    SessionId,
859    WorkspacePathPrefix,
860    TranscriptPathPrefix,
861    ExcludeWorkspacePathPrefixes,
862    ExcludeTranscriptPathPrefixes,
863    ExcludeSessionIds,
864    Since,
865    Until,
866    Sequence,
867    ToolNameContains,
868    IncludeCompaction,
869    MatchWindow,
870    Context,
871    ResultExtent,
872    Detail,
873    LinesPerMessage,
874    FieldView,
875    MatchView,
876    Purpose,
877    ReceiptLevel,
878    Include,
879}
880
881impl MessageSearchParameter {
882    pub const fn as_str(self) -> &'static str {
883        match self {
884            Self::Query => "query",
885            Self::QueryMode => "query_mode",
886            Self::Field => "field",
887            Self::ArgumentPath => "argument_path",
888            Self::Role => "role",
889            Self::Kinds => "kinds",
890            Self::Providers => "providers",
891            Self::SessionId => "session_id",
892            Self::WorkspacePathPrefix => "workspace_path_prefix",
893            Self::TranscriptPathPrefix => "transcript_path_prefix",
894            Self::ExcludeWorkspacePathPrefixes => "exclude_workspace_path_prefixes",
895            Self::ExcludeTranscriptPathPrefixes => "exclude_transcript_path_prefixes",
896            Self::ExcludeSessionIds => "exclude_session_ids",
897            Self::Since => "since",
898            Self::Until => "until",
899            Self::Sequence => "sequence",
900            Self::ToolNameContains => "tool_name_contains",
901            Self::IncludeCompaction => "include_compaction",
902            Self::MatchWindow => "match_window",
903            Self::Context => "context",
904            Self::ResultExtent => "result_extent",
905            Self::Detail => "detail",
906            Self::LinesPerMessage => "lines_per_message",
907            Self::FieldView => "field_view",
908            Self::MatchView => "match_view",
909            Self::Purpose => "purpose",
910            Self::ReceiptLevel => "receipt_level",
911            Self::Include => "include",
912        }
913    }
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
917#[serde(tag = "kind", rename_all = "snake_case")]
918pub enum MessageSearchObjectFieldDomain {
919    PositiveCharacterCount,
920}
921
922#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
923pub struct MessageSearchObjectFieldSpec {
924    name: &'static str,
925    required: bool,
926    domain: MessageSearchObjectFieldDomain,
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
930pub struct MessageSearchObjectVariantSpec {
931    value: &'static str,
932    selects: &'static str,
933    fields: Vec<MessageSearchObjectFieldSpec>,
934}
935
936#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
937#[serde(tag = "kind", rename_all = "snake_case")]
938pub enum MessageSearchParameterDomain {
939    Text {
940        non_empty: bool,
941    },
942    Boolean,
943    Enum {
944        accepted_values: Vec<String>,
945    },
946    NonEmptySet {
947        accepted_values: Vec<String>,
948    },
949    NonNegativeCount,
950    SignedEdgeCount,
951    TimeBound,
952    SequenceRange,
953    ContextWindow,
954    ResultExtent,
955    FieldView {
956        discriminator: &'static str,
957        accepted_variants: Vec<MessageSearchObjectVariantSpec>,
958    },
959    MatchView {
960        discriminator: &'static str,
961        accepted_variants: Vec<MessageSearchObjectVariantSpec>,
962    },
963    PurposeSelection,
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
967#[serde(rename_all = "snake_case")]
968pub enum MessageSearchOmission {
969    AllEligible,
970    TypedDefault,
971    SurfacePolicy,
972    NoAdditionalFilter,
973    QuerylessSearch,
974}
975
976#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
977pub struct MessageSearchParameterSpec {
978    parameter: MessageSearchParameter,
979    selects: &'static str,
980    domain: MessageSearchParameterDomain,
981    omission: MessageSearchOmission,
982    surfaces: &'static [SearchSurface],
983}
984
985impl MessageSearchParameterSpec {
986    pub const fn parameter(&self) -> MessageSearchParameter {
987        self.parameter
988    }
989
990    pub const fn selects(&self) -> &'static str {
991        self.selects
992    }
993
994    pub const fn domain(&self) -> &MessageSearchParameterDomain {
995        &self.domain
996    }
997
998    pub const fn omission(&self) -> MessageSearchOmission {
999        self.omission
1000    }
1001
1002    pub const fn surfaces(&self) -> &'static [SearchSurface] {
1003        self.surfaces
1004    }
1005
1006    pub fn accepted_values(&self) -> &[String] {
1007        match &self.domain {
1008            MessageSearchParameterDomain::Enum { accepted_values }
1009            | MessageSearchParameterDomain::NonEmptySet { accepted_values } => accepted_values,
1010            _ => &[],
1011        }
1012    }
1013}
1014
1015#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1016#[serde(rename_all = "snake_case")]
1017pub enum ValueOriginKind {
1018    Explicit,
1019    DetailPreset,
1020    Purpose,
1021    OperationConfig,
1022    SurfaceConfig,
1023    TypedDefault,
1024    Derived,
1025}
1026
1027/// Executable cross-parameter rule identity shared by request validation and caller specifications.
1028#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1029#[serde(rename_all = "snake_case")]
1030pub enum MessageSearchRule {
1031    DetailOwnsPresentationBudgets,
1032    SequenceRequiresSession,
1033    KindsMustRemainSatisfiable,
1034    CompactionRoleRequiresCompactionKind,
1035    ToolArgumentRequiresToolCallKind,
1036    MatchViewRequiresQuery,
1037    FuzzyRejectsMatchWindow,
1038    LatestWindowRequiresSession,
1039    FuzzyRejectsAllResults,
1040}
1041
1042impl MessageSearchRule {
1043    pub const ALL: &'static [Self] = &[
1044        Self::DetailOwnsPresentationBudgets,
1045        Self::SequenceRequiresSession,
1046        Self::KindsMustRemainSatisfiable,
1047        Self::CompactionRoleRequiresCompactionKind,
1048        Self::ToolArgumentRequiresToolCallKind,
1049        Self::MatchViewRequiresQuery,
1050        Self::FuzzyRejectsMatchWindow,
1051        Self::LatestWindowRequiresSession,
1052        Self::FuzzyRejectsAllResults,
1053    ];
1054
1055    pub const fn as_str(self) -> &'static str {
1056        match self {
1057            Self::DetailOwnsPresentationBudgets => "detail_owns_presentation_budgets",
1058            Self::SequenceRequiresSession => "sequence_requires_session",
1059            Self::KindsMustRemainSatisfiable => "kinds_must_remain_satisfiable",
1060            Self::CompactionRoleRequiresCompactionKind => {
1061                "compaction_role_requires_compaction_kind"
1062            }
1063            Self::ToolArgumentRequiresToolCallKind => "tool_argument_requires_tool_call_kind",
1064            Self::MatchViewRequiresQuery => "match_view_requires_query",
1065            Self::FuzzyRejectsMatchWindow => "fuzzy_rejects_match_window",
1066            Self::LatestWindowRequiresSession => "latest_window_requires_session",
1067            Self::FuzzyRejectsAllResults => "fuzzy_rejects_all_results",
1068        }
1069    }
1070
1071    pub const fn message(self) -> &'static str {
1072        match self {
1073            Self::DetailOwnsPresentationBudgets => {
1074                "detail conflicts with lines_per_message, field_view, and match_view; omit detail to compose custom presentation budgets"
1075            }
1076            Self::SequenceRequiresSession => "sequence bounds require one session",
1077            Self::KindsMustRemainSatisfiable => {
1078                "the selected kinds exclude every message class, so nothing can match"
1079            }
1080            Self::CompactionRoleRequiresCompactionKind => {
1081                "role=compaction requires compaction among the selected kinds; include_compaction=false or a kinds set without it removes every match"
1082            }
1083            Self::ToolArgumentRequiresToolCallKind => {
1084                "tool-argument target requires tool_call among the selected kinds"
1085            }
1086            Self::MatchViewRequiresQuery => {
1087                "match_view requires a literal, regex, or fuzzy query"
1088            }
1089            Self::FuzzyRejectsMatchWindow => "match_window does not apply to fuzzy queries",
1090            Self::LatestWindowRequiresSession => "match_window=latest requires one session",
1091            Self::FuzzyRejectsAllResults => "fuzzy search does not support all_results",
1092        }
1093    }
1094
1095    fn error(self) -> MessageSearchError {
1096        MessageSearchError::Conflict(format!("{}: {}", self.as_str(), self.message()))
1097    }
1098}
1099
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1101pub struct MessageSearchRuleDescriptor {
1102    rule: MessageSearchRule,
1103    message: &'static str,
1104}
1105
1106impl MessageSearchRuleDescriptor {
1107    const fn new(rule: MessageSearchRule) -> Self {
1108        Self {
1109            rule,
1110            message: rule.message(),
1111        }
1112    }
1113
1114    pub const fn rule(self) -> MessageSearchRule {
1115        self.rule
1116    }
1117
1118    pub const fn message(self) -> &'static str {
1119        self.message
1120    }
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1124pub struct MessageSearchParameterRegistry {
1125    purpose: &'static str,
1126    parameters: Vec<MessageSearchParameterSpec>,
1127    precedence: &'static [ValueOriginKind],
1128    #[serde(skip)]
1129    rules: &'static [MessageSearchRule],
1130    #[serde(rename = "rules")]
1131    rule_descriptors: Vec<MessageSearchRuleDescriptor>,
1132}
1133
1134impl MessageSearchParameterRegistry {
1135    /// Return the process-wide immutable input catalogue.
1136    ///
1137    /// Construction is `O(P + V)` time and memory for bounded parameter and vocabulary counts and
1138    /// occurs once per process. Search calls never clone or attach this catalogue to responses.
1139    pub fn current() -> &'static Self {
1140        static REGISTRY: std::sync::OnceLock<MessageSearchParameterRegistry> =
1141            std::sync::OnceLock::new();
1142        REGISTRY.get_or_init(Self::build)
1143    }
1144
1145    pub const fn purpose(&self) -> &'static str {
1146        self.purpose
1147    }
1148
1149    pub fn parameters(&self) -> &[MessageSearchParameterSpec] {
1150        &self.parameters
1151    }
1152
1153    pub fn parameter(
1154        &self,
1155        parameter: MessageSearchParameter,
1156    ) -> Option<&MessageSearchParameterSpec> {
1157        self.parameters
1158            .iter()
1159            .find(|candidate| candidate.parameter == parameter)
1160    }
1161
1162    pub const fn precedence(&self) -> &'static [ValueOriginKind] {
1163        self.precedence
1164    }
1165
1166    pub const fn rules(&self) -> &'static [MessageSearchRule] {
1167        self.rules
1168    }
1169
1170    pub fn rule_descriptors(&self) -> &[MessageSearchRuleDescriptor] {
1171        &self.rule_descriptors
1172    }
1173
1174    fn build() -> Self {
1175        fn serialized_variants<T>() -> Vec<String>
1176        where
1177            T: clap::ValueEnum + Copy + Serialize + 'static,
1178        {
1179            T::value_variants()
1180                .iter()
1181                .map(|value| {
1182                    serde_json::to_value(*value)
1183                        .expect("message-search enum serializes")
1184                        .as_str()
1185                        .expect("message-search enum serializes as a string")
1186                        .to_owned()
1187                })
1188                .collect()
1189        }
1190
1191        let enum_domain = |accepted_values| MessageSearchParameterDomain::Enum { accepted_values };
1192        let set_domain =
1193            |accepted_values| MessageSearchParameterDomain::NonEmptySet { accepted_values };
1194        let max_chars_field = || MessageSearchObjectFieldSpec {
1195            name: "max_chars",
1196            required: true,
1197            domain: MessageSearchObjectFieldDomain::PositiveCharacterCount,
1198        };
1199        let parameter = |parameter, selects, domain, omission| MessageSearchParameterSpec {
1200            parameter,
1201            selects,
1202            domain,
1203            omission,
1204            surfaces: SearchSurface::ALL,
1205        };
1206        let parameters = vec![
1207            parameter(
1208                MessageSearchParameter::Query,
1209                "Text to match.",
1210                MessageSearchParameterDomain::Text { non_empty: false },
1211                MessageSearchOmission::QuerylessSearch,
1212            ),
1213            parameter(
1214                MessageSearchParameter::QueryMode,
1215                "How query is matched.",
1216                enum_domain(serialized_variants::<MessageSearchMode>()),
1217                MessageSearchOmission::TypedDefault,
1218            ),
1219            parameter(
1220                MessageSearchParameter::Field,
1221                "Which field to match.",
1222                enum_domain(serialized_variants::<SearchField>()),
1223                MessageSearchOmission::TypedDefault,
1224            ),
1225            parameter(
1226                MessageSearchParameter::ArgumentPath,
1227                "RFC 6901 pointer into tool arguments; needs field=tool_argument",
1228                MessageSearchParameterDomain::Text { non_empty: true },
1229                MessageSearchOmission::NoAdditionalFilter,
1230            ),
1231            parameter(
1232                MessageSearchParameter::Role,
1233                "Author role.",
1234                enum_domain(serialized_variants::<Role>()),
1235                MessageSearchOmission::AllEligible,
1236            ),
1237            parameter(
1238                MessageSearchParameter::Kinds,
1239                "Message classes to return.",
1240                set_domain(serialized_variants::<MessageKind>()),
1241                MessageSearchOmission::TypedDefault,
1242            ),
1243            parameter(
1244                MessageSearchParameter::Providers,
1245                "Session sources.",
1246                set_domain(
1247                    crate::source::PROVIDERS
1248                        .iter()
1249                        .map(|provider| provider.as_str().to_owned())
1250                        .collect(),
1251                ),
1252                MessageSearchOmission::AllEligible,
1253            ),
1254            parameter(
1255                MessageSearchParameter::SessionId,
1256                "One session by ID or prefix.",
1257                MessageSearchParameterDomain::Text { non_empty: true },
1258                MessageSearchOmission::AllEligible,
1259            ),
1260            parameter(
1261                MessageSearchParameter::WorkspacePathPrefix,
1262                "Match cwd or repo-root prefix.",
1263                MessageSearchParameterDomain::Text { non_empty: true },
1264                MessageSearchOmission::AllEligible,
1265            ),
1266            parameter(
1267                MessageSearchParameter::TranscriptPathPrefix,
1268                "Match transcript-path prefix.",
1269                MessageSearchParameterDomain::Text { non_empty: true },
1270                MessageSearchOmission::AllEligible,
1271            ),
1272            parameter(
1273                MessageSearchParameter::ExcludeWorkspacePathPrefixes,
1274                "Excluded cwd/repo-root prefixes.",
1275                MessageSearchParameterDomain::Text { non_empty: true },
1276                MessageSearchOmission::NoAdditionalFilter,
1277            ),
1278            parameter(
1279                MessageSearchParameter::ExcludeTranscriptPathPrefixes,
1280                "Excluded transcript prefixes.",
1281                MessageSearchParameterDomain::Text { non_empty: true },
1282                MessageSearchOmission::NoAdditionalFilter,
1283            ),
1284            parameter(
1285                MessageSearchParameter::ExcludeSessionIds,
1286                "Excluded session IDs.",
1287                MessageSearchParameterDomain::Text { non_empty: true },
1288                MessageSearchOmission::NoAdditionalFilter,
1289            ),
1290            parameter(
1291                MessageSearchParameter::Since,
1292                "Lower time bound.",
1293                MessageSearchParameterDomain::TimeBound,
1294                MessageSearchOmission::NoAdditionalFilter,
1295            ),
1296            parameter(
1297                MessageSearchParameter::Until,
1298                "Upper time bound.",
1299                MessageSearchParameterDomain::TimeBound,
1300                MessageSearchOmission::NoAdditionalFilter,
1301            ),
1302            parameter(
1303                MessageSearchParameter::Sequence,
1304                "Sequence bound.",
1305                MessageSearchParameterDomain::SequenceRange,
1306                MessageSearchOmission::NoAdditionalFilter,
1307            ),
1308            parameter(
1309                MessageSearchParameter::ToolNameContains,
1310                "Substring in the tool name.",
1311                MessageSearchParameterDomain::Text { non_empty: true },
1312                MessageSearchOmission::NoAdditionalFilter,
1313            ),
1314            parameter(
1315                MessageSearchParameter::IncludeCompaction,
1316                "Keep compaction summaries.",
1317                MessageSearchParameterDomain::Boolean,
1318                MessageSearchOmission::TypedDefault,
1319            ),
1320            parameter(
1321                MessageSearchParameter::MatchWindow,
1322                "First N matches in session order, or last N of one session.",
1323                enum_domain(serialized_variants::<MatchWindow>()),
1324                MessageSearchOmission::TypedDefault,
1325            ),
1326            parameter(
1327                MessageSearchParameter::Context,
1328                "Neighbours each side per hit.",
1329                MessageSearchParameterDomain::ContextWindow,
1330                MessageSearchOmission::TypedDefault,
1331            ),
1332            parameter(
1333                MessageSearchParameter::ResultExtent,
1334                "Page size.",
1335                MessageSearchParameterDomain::ResultExtent,
1336                MessageSearchOmission::SurfacePolicy,
1337            ),
1338            parameter(
1339                MessageSearchParameter::Detail,
1340                "Presentation preset.",
1341                enum_domain(serialized_variants::<DetailLevel>()),
1342                MessageSearchOmission::SurfacePolicy,
1343            ),
1344            parameter(
1345                MessageSearchParameter::LinesPerMessage,
1346                "Line window.",
1347                MessageSearchParameterDomain::SignedEdgeCount,
1348                MessageSearchOmission::SurfacePolicy,
1349            ),
1350            parameter(
1351                MessageSearchParameter::FieldView,
1352                "Character budget for the field.",
1353                MessageSearchParameterDomain::FieldView {
1354                    discriminator: "kind",
1355                    accepted_variants: vec![
1356                        MessageSearchObjectVariantSpec {
1357                            value: "no_char_limit",
1358                            selects: "No additional character limit after line selection.",
1359                            fields: vec![],
1360                        },
1361                        MessageSearchObjectVariantSpec {
1362                            value: "max_chars",
1363                            selects: "At most max_chars Unicode-scalar characters from the field boundary.",
1364                            fields: vec![max_chars_field()],
1365                        },
1366                    ],
1367                },
1368                MessageSearchOmission::SurfacePolicy,
1369            ),
1370            parameter(
1371                MessageSearchParameter::MatchView,
1372                "Character budget around the match.",
1373                MessageSearchParameterDomain::MatchView {
1374                    discriminator: "kind",
1375                    accepted_variants: vec![
1376                        MessageSearchObjectVariantSpec {
1377                            value: "minimal_span",
1378                            selects: "Only the complete selected match span.",
1379                            fields: vec![],
1380                        },
1381                        MessageSearchObjectVariantSpec {
1382                            value: "max_chars",
1383                            selects: "Up to max_chars Unicode-scalar characters centered on the complete match.",
1384                            fields: vec![max_chars_field()],
1385                        },
1386                    ],
1387                },
1388                MessageSearchOmission::TypedDefault,
1389            ),
1390            parameter(
1391                MessageSearchParameter::Purpose,
1392                "Configured preference bundle.",
1393                MessageSearchParameterDomain::PurposeSelection,
1394                MessageSearchOmission::NoAdditionalFilter,
1395            ),
1396            parameter(
1397                MessageSearchParameter::ReceiptLevel,
1398                "Diagnostics.",
1399                enum_domain(serialized_variants::<ReceiptLevel>()),
1400                MessageSearchOmission::TypedDefault,
1401            ),
1402            parameter(
1403                MessageSearchParameter::Include,
1404                "Payload groups; a supplied set replaces the default",
1405                set_domain(serialized_variants::<MessageSearchInclude>()),
1406                MessageSearchOmission::TypedDefault,
1407            ),
1408        ];
1409        Self {
1410            purpose: "Search indexed AI-session messages while separating result selection, context, presentation, optional payloads, and receipts.",
1411            parameters,
1412            precedence: &[
1413                ValueOriginKind::Explicit,
1414                ValueOriginKind::DetailPreset,
1415                ValueOriginKind::Purpose,
1416                ValueOriginKind::OperationConfig,
1417                ValueOriginKind::SurfaceConfig,
1418                ValueOriginKind::TypedDefault,
1419                ValueOriginKind::Derived,
1420            ],
1421            rules: MessageSearchRule::ALL,
1422            rule_descriptors: MessageSearchRule::ALL
1423                .iter()
1424                .copied()
1425                .map(MessageSearchRuleDescriptor::new)
1426                .collect(),
1427        }
1428    }
1429}
1430
1431/// Static caller catalogue plus the real planner's configured default semantic request.
1432///
1433/// The registry is borrowed from the process-wide cache. The configured request is produced by
1434/// [`crate::service::MessageService::plan`], so this DTO cannot drift into a second precedence
1435/// resolver. Construction is independent of indexed result count and message bytes.
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1437pub struct MessageSearchSpecification {
1438    registry: &'static MessageSearchParameterRegistry,
1439    configured_default: ResolvedMessageSearchRequest,
1440}
1441
1442impl MessageSearchSpecification {
1443    pub(crate) fn new(configured_default: ResolvedMessageSearchRequest) -> Self {
1444        Self {
1445            registry: MessageSearchParameterRegistry::current(),
1446            configured_default,
1447        }
1448    }
1449
1450    pub const fn registry(&self) -> &'static MessageSearchParameterRegistry {
1451        self.registry
1452    }
1453
1454    pub const fn configured_default(&self) -> &ResolvedMessageSearchRequest {
1455        &self.configured_default
1456    }
1457}
1458
1459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1460#[serde(tag = "source", rename_all = "kebab-case")]
1461pub enum ValueOrigin {
1462    Explicit,
1463    DetailPreset { detail: DetailLevel },
1464    Purpose { name: String, version: NonZeroU32 },
1465    SurfaceConfig { surface: SearchSurface },
1466    OperationConfig,
1467    TypedDefault,
1468    Derived,
1469}
1470
1471#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1472pub struct MessageSearchOrigins {
1473    pub(crate) result_extent: ValueOrigin,
1474    pub(crate) context_messages_before: ValueOrigin,
1475    pub(crate) context_messages_after: ValueOrigin,
1476    pub(crate) includes: ValueOrigin,
1477    pub(crate) detail: ValueOrigin,
1478    pub(crate) lines_per_message: ValueOrigin,
1479    pub(crate) field_view: ValueOrigin,
1480    pub(crate) match_view: ValueOrigin,
1481    pub(crate) receipt_level: ValueOrigin,
1482    pub(crate) result_order: ValueOrigin,
1483}
1484
1485impl MessageSearchOrigins {
1486    pub fn result_extent(&self) -> &ValueOrigin {
1487        &self.result_extent
1488    }
1489
1490    pub fn context_messages_before(&self) -> &ValueOrigin {
1491        &self.context_messages_before
1492    }
1493
1494    pub fn context_messages_after(&self) -> &ValueOrigin {
1495        &self.context_messages_after
1496    }
1497
1498    pub fn includes(&self) -> &ValueOrigin {
1499        &self.includes
1500    }
1501
1502    pub fn detail(&self) -> &ValueOrigin {
1503        &self.detail
1504    }
1505
1506    pub fn lines_per_message(&self) -> &ValueOrigin {
1507        &self.lines_per_message
1508    }
1509
1510    pub fn field_view(&self) -> &ValueOrigin {
1511        &self.field_view
1512    }
1513
1514    pub fn match_view(&self) -> &ValueOrigin {
1515        &self.match_view
1516    }
1517
1518    pub fn receipt_level(&self) -> &ValueOrigin {
1519        &self.receipt_level
1520    }
1521
1522    pub fn result_order(&self) -> &ValueOrigin {
1523        &self.result_order
1524    }
1525}
1526
1527#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1528#[serde(rename_all = "kebab-case")]
1529pub enum ExecutionOrder {
1530    SessionSequence,
1531    FuzzyRelevance,
1532}
1533
1534#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1535pub enum ResolvedExtent {
1536    Page { limit: NonZeroUsize, offset: usize },
1537    AllResults { offset: usize },
1538}
1539
1540#[derive(Debug, Clone)]
1541pub(crate) struct ResolvedMessagePredicates {
1542    pub(crate) role: Option<Role>,
1543    pub(crate) kinds: Option<Vec<MessageKind>>,
1544    pub(crate) providers: Option<Vec<Provider>>,
1545    pub(crate) session_id: Option<String>,
1546    pub(crate) workspace_path_prefix: Option<String>,
1547    pub(crate) transcript_path_prefix: Option<String>,
1548    pub(crate) exclude_workspace_path_prefixes: Vec<String>,
1549    pub(crate) exclude_transcript_path_prefixes: Vec<String>,
1550    pub(crate) exclude_session_ids: Vec<String>,
1551    pub(crate) time: RequestedTimeRange,
1552    pub(crate) sequence: Option<SequenceRange>,
1553    pub(crate) tool_name_contains: Option<String>,
1554    pub(crate) include_compaction: bool,
1555}
1556
1557#[derive(Debug, Clone)]
1558pub(crate) struct MessageRetrievalPlan {
1559    pub(crate) query: MessageQuery,
1560    pub(crate) target: MessageTarget,
1561    pub(crate) predicates: ResolvedMessagePredicates,
1562    pub(crate) match_window: Option<MatchWindow>,
1563    pub(crate) ordering: ExecutionOrder,
1564    pub(crate) extent: ResolvedExtent,
1565}
1566
1567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1568pub struct ResolvedMessagePresentation {
1569    pub(crate) include_refs: bool,
1570    pub(crate) message_lines: LineWindow,
1571    pub(crate) match_evidence_max_chars: NonZeroUsize,
1572    pub(crate) detail: Option<DetailLevel>,
1573    pub(crate) field_view: FieldViewBudget,
1574    pub(crate) match_view: MatchViewBudget,
1575}
1576
1577impl ResolvedMessagePresentation {
1578    pub const fn include_refs(&self) -> bool {
1579        self.include_refs
1580    }
1581
1582    pub const fn message_lines(&self) -> LineWindow {
1583        self.message_lines
1584    }
1585
1586    pub const fn match_evidence_max_chars(&self) -> NonZeroUsize {
1587        self.match_evidence_max_chars
1588    }
1589
1590    pub const fn detail(&self) -> Option<DetailLevel> {
1591        self.detail
1592    }
1593
1594    pub const fn field_view(&self) -> FieldViewBudget {
1595        self.field_view
1596    }
1597
1598    pub const fn match_view(&self) -> MatchViewBudget {
1599        self.match_view
1600    }
1601}
1602
1603#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1604pub(crate) struct MessageResponsePlan {
1605    pub(crate) context: ContextWindow,
1606    pub(crate) presentation: ResolvedMessagePresentation,
1607    pub(crate) includes: Vec<MessageSearchInclude>,
1608}
1609
1610#[derive(Debug, Clone)]
1611pub struct MessageSearchPlan {
1612    pub(crate) retrieval: MessageRetrievalPlan,
1613    pub(crate) response: MessageResponsePlan,
1614    pub(crate) receipt: ReceiptLevel,
1615    pub(crate) origins: MessageSearchOrigins,
1616}
1617
1618impl MessageSearchPlan {
1619    pub const fn ordering(&self) -> ExecutionOrder {
1620        self.retrieval.ordering
1621    }
1622
1623    pub const fn extent(&self) -> ResolvedExtent {
1624        self.retrieval.extent
1625    }
1626
1627    pub const fn context(&self) -> ContextWindow {
1628        self.response.context
1629    }
1630
1631    pub const fn presentation(&self) -> ResolvedMessagePresentation {
1632        self.response.presentation
1633    }
1634
1635    pub fn includes(&self) -> &[MessageSearchInclude] {
1636        &self.response.includes
1637    }
1638
1639    pub const fn receipt_level(&self) -> ReceiptLevel {
1640        self.receipt
1641    }
1642
1643    pub fn origins(&self) -> &MessageSearchOrigins {
1644        &self.origins
1645    }
1646}
1647
1648#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1649#[serde(tag = "kind", rename_all = "snake_case")]
1650pub enum ProviderScope {
1651    All,
1652    Selected { providers: Vec<Provider> },
1653}
1654
1655#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1656#[serde(tag = "kind", rename_all = "snake_case")]
1657pub enum ResolvedRequestExtent {
1658    Page { limit: NonZeroUsize, offset: usize },
1659    AllResults { offset: usize },
1660}
1661
1662impl From<ResolvedExtent> for ResolvedRequestExtent {
1663    fn from(value: ResolvedExtent) -> Self {
1664        match value {
1665            ResolvedExtent::Page { limit, offset } => Self::Page { limit, offset },
1666            ResolvedExtent::AllResults { offset } => Self::AllResults { offset },
1667        }
1668    }
1669}
1670
1671/// Public query-mode vocabulary shared by CLI, Python, MCP, and response documents.
1672///
1673/// Public query-mode vocabulary shared by all search surfaces.
1674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1675#[serde(rename_all = "snake_case")]
1676pub enum ResolvedQueryMode {
1677    Literal,
1678    Regex,
1679    Fuzzy,
1680}
1681
1682impl From<MessageSearchMode> for ResolvedQueryMode {
1683    fn from(value: MessageSearchMode) -> Self {
1684        match value {
1685            MessageSearchMode::Literal => Self::Literal,
1686            MessageSearchMode::Regex => Self::Regex,
1687            MessageSearchMode::Fuzzy => Self::Fuzzy,
1688        }
1689    }
1690}
1691
1692#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1693pub struct ResolvedRequestPresentation {
1694    lines_per_message: i64,
1695    field_view: FieldViewBudget,
1696    match_view: MatchViewBudget,
1697}
1698
1699impl ResolvedRequestPresentation {
1700    pub const fn lines_per_message(&self) -> i64 {
1701        self.lines_per_message
1702    }
1703
1704    pub const fn field_view(&self) -> FieldViewBudget {
1705        self.field_view
1706    }
1707
1708    pub const fn match_view(&self) -> MatchViewBudget {
1709        self.match_view
1710    }
1711}
1712
1713/// Effective semantic choices returned with every response.
1714///
1715/// Parameter origins and planner evidence are optional receipt data; this compact record is always
1716/// present so a caller can interpret result and presentation extent without conversational state.
1717#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1718pub struct ResolvedMessageSearchRequest {
1719    #[serde(skip_serializing_if = "Option::is_none")]
1720    query: Option<String>,
1721    #[serde(skip_serializing_if = "Option::is_none")]
1722    query_mode: Option<ResolvedQueryMode>,
1723    target: MessageTarget,
1724    provider_scope: ProviderScope,
1725    extent: ResolvedRequestExtent,
1726    #[serde(skip_serializing_if = "Option::is_none")]
1727    match_window: Option<MatchWindow>,
1728    context: ContextWindow,
1729    presentation: ResolvedRequestPresentation,
1730    include: Vec<MessageSearchInclude>,
1731    receipt_level: ReceiptLevel,
1732}
1733
1734impl ResolvedMessageSearchRequest {
1735    pub(crate) fn from_plan(plan: &MessageSearchPlan) -> Result<Self, MessageSearchError> {
1736        Ok(Self {
1737            query: plan.retrieval.query.text().map(str::to_owned),
1738            query_mode: plan.retrieval.query.mode().map(ResolvedQueryMode::from),
1739            target: plan.retrieval.target.clone(),
1740            provider_scope: plan
1741                .retrieval
1742                .predicates
1743                .providers
1744                .clone()
1745                .map_or(ProviderScope::All, |providers| ProviderScope::Selected {
1746                    providers,
1747                }),
1748            extent: plan.retrieval.extent.into(),
1749            match_window: (!matches!(plan.retrieval.query, MessageQuery::Fuzzy(_)))
1750                .then_some(plan.retrieval.match_window.unwrap_or_default()),
1751            context: plan.response.context,
1752            presentation: ResolvedRequestPresentation {
1753                lines_per_message: plan.response.presentation.message_lines.to_signed()?,
1754                field_view: plan.response.presentation.field_view,
1755                match_view: plan.response.presentation.match_view,
1756            },
1757            include: plan.response.includes.clone(),
1758            receipt_level: plan.receipt,
1759        })
1760    }
1761
1762    pub fn query(&self) -> Option<&str> {
1763        self.query.as_deref()
1764    }
1765
1766    pub const fn query_mode(&self) -> Option<ResolvedQueryMode> {
1767        self.query_mode
1768    }
1769
1770    pub const fn target(&self) -> &MessageTarget {
1771        &self.target
1772    }
1773
1774    pub const fn provider_scope(&self) -> &ProviderScope {
1775        &self.provider_scope
1776    }
1777
1778    pub const fn extent(&self) -> ResolvedRequestExtent {
1779        self.extent
1780    }
1781
1782    pub const fn match_window(&self) -> Option<MatchWindow> {
1783        self.match_window
1784    }
1785
1786    pub const fn context(&self) -> ContextWindow {
1787        self.context
1788    }
1789
1790    pub const fn presentation(&self) -> &ResolvedRequestPresentation {
1791        &self.presentation
1792    }
1793
1794    pub fn include(&self) -> &[MessageSearchInclude] {
1795        &self.include
1796    }
1797
1798    pub const fn receipt_level(&self) -> ReceiptLevel {
1799        self.receipt_level
1800    }
1801}
1802
1803#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1804pub struct PageInfo {
1805    extent: ResolvedExtent,
1806    returned: usize,
1807    next_offset: Option<usize>,
1808    ordering: ExecutionOrder,
1809    earlier_results: PageSide,
1810    result_set_extent: ResultSetExtent,
1811}
1812
1813impl PageInfo {
1814    pub(crate) const fn new(
1815        extent: ResolvedExtent,
1816        returned: usize,
1817        next_offset: Option<usize>,
1818        ordering: ExecutionOrder,
1819    ) -> Self {
1820        let offset = match extent {
1821            ResolvedExtent::Page { offset, .. } | ResolvedExtent::AllResults { offset } => offset,
1822        };
1823        let earlier_results = if offset == 0 {
1824            PageSide::None
1825        } else if returned > 0 {
1826            // A nonempty page after an offset proves that matching rows were skipped.
1827            PageSide::Present
1828        } else {
1829            // A positive offset alone does not distinguish no matches from a beyond-end page.
1830            PageSide::Unknown
1831        };
1832        let result_set_extent = match (earlier_results, next_offset.is_some()) {
1833            (PageSide::Present, _) | (_, true) => ResultSetExtent::Partial,
1834            (PageSide::None, false) => ResultSetExtent::All,
1835            _ => ResultSetExtent::Unknown,
1836        };
1837        Self {
1838            extent,
1839            returned,
1840            next_offset,
1841            ordering,
1842            earlier_results,
1843            result_set_extent,
1844        }
1845    }
1846
1847    pub const fn extent(&self) -> ResolvedExtent {
1848        self.extent
1849    }
1850
1851    pub const fn next_offset(&self) -> Option<usize> {
1852        self.next_offset
1853    }
1854
1855    pub const fn ordering(&self) -> ExecutionOrder {
1856        self.ordering
1857    }
1858
1859    pub const fn returned(&self) -> usize {
1860        self.returned
1861    }
1862
1863    pub const fn earlier_results(&self) -> PageSide {
1864        self.earlier_results
1865    }
1866
1867    pub const fn result_set_extent(&self) -> ResultSetExtent {
1868        self.result_set_extent
1869    }
1870}
1871
1872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1873#[serde(rename_all = "snake_case")]
1874pub enum PageSide {
1875    None,
1876    Present,
1877    Unknown,
1878}
1879
1880impl PageSide {
1881    pub const fn as_str(self) -> &'static str {
1882        match self {
1883            Self::None => "none",
1884            Self::Present => "present",
1885            Self::Unknown => "unknown",
1886        }
1887    }
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1891#[serde(rename_all = "snake_case")]
1892pub enum ResultSetExtent {
1893    All,
1894    Partial,
1895    Unknown,
1896}
1897
1898impl ResultSetExtent {
1899    pub const fn as_str(self) -> &'static str {
1900        match self {
1901            Self::All => "all",
1902            Self::Partial => "partial",
1903            Self::Unknown => "unknown",
1904        }
1905    }
1906}
1907
1908#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1909pub struct ViewCharRange {
1910    pub view_start_char: usize,
1911    pub view_end_char_exclusive: usize,
1912}
1913
1914#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1915#[serde(tag = "kind", rename_all = "kebab-case")]
1916pub enum MessageMatchViewMarkers {
1917    Characters {
1918        ranges: Vec<ViewCharRange>,
1919        matched_chars_total: usize,
1920        matched_chars_shown: usize,
1921    },
1922    Boundary {
1923        view_at_char: usize,
1924    },
1925}
1926
1927#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1928pub struct MessageMatchEvidence {
1929    pub view_text: String,
1930    pub field_start_char: usize,
1931    pub field_total_chars: usize,
1932    pub markers: MessageMatchViewMarkers,
1933}
1934
1935/// Complete selected-field occurrence for literal mode.
1936///
1937/// The independently bounded evidence excerpt can omit part of a long literal. This record keeps
1938/// the exact matched field text and absolute character coordinates without expanding regex or
1939/// fuzzy evidence.
1940#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1941pub struct MessageLiteralMatch {
1942    pub text: String,
1943    pub field_start_char: usize,
1944    pub field_end_char_exclusive: usize,
1945}
1946
1947#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1948struct FieldCharRange {
1949    field_start_char: usize,
1950    field_end_char_exclusive: usize,
1951}
1952
1953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1954#[serde(rename_all = "snake_case")]
1955pub enum CoordinateUnit {
1956    UnicodeScalar,
1957}
1958
1959/// Which portion of the selected field is present in one returned view.
1960#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1961#[serde(rename_all = "snake_case")]
1962pub enum AdditionalFieldText {
1963    None,
1964    Before,
1965    After,
1966    BeforeAndAfter,
1967}
1968
1969impl AdditionalFieldText {
1970    pub const fn as_str(self) -> &'static str {
1971        match self {
1972            Self::None => "none",
1973            Self::Before => "before",
1974            Self::After => "after",
1975            Self::BeforeAndAfter => "before_and_after",
1976        }
1977    }
1978}
1979
1980#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1981pub struct FieldViewExtent {
1982    additional_field_text: AdditionalFieldText,
1983    /// Full selected-field Unicode scalar count, or `None` when computing it would require an
1984    /// additional full-field scan solely for metadata.
1985    ///
1986    /// Deliberately per view rather than hoisted with the unit beside it. The two views are
1987    /// filled by different code paths -- `evidence_field_view` always knows the count while
1988    /// `selected_field_view` computes an `Option` -- so one can be `null` while the other is
1989    /// populated for the same result. Collapsing them would either invent a value or drop one,
1990    /// which `REQ046-preserve-boundary-results` names as silently dropping optional data.
1991    field_total_chars: Option<usize>,
1992    /// Not serialized: `CoordinateUnit` has a single variant, so this was the same constant
1993    /// written three times per result. It is stated once at the response root instead.
1994    #[serde(skip)]
1995    coordinate_unit: CoordinateUnit,
1996}
1997
1998impl FieldViewExtent {
1999    pub const fn additional_field_text(&self) -> AdditionalFieldText {
2000        self.additional_field_text
2001    }
2002
2003    pub const fn field_total_chars(&self) -> Option<usize> {
2004        self.field_total_chars
2005    }
2006}
2007
2008/// Absolute Unicode-scalar range and remaining-text direction for returned message content.
2009///
2010/// The range length equals the returned `content` character count. `field_total_chars` stays
2011/// optional when obtaining it would require another full-field scan solely for metadata.
2012/// Completeness and both boundary conditions are derivable without parallel booleans:
2013/// `additional_field_text == none` means the range contains the complete field.
2014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2015pub struct MessageContentExtent {
2016    field_start_char: usize,
2017    field_end_char_exclusive: usize,
2018    #[serde(flatten)]
2019    extent: FieldViewExtent,
2020    /// Kept here, unlike the search response's views, because this shape is serialized into
2021    /// `get_session`, a different document with no shared root to hoist to. It also appears once
2022    /// per returned message rather than three times per result, so the repetition the search
2023    /// response had is not there to remove.
2024    coordinate_unit: CoordinateUnit,
2025}
2026
2027#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2028pub struct MessageFieldView {
2029    text: String,
2030    field_start_char: usize,
2031    field_end_char_exclusive: usize,
2032    #[serde(skip_serializing_if = "Vec::is_empty")]
2033    markers: Vec<ViewCharRange>,
2034    #[serde(flatten)]
2035    extent: FieldViewExtent,
2036}
2037
2038impl MessageFieldView {
2039    pub fn text(&self) -> &str {
2040        &self.text
2041    }
2042
2043    pub const fn field_start_char(&self) -> usize {
2044        self.field_start_char
2045    }
2046
2047    pub const fn field_end_char_exclusive(&self) -> usize {
2048        self.field_end_char_exclusive
2049    }
2050
2051    pub fn markers(&self) -> &[ViewCharRange] {
2052        &self.markers
2053    }
2054
2055    pub const fn extent(&self) -> FieldViewExtent {
2056        self.extent
2057    }
2058
2059    pub(crate) fn into_content_and_extent(self) -> (String, MessageContentExtent) {
2060        (
2061            self.text,
2062            MessageContentExtent {
2063                field_start_char: self.field_start_char,
2064                field_end_char_exclusive: self.field_end_char_exclusive,
2065                extent: self.extent,
2066                coordinate_unit: CoordinateUnit::UnicodeScalar,
2067            },
2068        )
2069    }
2070}
2071
2072#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2073pub struct MessageResultRef<'a> {
2074    session_id: &'a str,
2075    message_seq: i64,
2076}
2077
2078impl<'a> MessageResultRef<'a> {
2079    pub const fn session_id(self) -> &'a str {
2080        self.session_id
2081    }
2082
2083    pub const fn message_seq(self) -> i64 {
2084        self.message_seq
2085    }
2086}
2087
2088#[derive(Debug, Clone, Serialize)]
2089pub struct MessageSearchHit {
2090    #[serde(flatten)]
2091    pub message: crate::models::MessageHit,
2092    #[serde(skip_serializing_if = "Option::is_none")]
2093    pub match_evidence: Option<MessageMatchEvidence>,
2094    #[serde(skip_serializing_if = "Option::is_none")]
2095    pub literal_match: Option<MessageLiteralMatch>,
2096    #[serde(skip_serializing)]
2097    field_view: Option<MessageFieldView>,
2098    #[serde(skip_serializing)]
2099    match_view: Option<MessageFieldView>,
2100    #[serde(skip_serializing)]
2101    parsed_references: Option<Vec<MessageRef>>,
2102}
2103
2104impl MessageSearchHit {
2105    #[cfg(test)]
2106    pub(crate) fn from_parts(
2107        message: crate::models::MessageHit,
2108        match_evidence: Option<MessageMatchEvidence>,
2109        literal_match: Option<MessageLiteralMatch>,
2110    ) -> Self {
2111        let field_view = complete_field_view(&message.content);
2112        let match_view = match_evidence.as_ref().map(evidence_field_view);
2113        Self {
2114            message,
2115            match_evidence,
2116            literal_match,
2117            field_view: Some(field_view),
2118            match_view,
2119            parsed_references: None,
2120        }
2121    }
2122
2123    pub fn message(&self) -> &crate::models::MessageHit {
2124        &self.message
2125    }
2126
2127    pub fn match_evidence(&self) -> Option<&MessageMatchEvidence> {
2128        self.match_evidence.as_ref()
2129    }
2130
2131    pub fn literal_match(&self) -> Option<&MessageLiteralMatch> {
2132        self.literal_match.as_ref()
2133    }
2134
2135    pub fn message_ref(&self) -> MessageResultRef<'_> {
2136        MessageResultRef {
2137            session_id: &self.message.session_id,
2138            message_seq: self.message.seq,
2139        }
2140    }
2141
2142    pub const fn field_view(&self) -> &MessageFieldView {
2143        self.field_view
2144            .as_ref()
2145            .expect("message presentation must be applied before returning search results")
2146    }
2147
2148    pub fn match_view(&self) -> Option<&MessageFieldView> {
2149        self.match_view.as_ref()
2150    }
2151
2152    pub fn parsed_references(&self) -> Option<&[MessageRef]> {
2153        self.parsed_references.as_deref()
2154    }
2155
2156    pub(crate) fn set_parsed_references(&mut self, references: Vec<MessageRef>) {
2157        self.parsed_references = Some(references);
2158    }
2159}
2160
2161impl std::ops::Deref for MessageSearchHit {
2162    type Target = crate::models::MessageHit;
2163
2164    fn deref(&self) -> &Self::Target {
2165        &self.message
2166    }
2167}
2168
2169#[derive(Debug, Clone)]
2170pub struct MessageSearchResponse {
2171    request: ResolvedMessageSearchRequest,
2172    query: Option<String>,
2173    match_target: Option<MessageTarget>,
2174    match_mode: Option<MessageSearchMode>,
2175    hits: Vec<MessageSearchHit>,
2176    context_windows: Vec<Vec<crate::models::MessageHit>>,
2177    page: PageInfo,
2178    context: ContextWindow,
2179    presentation: ResolvedMessagePresentation,
2180    includes: Vec<MessageSearchInclude>,
2181    planner: Option<crate::models::SearchExplain>,
2182    origins: Option<MessageSearchOrigins>,
2183    included: MessageSearchIncludedData,
2184}
2185
2186#[derive(Debug, Clone, Serialize)]
2187pub struct MessageSearchRuntimeDiagnostics {
2188    package_version: &'static str,
2189    database_schema_version: i64,
2190    response_schema_version: u32,
2191    surface: SearchSurface,
2192    config_digest: String,
2193}
2194
2195impl MessageSearchRuntimeDiagnostics {
2196    pub(crate) fn new(surface: SearchSurface, config_digest: String) -> Self {
2197        Self {
2198            package_version: env!("CARGO_PKG_VERSION"),
2199            database_schema_version: crate::db::SCHEMA_VERSION,
2200            response_schema_version: MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
2201            surface,
2202            config_digest,
2203        }
2204    }
2205
2206    pub const fn package_version(&self) -> &'static str {
2207        self.package_version
2208    }
2209
2210    pub const fn database_schema_version(&self) -> i64 {
2211        self.database_schema_version
2212    }
2213
2214    pub const fn response_schema_version(&self) -> u32 {
2215        self.response_schema_version
2216    }
2217
2218    pub const fn surface(&self) -> SearchSurface {
2219        self.surface
2220    }
2221
2222    pub fn config_digest(&self) -> &str {
2223        &self.config_digest
2224    }
2225}
2226
2227#[derive(Debug, Clone, Default, Serialize)]
2228pub struct MessageSearchIncludedData {
2229    #[serde(skip_serializing_if = "Option::is_none")]
2230    normalized_session_metadata: Option<BTreeMap<String, SessionMeta>>,
2231    #[serde(skip_serializing_if = "Option::is_none")]
2232    raw_provider_metadata: Option<BTreeMap<String, Box<serde_json::value::RawValue>>>,
2233    #[serde(skip_serializing_if = "Option::is_none")]
2234    runtime_diagnostics: Option<MessageSearchRuntimeDiagnostics>,
2235}
2236
2237impl MessageSearchIncludedData {
2238    pub(crate) fn new(
2239        normalized_session_metadata: Option<BTreeMap<String, SessionMeta>>,
2240        raw_provider_metadata: Option<BTreeMap<String, Box<serde_json::value::RawValue>>>,
2241        runtime_diagnostics: Option<MessageSearchRuntimeDiagnostics>,
2242    ) -> Self {
2243        Self {
2244            normalized_session_metadata,
2245            raw_provider_metadata,
2246            runtime_diagnostics,
2247        }
2248    }
2249
2250    pub(crate) fn is_empty(&self) -> bool {
2251        self.normalized_session_metadata.is_none()
2252            && self.raw_provider_metadata.is_none()
2253            && self.runtime_diagnostics.is_none()
2254    }
2255
2256    pub fn normalized_session_metadata(&self) -> Option<&BTreeMap<String, SessionMeta>> {
2257        self.normalized_session_metadata.as_ref()
2258    }
2259
2260    pub fn raw_provider_metadata(
2261        &self,
2262    ) -> Option<&BTreeMap<String, Box<serde_json::value::RawValue>>> {
2263        self.raw_provider_metadata.as_ref()
2264    }
2265
2266    pub const fn runtime_diagnostics(&self) -> Option<&MessageSearchRuntimeDiagnostics> {
2267        self.runtime_diagnostics.as_ref()
2268    }
2269}
2270
2271pub(crate) struct MessageSearchResponseParts {
2272    pub request: ResolvedMessageSearchRequest,
2273    pub match_details: Option<(MessageTarget, MessageSearchMode)>,
2274    pub hits: Vec<MessageSearchHit>,
2275    pub context_windows: Vec<Vec<crate::models::MessageHit>>,
2276    pub page: PageInfo,
2277    pub response: MessageResponsePlan,
2278    pub planner: Option<crate::models::SearchExplain>,
2279    pub origins: Option<MessageSearchOrigins>,
2280    pub included: MessageSearchIncludedData,
2281}
2282
2283impl MessageSearchResponse {
2284    pub(crate) fn new(parts: MessageSearchResponseParts) -> Self {
2285        let MessageSearchResponseParts {
2286            request,
2287            match_details,
2288            hits,
2289            context_windows,
2290            page,
2291            response,
2292            planner,
2293            origins,
2294            included,
2295        } = parts;
2296        let (match_target, match_mode) = match match_details {
2297            Some((target, mode)) => (Some(target), Some(mode)),
2298            None => (None, None),
2299        };
2300        Self {
2301            request,
2302            query: None,
2303            match_target,
2304            match_mode,
2305            hits,
2306            context_windows,
2307            page,
2308            context: response.context,
2309            presentation: response.presentation,
2310            includes: response.includes,
2311            planner,
2312            origins,
2313            included,
2314        }
2315    }
2316
2317    pub(crate) fn with_query(mut self, query: Option<String>) -> Self {
2318        self.query = query;
2319        self
2320    }
2321
2322    pub fn query(&self) -> Option<&str> {
2323        self.query.as_deref()
2324    }
2325
2326    pub const fn request(&self) -> &ResolvedMessageSearchRequest {
2327        &self.request
2328    }
2329
2330    pub fn hits(&self) -> &[MessageSearchHit] {
2331        &self.hits
2332    }
2333
2334    /// Finalized semantic result records. `hits()` remains as the Rust compatibility spelling
2335    /// while adapters migrate; both access the same allocation and ordered identities.
2336    pub fn results(&self) -> &[MessageSearchHit] {
2337        &self.hits
2338    }
2339
2340    pub fn into_hits(self) -> Vec<MessageSearchHit> {
2341        self.hits
2342    }
2343
2344    /// Consume returned rows without cloning their potentially large content strings.
2345    pub fn into_rows(self) -> (Vec<MessageSearchHit>, Vec<Vec<crate::models::MessageHit>>) {
2346        (self.hits, self.context_windows)
2347    }
2348
2349    pub fn match_target(&self) -> Option<&MessageTarget> {
2350        self.match_target.as_ref()
2351    }
2352
2353    pub const fn match_mode(&self) -> Option<MessageSearchMode> {
2354        self.match_mode
2355    }
2356
2357    /// Context windows aligned by index with [`MessageSearchResponse::hits`]. Empty when the
2358    /// resolved context is zero on both sides.
2359    pub fn context_windows(&self) -> &[Vec<crate::models::MessageHit>] {
2360        &self.context_windows
2361    }
2362
2363    pub const fn page(&self) -> PageInfo {
2364        self.page
2365    }
2366
2367    pub const fn context(&self) -> ContextWindow {
2368        self.context
2369    }
2370
2371    pub const fn presentation(&self) -> ResolvedMessagePresentation {
2372        self.presentation
2373    }
2374
2375    pub fn includes(&self) -> &[MessageSearchInclude] {
2376        &self.includes
2377    }
2378
2379    pub fn search_explanation(&self) -> Option<&crate::models::SearchExplain> {
2380        self.planner.as_ref()
2381    }
2382
2383    pub fn parameter_origins(&self) -> Option<&MessageSearchOrigins> {
2384        self.origins.as_ref()
2385    }
2386
2387    pub const fn included(&self) -> &MessageSearchIncludedData {
2388        &self.included
2389    }
2390
2391    pub fn has_included_data(&self) -> bool {
2392        !self.included.is_empty()
2393    }
2394
2395    /// Borrow the finalized cross-surface version-1 semantic document without cloning hit text.
2396    pub const fn document(&self) -> MessageSearchDocument<'_> {
2397        MessageSearchDocument {
2398            response: self,
2399            cancellation: None,
2400        }
2401    }
2402
2403    /// Borrow the canonical document with cooperative cancellation between encoded results.
2404    ///
2405    /// Let `H` be returned results and `P` their encoded bytes. Successful serialization remains
2406    /// `O(H + P)` time with `O(P)` destination memory. Cancellation is observed in `O(1)` at every
2407    /// result boundary and during an optional ordered-digest pass; one result is the maximum
2408    /// non-interruptible unit because serializers receive each string value atomically.
2409    pub const fn document_cancellable<'a>(
2410        &'a self,
2411        cancellation: &'a AtomicBool,
2412    ) -> MessageSearchDocument<'a> {
2413        MessageSearchDocument {
2414            response: self,
2415            cancellation: Some(cancellation),
2416        }
2417    }
2418
2419    /// Borrow one canonical result for incremental encoders such as CLI JSON Lines.
2420    ///
2421    /// Serialization is `O(result bytes)` time and retains no second result tree.
2422    pub fn result_document(&self, index: usize) -> Option<MessageSearchResultDocument<'_>> {
2423        self.hits.get(index).map(|hit| MessageSearchResultDocument {
2424            hit,
2425            target: Some(self.request.target()),
2426            context: self.context_windows.get(index).map(Vec::as_slice),
2427            presentation: self.presentation,
2428        })
2429    }
2430
2431    /// Borrow canonical page metadata for incremental encoders.
2432    pub const fn page_document(&self) -> MessageSearchPageDocument {
2433        MessageSearchPageDocument(self.page)
2434    }
2435
2436    /// Borrow the requested receipt without cloning response data.
2437    ///
2438    /// The ordered digest is computed only for a full receipt, matching [`MessageSearchDocument`].
2439    pub fn receipt_document(&self) -> Option<MessageSearchReceiptDocument<'_>> {
2440        (self.request.receipt_level != ReceiptLevel::None).then(|| MessageSearchReceiptDocument {
2441            search_explanation: self.planner.as_ref(),
2442            parameter_origins: self.origins.as_ref(),
2443            ordered_digest: (self.request.receipt_level == ReceiptLevel::Full)
2444                .then(|| self.ordered_digest()),
2445        })
2446    }
2447
2448    /// Digest ordered semantic result identities, excluding presentation and output encoding.
2449    ///
2450    /// Let `H` be returned hits and `I` their identity/target bytes. Time is `O(H + I)` and
2451    /// retained memory is `O(1)` beyond the SHA-256 state and final string.
2452    pub fn ordered_digest(&self) -> String {
2453        self.ordered_digest_cancellable(None)
2454            .expect("uncancelled digest construction cannot fail")
2455    }
2456
2457    fn ordered_digest_cancellable(
2458        &self,
2459        cancellation: Option<&AtomicBool>,
2460    ) -> Result<String, &'static str> {
2461        let mut digest =
2462            MessageSearchOrderedDigest::new(self.request.target().clone(), self.match_mode);
2463        for hit in &self.hits {
2464            ensure_message_search_serialization_active(cancellation)?;
2465            digest.update(hit);
2466        }
2467        ensure_message_search_serialization_active(cancellation)?;
2468        Ok(digest.finish())
2469    }
2470}
2471
2472/// Incremental owner of the canonical ordered-result digest used by materialized and streamed
2473/// responses. Presentation, batch size, context, includes, and encoding never enter this digest.
2474pub(crate) struct MessageSearchOrderedDigest {
2475    digest: FramedSha256,
2476    target: MessageTarget,
2477    match_mode: Option<MessageSearchMode>,
2478}
2479
2480impl MessageSearchOrderedDigest {
2481    pub(crate) fn new(target: MessageTarget, match_mode: Option<MessageSearchMode>) -> Self {
2482        let digest = FramedSha256::new(b"aise-message-search-ordered-digest-v1");
2483        Self {
2484            digest,
2485            target,
2486            match_mode,
2487        }
2488    }
2489
2490    /// Add one already-enriched semantic result in `O(identity + target-path bytes)` time and
2491    /// `O(1)` retained state.
2492    pub(crate) fn update(&mut self, hit: &MessageSearchHit) {
2493        self.digest.update_bytes(hit.message.session_id.as_bytes());
2494        self.digest.update_i64(hit.message.seq);
2495        self.digest.update_u8(match self.target.field() {
2496            SearchField::Content => 0,
2497            SearchField::ToolName => 1,
2498            SearchField::ToolArgument => 2,
2499        });
2500        if let Some(path) = self.target.argument_path() {
2501            self.digest.update_u8(1);
2502            self.digest.update_bytes(path.as_str().as_bytes());
2503        } else {
2504            self.digest.update_u8(0);
2505        }
2506        self.digest.update_u8(match self.match_mode {
2507            Some(MessageSearchMode::Literal) => 0,
2508            Some(MessageSearchMode::Regex) => 1,
2509            Some(MessageSearchMode::Fuzzy) => 2,
2510            None => 255,
2511        });
2512        if let Some(score) = hit.message.fuzzy_score {
2513            self.digest.update_u8(1);
2514            self.digest.update_u32(score);
2515        } else {
2516            self.digest.update_u8(0);
2517        }
2518        if let Some(literal) = hit.literal_match.as_ref() {
2519            self.digest.update_u8(1);
2520            self.digest.update_u64(literal.field_start_char as u64);
2521            self.digest
2522                .update_u64(literal.field_end_char_exclusive as u64);
2523        } else {
2524            self.digest.update_u8(0);
2525        }
2526    }
2527
2528    pub(crate) fn finish(self) -> String {
2529        self.digest.finish()
2530    }
2531}
2532
2533impl Serialize for MessageSearchResponse {
2534    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2535    where
2536        S: Serializer,
2537    {
2538        self.document().serialize(serializer)
2539    }
2540}
2541
2542/// Canonical structured response projection. CLI JSON, JSONL framing, MCP structured content, and
2543/// schema fixtures must derive from this owner rather than rebuilding response facts.
2544#[derive(Debug, Clone, Copy)]
2545pub struct MessageSearchDocument<'a> {
2546    response: &'a MessageSearchResponse,
2547    cancellation: Option<&'a AtomicBool>,
2548}
2549
2550impl Serialize for MessageSearchDocument<'_> {
2551    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2552    where
2553        S: Serializer,
2554    {
2555        use serde::ser::Error as _;
2556
2557        ensure_message_search_serialization_active(self.cancellation).map_err(S::Error::custom)?;
2558        let receipt_level = self.response.request.receipt_level;
2559        let mut entry_count = 5;
2560        if receipt_level != ReceiptLevel::None {
2561            entry_count += 1;
2562        }
2563        if !self.response.included.is_empty() {
2564            entry_count += 1;
2565        }
2566        let mut map = serializer.serialize_map(Some(entry_count))?;
2567        map.serialize_entry(
2568            "response_schema_version",
2569            &MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
2570        )?;
2571        // Every character offset in this document is in this unit. It used to be repeated at
2572        // three sites per result, always the same constant, because `CoordinateUnit` has one
2573        // variant. `REQ006-report-extent-honestly` requires the response to state it; stating it
2574        // once satisfies that and costs 34 characters instead of 34 times three times the page.
2575        map.serialize_entry("coordinate_unit", &CoordinateUnit::UnicodeScalar)?;
2576        map.serialize_entry("effective_request", &self.response.request)?;
2577        map.serialize_entry(
2578            "results",
2579            &SemanticResults {
2580                hits: &self.response.hits,
2581                target: Some(self.response.request.target()),
2582                context_windows: &self.response.context_windows,
2583                presentation: self.response.presentation,
2584                cancellation: self.cancellation,
2585            },
2586        )?;
2587        ensure_message_search_serialization_active(self.cancellation).map_err(S::Error::custom)?;
2588        map.serialize_entry("page", &self.response.page_document())?;
2589        if !self.response.included.is_empty() {
2590            ensure_message_search_serialization_active(self.cancellation)
2591                .map_err(S::Error::custom)?;
2592            map.serialize_entry("included", &self.response.included)?;
2593        }
2594        if receipt_level != ReceiptLevel::None {
2595            ensure_message_search_serialization_active(self.cancellation)
2596                .map_err(S::Error::custom)?;
2597            let ordered_digest = if receipt_level == ReceiptLevel::Full {
2598                Some(
2599                    self.response
2600                        .ordered_digest_cancellable(self.cancellation)
2601                        .map_err(S::Error::custom)?,
2602                )
2603            } else {
2604                None
2605            };
2606            map.serialize_entry(
2607                "receipt",
2608                &MessageSearchReceiptDocument {
2609                    search_explanation: self.response.planner.as_ref(),
2610                    parameter_origins: self.response.origins.as_ref(),
2611                    ordered_digest,
2612                },
2613            )?;
2614        }
2615        ensure_message_search_serialization_active(self.cancellation).map_err(S::Error::custom)?;
2616        map.end()
2617    }
2618}
2619
2620/// Borrowed canonical result projection for incremental output encoders.
2621#[derive(Debug, Clone, Copy)]
2622pub struct MessageSearchResultDocument<'a> {
2623    hit: &'a MessageSearchHit,
2624    target: Option<&'a MessageTarget>,
2625    context: Option<&'a [crate::models::MessageHit]>,
2626    presentation: ResolvedMessagePresentation,
2627}
2628
2629impl<'a> MessageSearchResultDocument<'a> {
2630    pub(crate) fn from_batch_parts(
2631        hit: &'a MessageSearchHit,
2632        request: &'a ResolvedMessageSearchRequest,
2633        context: Option<&'a [crate::models::MessageHit]>,
2634    ) -> Self {
2635        let resolved = request.presentation();
2636        Self {
2637            hit,
2638            target: Some(request.target()),
2639            context,
2640            presentation: ResolvedMessagePresentation {
2641                include_refs: request
2642                    .include()
2643                    .contains(&MessageSearchInclude::ParsedReferences),
2644                message_lines: LineWindow::from_signed(resolved.lines_per_message())
2645                    .expect("resolved request contains a validated line window"),
2646                match_evidence_max_chars: NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
2647                    .expect("typed default is positive"),
2648                detail: None,
2649                field_view: resolved.field_view(),
2650                match_view: resolved.match_view(),
2651            },
2652        }
2653    }
2654}
2655
2656impl Serialize for MessageSearchResultDocument<'_> {
2657    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2658    where
2659        S: Serializer,
2660    {
2661        SemanticResult {
2662            hit: self.hit,
2663            target: self.target,
2664            context: self.context,
2665            presentation: self.presentation,
2666        }
2667        .serialize(serializer)
2668    }
2669}
2670
2671/// Borrowed canonical result-page projection for incremental output encoders.
2672#[derive(Debug, Clone, Copy)]
2673pub struct MessageSearchPageDocument(PageInfo);
2674
2675impl MessageSearchPageDocument {
2676    pub(crate) const fn from_page(page: PageInfo) -> Self {
2677        Self(page)
2678    }
2679}
2680
2681impl Serialize for MessageSearchPageDocument {
2682    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2683    where
2684        S: Serializer,
2685    {
2686        SemanticPageFields::from(self.0).serialize(serializer)
2687    }
2688}
2689
2690/// Borrowed canonical optional receipt for incremental output encoders.
2691#[derive(Debug)]
2692pub struct MessageSearchReceiptDocument<'a> {
2693    search_explanation: Option<&'a crate::models::SearchExplain>,
2694    parameter_origins: Option<&'a MessageSearchOrigins>,
2695    ordered_digest: Option<String>,
2696}
2697
2698impl<'a> MessageSearchReceiptDocument<'a> {
2699    pub(crate) fn from_parts(
2700        receipt_level: ReceiptLevel,
2701        search_explanation: Option<&'a crate::models::SearchExplain>,
2702        parameter_origins: Option<&'a MessageSearchOrigins>,
2703        ordered_digest: Option<&'a str>,
2704    ) -> Option<Self> {
2705        (receipt_level != ReceiptLevel::None).then_some(Self {
2706            search_explanation,
2707            parameter_origins,
2708            ordered_digest: (receipt_level == ReceiptLevel::Full)
2709                .then_some(ordered_digest)
2710                .flatten()
2711                .map(str::to_owned),
2712        })
2713    }
2714}
2715
2716impl Serialize for MessageSearchReceiptDocument<'_> {
2717    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2718    where
2719        S: Serializer,
2720    {
2721        SemanticReceipt {
2722            search_explanation: self.search_explanation,
2723            parameter_origins: self.parameter_origins,
2724            ordered_digest: self.ordered_digest.clone(),
2725        }
2726        .serialize(serializer)
2727    }
2728}
2729
2730struct SemanticResults<'a> {
2731    hits: &'a [MessageSearchHit],
2732    target: Option<&'a MessageTarget>,
2733    context_windows: &'a [Vec<crate::models::MessageHit>],
2734    presentation: ResolvedMessagePresentation,
2735    cancellation: Option<&'a AtomicBool>,
2736}
2737
2738impl Serialize for SemanticResults<'_> {
2739    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2740    where
2741        S: Serializer,
2742    {
2743        use serde::ser::Error as _;
2744
2745        let mut sequence = serializer.serialize_seq(Some(self.hits.len()))?;
2746        for (index, hit) in self.hits.iter().enumerate() {
2747            ensure_message_search_serialization_active(self.cancellation)
2748                .map_err(S::Error::custom)?;
2749            sequence.serialize_element(&SemanticResult {
2750                hit,
2751                target: self.target,
2752                context: self.context_windows.get(index).map(Vec::as_slice),
2753                presentation: self.presentation,
2754            })?;
2755        }
2756        sequence.end()
2757    }
2758}
2759
2760fn ensure_message_search_serialization_active(
2761    cancellation: Option<&AtomicBool>,
2762) -> Result<(), &'static str> {
2763    if cancellation.is_some_and(|flag| flag.load(AtomicOrdering::Acquire)) {
2764        Err("message-search response serialization was cancelled")
2765    } else {
2766        Ok(())
2767    }
2768}
2769
2770#[derive(Serialize)]
2771struct SemanticMessageRef<'a> {
2772    session_id: &'a str,
2773    message_seq: i64,
2774}
2775
2776#[derive(Serialize)]
2777struct SemanticMessageMetadata {
2778    provider: Provider,
2779    role: Role,
2780    kind: MessageKind,
2781}
2782
2783#[derive(Serialize)]
2784struct SemanticLiteralOccurrence<'a> {
2785    text: &'a str,
2786    field_start_char: usize,
2787    field_end_char_exclusive: usize,
2788}
2789
2790#[derive(Serialize)]
2791struct SemanticMatch<'a> {
2792    field: SearchField,
2793    #[serde(skip_serializing_if = "Option::is_none")]
2794    argument_path: Option<&'a str>,
2795    #[serde(skip_serializing_if = "Option::is_none")]
2796    fuzzy_score: Option<u32>,
2797    #[serde(skip_serializing_if = "Option::is_none")]
2798    literal_occurrence: Option<SemanticLiteralOccurrence<'a>>,
2799}
2800
2801#[derive(Serialize)]
2802struct SemanticPresentation<'a> {
2803    field_view: SemanticFieldView<'a>,
2804    #[serde(skip_serializing_if = "Option::is_none")]
2805    match_view: Option<SemanticFieldView<'a>>,
2806}
2807
2808#[derive(Serialize)]
2809struct SemanticFieldView<'a> {
2810    text: &'a str,
2811    field_start_char: usize,
2812    field_end_char_exclusive: usize,
2813    #[serde(skip_serializing_if = "SemanticMarkers::is_empty")]
2814    markers: SemanticMarkers<'a>,
2815    #[serde(flatten)]
2816    extent: FieldViewExtent,
2817}
2818
2819impl<'a> From<&'a MessageFieldView> for SemanticFieldView<'a> {
2820    fn from(view: &'a MessageFieldView) -> Self {
2821        Self {
2822            text: &view.text,
2823            field_start_char: view.field_start_char,
2824            field_end_char_exclusive: view.field_end_char_exclusive,
2825            markers: SemanticMarkers(&view.markers),
2826            extent: view.extent,
2827        }
2828    }
2829}
2830
2831struct SemanticMarkers<'a>(&'a [ViewCharRange]);
2832
2833impl SemanticMarkers<'_> {
2834    fn is_empty(&self) -> bool {
2835        self.0.is_empty()
2836    }
2837}
2838
2839impl Serialize for SemanticMarkers<'_> {
2840    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2841    where
2842        S: Serializer,
2843    {
2844        let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
2845        for marker in self.0 {
2846            sequence.serialize_element(&SemanticMarker {
2847                view_start_char: marker.view_start_char,
2848                view_end_char_exclusive: marker.view_end_char_exclusive,
2849            })?;
2850        }
2851        sequence.end()
2852    }
2853}
2854
2855#[derive(Serialize)]
2856struct SemanticMarker {
2857    view_start_char: usize,
2858    view_end_char_exclusive: usize,
2859}
2860
2861struct SemanticResult<'a> {
2862    hit: &'a MessageSearchHit,
2863    target: Option<&'a MessageTarget>,
2864    context: Option<&'a [crate::models::MessageHit]>,
2865    presentation: ResolvedMessagePresentation,
2866}
2867
2868impl Serialize for SemanticResult<'_> {
2869    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2870    where
2871        S: Serializer,
2872    {
2873        let hit = self.hit;
2874        let message_ref = SemanticMessageRef {
2875            session_id: &hit.message.session_id,
2876            message_seq: hit.message.seq,
2877        };
2878        let literal_occurrence =
2879            hit.literal_match
2880                .as_ref()
2881                .map(|literal| SemanticLiteralOccurrence {
2882                    text: &literal.text,
2883                    field_start_char: literal.field_start_char,
2884                    field_end_char_exclusive: literal.field_end_char_exclusive,
2885                });
2886        let field = self
2887            .target
2888            .map_or(SearchField::Content, MessageTarget::field);
2889        let has_match = hit.match_evidence.is_some() || hit.literal_match.is_some();
2890        let mut entry_count = if has_match { 4 } else { 3 };
2891        if hit.parsed_references.is_some() {
2892            entry_count += 1;
2893        }
2894        if self.context.is_some() {
2895            entry_count += 1;
2896        }
2897        let mut map = serializer.serialize_map(Some(entry_count))?;
2898        map.serialize_entry("message_ref", &message_ref)?;
2899        map.serialize_entry(
2900            "message_metadata",
2901            &SemanticMessageMetadata {
2902                provider: hit.message.provider,
2903                role: hit.message.role,
2904                kind: hit.message.kind,
2905            },
2906        )?;
2907        if has_match {
2908            map.serialize_entry(
2909                "match",
2910                &SemanticMatch {
2911                    field,
2912                    argument_path: self
2913                        .target
2914                        .and_then(MessageTarget::argument_path)
2915                        .map(JsonPointer::as_str),
2916                    fuzzy_score: hit.message.fuzzy_score,
2917                    literal_occurrence,
2918                },
2919            )?;
2920        }
2921        map.serialize_entry(
2922            "presentation",
2923            &SemanticPresentation {
2924                field_view: SemanticFieldView::from(hit.field_view()),
2925                match_view: hit.match_view.as_ref().map(SemanticFieldView::from),
2926            },
2927        )?;
2928        if let Some(parsed_references) = hit.parsed_references.as_ref() {
2929            map.serialize_entry("included", &SemanticResultIncluded { parsed_references })?;
2930        }
2931        if let Some(context) = self.context {
2932            map.serialize_entry(
2933                "context",
2934                &SemanticContext {
2935                    anchor_seq: hit.message.seq,
2936                    messages: context,
2937                    presentation: self.presentation,
2938                },
2939            )?;
2940        }
2941        map.end()
2942    }
2943}
2944
2945#[derive(Serialize)]
2946struct SemanticResultIncluded<'a> {
2947    parsed_references: &'a [MessageRef],
2948}
2949
2950struct SemanticContext<'a> {
2951    anchor_seq: i64,
2952    messages: &'a [crate::models::MessageHit],
2953    presentation: ResolvedMessagePresentation,
2954}
2955
2956impl Serialize for SemanticContext<'_> {
2957    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2958    where
2959        S: Serializer,
2960    {
2961        let messages_before = self
2962            .messages
2963            .iter()
2964            .filter(|message| message.seq < self.anchor_seq)
2965            .map(|message| SemanticContextMessage {
2966                message,
2967                presentation: self.presentation,
2968            })
2969            .collect::<Vec<_>>();
2970        let messages_after = self
2971            .messages
2972            .iter()
2973            .filter(|message| message.seq > self.anchor_seq)
2974            .map(|message| SemanticContextMessage {
2975                message,
2976                presentation: self.presentation,
2977            })
2978            .collect::<Vec<_>>();
2979        let mut map = serializer.serialize_map(Some(2))?;
2980        map.serialize_entry("messages_before", &messages_before)?;
2981        map.serialize_entry("messages_after", &messages_after)?;
2982        map.end()
2983    }
2984}
2985
2986struct SemanticContextMessage<'a> {
2987    message: &'a crate::models::MessageHit,
2988    presentation: ResolvedMessagePresentation,
2989}
2990
2991impl Serialize for SemanticContextMessage<'_> {
2992    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2993    where
2994        S: Serializer,
2995    {
2996        use serde::ser::Error as _;
2997
2998        let message = self.message;
2999        let field_view = selected_field_view(
3000            &message.content,
3001            self.presentation.message_lines,
3002            self.presentation.field_view,
3003            None,
3004        )
3005        .map_err(S::Error::custom)?;
3006        let mut map = serializer.serialize_map(Some(6))?;
3007        map.serialize_entry(
3008            "message_ref",
3009            &SemanticMessageRef {
3010                session_id: &message.session_id,
3011                message_seq: message.seq,
3012            },
3013        )?;
3014        map.serialize_entry(
3015            "message_metadata",
3016            &SemanticMessageMetadata {
3017                provider: message.provider,
3018                role: message.role,
3019                kind: message.kind,
3020            },
3021        )?;
3022        map.serialize_entry("timestamp", &message.ts)?;
3023        map.serialize_entry("tool_name", &message.tool_name)?;
3024        map.serialize_entry("tool_call_id", &message.tool_call_id)?;
3025        map.serialize_entry(
3026            "presentation",
3027            &SemanticPresentation {
3028                field_view: SemanticFieldView::from(&field_view),
3029                match_view: None,
3030            },
3031        )?;
3032        map.end()
3033    }
3034}
3035
3036#[derive(Serialize)]
3037struct SemanticPageFields {
3038    returned: usize,
3039    limit: Option<usize>,
3040    offset: usize,
3041    has_more: bool,
3042    next_offset: Option<usize>,
3043    earlier_results: PageSide,
3044    result_set_extent: ResultSetExtent,
3045    ordering: ExecutionOrder,
3046    consistency: PageConsistency,
3047}
3048
3049#[derive(Serialize)]
3050#[serde(rename_all = "kebab-case")]
3051enum PageConsistency {
3052    PerCall,
3053}
3054
3055impl From<PageInfo> for SemanticPageFields {
3056    fn from(page: PageInfo) -> Self {
3057        let (limit, offset) = match page.extent {
3058            ResolvedExtent::Page { limit, offset } => (Some(limit.get()), offset),
3059            ResolvedExtent::AllResults { offset } => (None, offset),
3060        };
3061        Self {
3062            returned: page.returned,
3063            limit,
3064            offset,
3065            has_more: page.next_offset.is_some(),
3066            next_offset: page.next_offset,
3067            earlier_results: page.earlier_results,
3068            result_set_extent: page.result_set_extent,
3069            ordering: page.ordering,
3070            consistency: PageConsistency::PerCall,
3071        }
3072    }
3073}
3074
3075#[derive(Serialize)]
3076struct SemanticReceipt<'a> {
3077    #[serde(skip_serializing_if = "Option::is_none")]
3078    search_explanation: Option<&'a crate::models::SearchExplain>,
3079    #[serde(skip_serializing_if = "Option::is_none")]
3080    parameter_origins: Option<&'a MessageSearchOrigins>,
3081    #[serde(skip_serializing_if = "Option::is_none")]
3082    ordered_digest: Option<String>,
3083}
3084
3085#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
3086pub struct MessageSearchRequest {
3087    query: MessageQuery,
3088    target: MessageTarget,
3089    predicates: MessagePredicates,
3090    match_window: Option<MatchWindow>,
3091    context: Option<ContextWindow>,
3092    presentation: MessagePresentation,
3093    extent: RequestedExtent,
3094    purpose: Option<PurposeSelection>,
3095    receipt: Option<ReceiptLevel>,
3096    includes: Option<Vec<MessageSearchInclude>>,
3097}
3098
3099impl MessageSearchRequest {
3100    pub fn builder(query: MessageQuery, target: MessageTarget) -> MessageSearchRequestBuilder {
3101        MessageSearchRequestBuilder {
3102            request: Self {
3103                query,
3104                target,
3105                predicates: MessagePredicates::default(),
3106                match_window: None,
3107                context: None,
3108                presentation: MessagePresentation::default(),
3109                extent: RequestedExtent::default(),
3110                purpose: None,
3111                receipt: None,
3112                includes: None,
3113            },
3114        }
3115    }
3116
3117    pub fn query(&self) -> &MessageQuery {
3118        &self.query
3119    }
3120
3121    pub fn target(&self) -> &MessageTarget {
3122        &self.target
3123    }
3124
3125    pub fn predicates(&self) -> &MessagePredicates {
3126        &self.predicates
3127    }
3128
3129    pub const fn match_window(&self) -> Option<MatchWindow> {
3130        self.match_window
3131    }
3132
3133    pub const fn context(&self) -> Option<ContextWindow> {
3134        self.context
3135    }
3136
3137    pub const fn presentation(&self) -> MessagePresentation {
3138        self.presentation
3139    }
3140
3141    pub const fn extent(&self) -> RequestedExtent {
3142        self.extent
3143    }
3144
3145    pub fn purpose(&self) -> Option<&PurposeSelection> {
3146        self.purpose.as_ref()
3147    }
3148
3149    pub const fn receipt_level(&self) -> Option<ReceiptLevel> {
3150        self.receipt
3151    }
3152
3153    pub fn includes(&self) -> Option<&[MessageSearchInclude]> {
3154        self.includes.as_deref()
3155    }
3156
3157    fn validate(&self) -> Result<(), MessageSearchError> {
3158        if self.presentation.detail.is_some()
3159            && (self.presentation.message_lines.is_some()
3160                || self.presentation.field_view.is_some()
3161                || self.presentation.match_view.is_some())
3162        {
3163            return Err(MessageSearchRule::DetailOwnsPresentationBudgets.error());
3164        }
3165        if self.predicates.sequence.is_some() && self.predicates.session.is_none() {
3166            return Err(MessageSearchRule::SequenceRequiresSession.error());
3167        }
3168        // Validate the RESOLVED set, not each parameter alone: `kinds` and include_compaction
3169        // both narrow the same set, and every conflict found in this area passed
3170        // per-parameter checks while producing a request that could not match anything.
3171        // An unsatisfiable request must error, never return silently empty.
3172        let mut effective = self
3173            .predicates
3174            .kinds
3175            .clone()
3176            .unwrap_or_else(MessageKind::default_search_set);
3177        if !self.predicates.include_compaction {
3178            effective.retain(|kind| *kind != MessageKind::Compaction);
3179        }
3180        if effective.is_empty() {
3181            return Err(MessageSearchRule::KindsMustRemainSatisfiable.error());
3182        }
3183        if self.predicates.role == Some(Role::Compaction)
3184            && !effective.contains(&MessageKind::Compaction)
3185        {
3186            return Err(MessageSearchRule::CompactionRoleRequiresCompactionKind.error());
3187        }
3188        if self.target.field == SearchField::ToolArgument
3189            && self
3190                .predicates
3191                .kinds
3192                .as_ref()
3193                .is_some_and(|kinds| !kinds.contains(&MessageKind::ToolCall))
3194        {
3195            return Err(MessageSearchRule::ToolArgumentRequiresToolCallKind.error());
3196        }
3197        if matches!(self.query, MessageQuery::All) && self.presentation.match_view.is_some() {
3198            return Err(MessageSearchRule::MatchViewRequiresQuery.error());
3199        }
3200        if self.match_window.is_some() && matches!(self.query, MessageQuery::Fuzzy(_)) {
3201            return Err(MessageSearchRule::FuzzyRejectsMatchWindow.error());
3202        }
3203        if self.match_window == Some(MatchWindow::Latest) && self.predicates.session.is_none() {
3204            return Err(MessageSearchRule::LatestWindowRequiresSession.error());
3205        }
3206        if let MessageQuery::Fuzzy(_) = self.query {
3207            match self.extent {
3208                RequestedExtent::Page { .. } => {}
3209                RequestedExtent::AllResults { .. } => {
3210                    return Err(MessageSearchRule::FuzzyRejectsAllResults.error())
3211                }
3212            }
3213        }
3214        Ok(())
3215    }
3216}
3217
3218pub struct MessageSearchRequestBuilder {
3219    request: MessageSearchRequest,
3220}
3221
3222impl MessageSearchRequestBuilder {
3223    pub fn role(mut self, role: Role) -> Self {
3224        self.request.predicates.role = Some(role);
3225        self
3226    }
3227
3228    /// Select exactly one class. Convenience over [`Self::kinds`] for the common case.
3229    pub fn kind(mut self, kind: MessageKind) -> Self {
3230        self.request.predicates.kinds = Some(vec![kind]);
3231        self
3232    }
3233
3234    /// Select the classes to return, replacing the default set (everything except
3235    /// `HarnessNotice`). This is the single mechanism for class selection.
3236    pub fn kinds(mut self, kinds: Vec<MessageKind>) -> Self {
3237        self.request.predicates.kinds = Some(kinds);
3238        self
3239    }
3240
3241    pub fn provider(mut self, provider: Provider) -> Self {
3242        self.request.predicates.providers = Some(vec![provider]);
3243        self
3244    }
3245
3246    /// Select one or more session sources.
3247    ///
3248    /// This is set semantics: duplicates are removed and the stored order is canonical, so
3249    /// equivalent requests produce byte-identical effective-request metadata. An empty set is an
3250    /// invalid scope rather than a successful search that misleadingly reports no matches.
3251    pub fn providers(mut self, mut providers: Vec<Provider>) -> Result<Self, MessageSearchError> {
3252        if providers.is_empty() {
3253            return Err(MessageSearchError::InvalidParameter {
3254                parameter: "providers",
3255                reason: "must contain at least one provider".into(),
3256            });
3257        }
3258        providers.sort_unstable_by_key(|provider| provider.as_str());
3259        providers.dedup();
3260        self.request.predicates.providers = Some(providers);
3261        Ok(self)
3262    }
3263
3264    pub fn session_id(mut self, session: impl Into<String>) -> Result<Self, MessageSearchError> {
3265        self.request.predicates.session = Some(NonEmptyValue::new("session_id", session)?);
3266        Ok(self)
3267    }
3268
3269    pub fn workspace_path_prefix(
3270        mut self,
3271        path: impl Into<String>,
3272    ) -> Result<Self, MessageSearchError> {
3273        self.request.predicates.workspace_path_prefix =
3274            Some(NonEmptyValue::new("workspace_path_prefix", path)?);
3275        Ok(self)
3276    }
3277
3278    pub fn transcript_path_prefix(
3279        mut self,
3280        path: impl Into<String>,
3281    ) -> Result<Self, MessageSearchError> {
3282        self.request.predicates.transcript_path_prefix =
3283            Some(NonEmptyValue::new("transcript_path_prefix", path)?);
3284        Ok(self)
3285    }
3286
3287    pub fn exclude_workspace_path_prefix(
3288        mut self,
3289        path: impl Into<String>,
3290    ) -> Result<Self, MessageSearchError> {
3291        self.request
3292            .predicates
3293            .exclude_workspace_path_prefixes
3294            .push(NonEmptyValue::new("exclude_workspace_path_prefix", path)?);
3295        Ok(self)
3296    }
3297
3298    pub fn exclude_transcript_path_prefix(
3299        mut self,
3300        path: impl Into<String>,
3301    ) -> Result<Self, MessageSearchError> {
3302        self.request
3303            .predicates
3304            .exclude_transcript_path_prefixes
3305            .push(NonEmptyValue::new("exclude_transcript_path_prefix", path)?);
3306        Ok(self)
3307    }
3308
3309    pub fn exclude_session_id(
3310        mut self,
3311        session: impl Into<String>,
3312    ) -> Result<Self, MessageSearchError> {
3313        self.request
3314            .predicates
3315            .exclude_session_ids
3316            .push(NonEmptyValue::new("exclude_session_id", session)?);
3317        Ok(self)
3318    }
3319
3320    pub fn time(mut self, time: RequestedTimeRange) -> Self {
3321        self.request.predicates.time = time;
3322        self
3323    }
3324
3325    pub fn sequence(mut self, sequence: SequenceRange) -> Self {
3326        self.request.predicates.sequence = Some(sequence);
3327        self
3328    }
3329
3330    pub fn tool_name_contains(
3331        mut self,
3332        value: impl Into<String>,
3333    ) -> Result<Self, MessageSearchError> {
3334        self.request.predicates.tool_name_contains =
3335            Some(NonEmptyValue::new("tool_name_contains", value)?);
3336        Ok(self)
3337    }
3338
3339    pub fn include_compaction(mut self, include: bool) -> Self {
3340        self.request.predicates.include_compaction = include;
3341        self
3342    }
3343
3344    pub fn match_window(mut self, window: MatchWindow) -> Self {
3345        self.request.match_window = Some(window);
3346        self
3347    }
3348
3349    pub fn context(mut self, context: ContextWindow) -> Self {
3350        self.request.context = Some(context);
3351        self
3352    }
3353
3354    pub fn message_lines(mut self, window: LineWindow) -> Self {
3355        self.request.presentation.message_lines = Some(window);
3356        self
3357    }
3358
3359    pub fn detail(mut self, detail: DetailLevel) -> Self {
3360        self.request.presentation.detail = Some(detail);
3361        self
3362    }
3363
3364    pub fn field_view(mut self, budget: FieldViewBudget) -> Self {
3365        self.request.presentation.field_view = Some(budget);
3366        self
3367    }
3368
3369    pub fn match_view(mut self, budget: MatchViewBudget) -> Self {
3370        self.request.presentation.match_view = Some(budget);
3371        self
3372    }
3373
3374    pub fn extent(mut self, extent: RequestedExtent) -> Self {
3375        self.request.extent = extent;
3376        self
3377    }
3378
3379    pub fn purpose(mut self, purpose: PurposeSelection) -> Self {
3380        self.request.purpose = Some(purpose);
3381        self
3382    }
3383
3384    pub fn receipt_level(mut self, level: ReceiptLevel) -> Self {
3385        self.request.receipt = Some(level);
3386        self
3387    }
3388
3389    /// Replace optional payload groups. An empty collection explicitly requests the semantic core.
3390    pub fn includes(mut self, includes: impl IntoIterator<Item = MessageSearchInclude>) -> Self {
3391        let mut includes = includes.into_iter().collect::<Vec<_>>();
3392        includes.sort_unstable();
3393        includes.dedup();
3394        self.request.includes = Some(includes);
3395        self
3396    }
3397
3398    pub fn build(self) -> Result<MessageSearchRequest, MessageSearchError> {
3399        self.request.validate()?;
3400        Ok(self.request)
3401    }
3402}
3403
3404pub(crate) fn selected_message_field<'a>(
3405    hit: &'a crate::models::MessageHit,
3406    target: &MessageTarget,
3407) -> Option<Cow<'a, str>> {
3408    selected_message_field_parts(
3409        hit,
3410        target.field(),
3411        target.argument_path().map(JsonPointer::as_str),
3412    )
3413}
3414
3415pub(crate) fn selected_message_field_parts<'a>(
3416    hit: &'a crate::models::MessageHit,
3417    field: SearchField,
3418    argument_path: Option<&str>,
3419) -> Option<Cow<'a, str>> {
3420    match field {
3421        SearchField::Content => Some(Cow::Borrowed(&hit.content)),
3422        SearchField::ToolName => hit.tool_name.as_deref().map(Cow::Borrowed),
3423        SearchField::ToolArgument => {
3424            let envelope: serde_json::Value = serde_json::from_str(&hit.content).ok()?;
3425            let args = envelope.get("args")?;
3426            let value = match argument_path.unwrap_or("") {
3427                "" => args,
3428                pointer => args.pointer(pointer)?,
3429            };
3430            Some(Cow::Owned(match value {
3431                serde_json::Value::String(value) => value.clone(),
3432                other => serde_json::to_string(other).ok()?,
3433            }))
3434        }
3435    }
3436}
3437
3438#[cfg(test)]
3439pub(crate) fn attach_match_evidence(
3440    query: &MessageQuery,
3441    target: &MessageTarget,
3442    match_view: MatchViewBudget,
3443    hits: Vec<crate::models::MessageHit>,
3444) -> anyhow::Result<Vec<MessageSearchHit>> {
3445    attach_match_evidence_cancellable(query, target, match_view, hits, || Ok(()))
3446}
3447
3448pub(crate) fn attach_match_evidence_cancellable(
3449    query: &MessageQuery,
3450    target: &MessageTarget,
3451    match_view: MatchViewBudget,
3452    hits: Vec<crate::models::MessageHit>,
3453    mut check_active: impl FnMut() -> anyhow::Result<()>,
3454) -> anyhow::Result<Vec<MessageSearchHit>> {
3455    let mut prepared = PreparedMatchEvidence::new(query)?;
3456    let mut enriched = Vec::with_capacity(hits.len());
3457    for message in hits {
3458        check_active()?;
3459        let (match_evidence, literal_match) = match query {
3460            MessageQuery::All => {
3461                // Queryless search selects rows by field presence and deliberately does not
3462                // parse or reconstruct a selected value merely to prove match evidence.
3463                (None, None)
3464            }
3465            _ => {
3466                let selected = selected_message_field(&message, target).ok_or_else(|| {
3467                    anyhow::anyhow!(
3468                        "message-search match evidence cannot project {:?} for {} sequence {}",
3469                        target.field(),
3470                        message.session_id,
3471                        message.seq
3472                    )
3473                })?;
3474                let evidence = prepared.build(&selected, match_view).ok_or_else(|| {
3475                    anyhow::anyhow!(
3476                        "message-search match evidence disagrees with {:?} membership for {} sequence {}",
3477                        target.field(),
3478                        message.session_id,
3479                        message.seq
3480                    )
3481                })?;
3482                check_active()?;
3483                let literal_match = match query {
3484                    MessageQuery::Literal(_) => {
3485                        let range = match &evidence.markers {
3486                            MessageMatchViewMarkers::Characters {
3487                                ranges,
3488                                matched_chars_total,
3489                                ..
3490                            } => ranges.first().map(|range| {
3491                                let field_start_char =
3492                                    evidence.field_start_char + range.view_start_char;
3493                                FieldCharRange {
3494                                    field_start_char,
3495                                    field_end_char_exclusive: field_start_char
3496                                        + matched_chars_total,
3497                                }
3498                            }),
3499                            MessageMatchViewMarkers::Boundary { .. } => None,
3500                        }
3501                        .ok_or_else(|| {
3502                            anyhow::anyhow!(
3503                                "literal match evidence has no character range for {} sequence {}",
3504                                message.session_id,
3505                                message.seq
3506                            )
3507                        })?;
3508                        Some(MessageLiteralMatch {
3509                            text: selected
3510                                .chars()
3511                                .skip(range.field_start_char)
3512                                .take(range.field_end_char_exclusive - range.field_start_char)
3513                                .collect(),
3514                            field_start_char: range.field_start_char,
3515                            field_end_char_exclusive: range.field_end_char_exclusive,
3516                        })
3517                    }
3518                    _ => None,
3519                };
3520                (Some(evidence), literal_match)
3521            }
3522        };
3523        enriched.push(MessageSearchHit {
3524            message,
3525            match_evidence,
3526            literal_match,
3527            field_view: None,
3528            match_view: None,
3529            parsed_references: None,
3530        });
3531    }
3532    Ok(enriched)
3533}
3534
3535#[cfg(test)]
3536fn complete_field_view(field_text: &str) -> MessageFieldView {
3537    let returned_chars = field_text.chars().count();
3538    MessageFieldView {
3539        text: field_text.to_owned(),
3540        field_start_char: 0,
3541        field_end_char_exclusive: returned_chars,
3542        markers: Vec::new(),
3543        extent: FieldViewExtent {
3544            additional_field_text: AdditionalFieldText::None,
3545            field_total_chars: Some(returned_chars),
3546            coordinate_unit: CoordinateUnit::UnicodeScalar,
3547        },
3548    }
3549}
3550
3551fn evidence_field_view(evidence: &MessageMatchEvidence) -> MessageFieldView {
3552    let markers = match &evidence.markers {
3553        MessageMatchViewMarkers::Characters { ranges, .. } => ranges.clone(),
3554        MessageMatchViewMarkers::Boundary { view_at_char } => vec![ViewCharRange {
3555            view_start_char: *view_at_char,
3556            view_end_char_exclusive: *view_at_char,
3557        }],
3558    };
3559    let returned_chars = evidence.view_text.chars().count();
3560    let has_text_before = evidence.field_start_char > 0;
3561    let has_text_after =
3562        evidence.field_start_char.saturating_add(returned_chars) < evidence.field_total_chars;
3563    MessageFieldView {
3564        text: evidence.view_text.clone(),
3565        field_start_char: evidence.field_start_char,
3566        field_end_char_exclusive: evidence.field_start_char.saturating_add(returned_chars),
3567        markers,
3568        extent: FieldViewExtent {
3569            additional_field_text: additional_field_text(has_text_before, has_text_after),
3570            field_total_chars: Some(evidence.field_total_chars),
3571            coordinate_unit: CoordinateUnit::UnicodeScalar,
3572        },
3573    }
3574}
3575
3576/// Project a fully classified message into bounded field and match views without changing the
3577/// text used for classification.
3578///
3579/// Time is `O(D + V)`, where `D` is authoritative message characters and `V` is returned view
3580/// characters. Retained memory is `O(V)`. Exact match coordinates come from the classifier, so a
3581/// repeated substring cannot move the presentation marker to a different occurrence.
3582pub(crate) fn classification_presentation(
3583    content: &str,
3584    match_start_char: usize,
3585    match_end_char_exclusive: usize,
3586    field_budget: FieldViewBudget,
3587    match_budget: MatchViewBudget,
3588) -> anyhow::Result<(MessageFieldView, MessageFieldView)> {
3589    let field_total_chars = content.chars().count();
3590    if match_start_char >= match_end_char_exclusive || match_end_char_exclusive > field_total_chars
3591    {
3592        anyhow::bail!(
3593            "classification match range {match_start_char}..{match_end_char_exclusive} is outside \
3594             a message containing {field_total_chars} Unicode scalar characters"
3595        );
3596    }
3597    let range = FieldCharRange {
3598        field_start_char: match_start_char,
3599        field_end_char_exclusive: match_end_char_exclusive,
3600    };
3601    let maximum = match_view_max_chars(
3602        match_budget,
3603        field_total_chars,
3604        std::slice::from_ref(&range),
3605    );
3606    let evidence = character_evidence(content, field_total_chars, maximum, vec![range], false);
3607    Ok((
3608        selected_field_view(
3609            content,
3610            LineWindow::Full,
3611            field_budget,
3612            Some(field_total_chars),
3613        )?,
3614        evidence_field_view(&evidence),
3615    ))
3616}
3617
3618/// Serialize one bounded classification result using the same recovery-oriented shape for CLI
3619/// JSON and MCP. The full classified content remains in the typed core report; bounded adapters
3620/// return its stable message reference and extent instead of duplicating large text.
3621pub(crate) fn classification_presentation_document(
3622    matched: &crate::models::MessageClassificationMatch,
3623    field_budget: FieldViewBudget,
3624    match_budget: MatchViewBudget,
3625) -> anyhow::Result<serde_json::Value> {
3626    let (field_view, match_view) = classification_presentation(
3627        &matched.content,
3628        matched.match_start_char,
3629        matched.match_end_char_exclusive,
3630        field_budget,
3631        match_budget,
3632    )?;
3633    Ok(serde_json::json!({
3634        "message_ref": {
3635            "session_id": matched.session_id,
3636            "message_seq": matched.message_seq
3637        },
3638        "message_metadata": {
3639            "provider": matched.provider,
3640            "timestamp": matched.ts
3641        },
3642        "classification": {
3643            "policy_name": matched.policy_name,
3644            "category": matched.category,
3645            "matched_text": matched.matched_text,
3646            "field_start_char": matched.match_start_char,
3647            "field_end_char_exclusive": matched.match_end_char_exclusive,
3648            "coordinate_unit": "unicode_scalar"
3649        },
3650        "presentation": {
3651            "field_view": field_view,
3652            "match_view": match_view
3653        }
3654    }))
3655}
3656
3657/// Apply presentation after retrieval and match proof have been finalized.
3658///
3659/// Let `V` be returned view characters and `D` the selected-field characters inspected to locate
3660/// a tail boundary. Head/full presentation is `O(V)` when the original size is not needed; tail
3661/// selection is `O(D)`. This function never changes hit membership, order, score, or page identity.
3662pub(crate) fn apply_message_presentation_cancellable(
3663    target: &MessageTarget,
3664    presentation: ResolvedMessagePresentation,
3665    hits: &mut [MessageSearchHit],
3666    mut check_active: impl FnMut() -> anyhow::Result<()>,
3667) -> anyhow::Result<()> {
3668    for hit in hits {
3669        check_active()?;
3670        let selected = match selected_message_field(&hit.message, target) {
3671            Some(selected) => selected,
3672            None if hit.match_evidence.is_none() => Cow::Borrowed(hit.message.content.as_str()),
3673            None => {
3674                return Err(anyhow::anyhow!(
3675                    "message-search presentation cannot project {:?} for {} sequence {}",
3676                    target.field(),
3677                    hit.message.session_id,
3678                    hit.message.seq
3679                ))
3680            }
3681        };
3682        hit.field_view = Some(selected_field_view(
3683            &selected,
3684            presentation.message_lines,
3685            presentation.field_view,
3686            hit.match_evidence
3687                .as_ref()
3688                .map(|evidence| evidence.field_total_chars),
3689        )?);
3690        hit.match_view = hit.match_evidence.as_ref().map(evidence_field_view);
3691        check_active()?;
3692    }
3693    Ok(())
3694}
3695
3696pub(crate) fn selected_field_view(
3697    original: &str,
3698    lines: LineWindow,
3699    budget: FieldViewBudget,
3700    known_original_chars: Option<usize>,
3701) -> Result<MessageFieldView, MessageSearchError> {
3702    let signed_lines = lines.to_signed()?;
3703    let line_selected = if signed_lines == 0 {
3704        Cow::Borrowed(original)
3705    } else {
3706        Cow::Owned(crate::util::select_message_lines(original, signed_lines))
3707    };
3708    let line_start = if matches!(lines, LineWindow::Tail(_)) {
3709        let selected_chars = line_selected.chars().count();
3710        known_original_chars
3711            .map(|total| total.saturating_sub(selected_chars))
3712            .or_else(|| {
3713                original
3714                    .rfind(line_selected.as_ref())
3715                    .map(|byte| original[..byte].chars().count())
3716            })
3717            .unwrap_or_else(|| original.chars().count().saturating_sub(selected_chars))
3718    } else {
3719        0
3720    };
3721    let line_has_text_before = line_start > 0;
3722    let line_end = line_start.saturating_add(line_selected.chars().count());
3723    let tail_has_text_after =
3724        matches!(lines, LineWindow::Tail(_)) && !original.ends_with(line_selected.as_ref());
3725    let original_chars_if_counted = match (known_original_chars, lines, budget) {
3726        (Some(total), _, _) => Some(total),
3727        (None, LineWindow::Full, FieldViewBudget::NoCharLimit) => Some(original.chars().count()),
3728        (None, LineWindow::Tail(_), _) => None,
3729        (None, LineWindow::Head(_), _) if line_selected == original => Some(line_end),
3730        _ => None,
3731    };
3732    let line_has_text_after = original_chars_if_counted.is_some_and(|total| line_end < total)
3733        || (matches!(lines, LineWindow::Head(_)) && line_selected != original)
3734        || tail_has_text_after;
3735
3736    let (text, field_start_char, budget_has_text_before, budget_has_text_after) = match budget {
3737        FieldViewBudget::NoCharLimit => (line_selected.into_owned(), line_start, false, false),
3738        FieldViewBudget::MaxChars { max_chars } => {
3739            let maximum = max_chars.get();
3740            match lines {
3741                LineWindow::Tail(_) => {
3742                    let selected_chars = line_selected.chars().count();
3743                    if selected_chars <= maximum {
3744                        (line_selected.into_owned(), line_start, false, false)
3745                    } else {
3746                        let skipped = selected_chars - maximum;
3747                        let start_byte = line_selected
3748                            .char_indices()
3749                            .nth(skipped)
3750                            .map_or(line_selected.len(), |(byte, _)| byte);
3751                        (
3752                            line_selected[start_byte..].to_owned(),
3753                            line_start + skipped,
3754                            true,
3755                            false,
3756                        )
3757                    }
3758                }
3759                LineWindow::Full | LineWindow::Head(_) => {
3760                    let mut characters = line_selected.chars();
3761                    let text = characters.by_ref().take(maximum).collect::<String>();
3762                    let truncated = characters.next().is_some();
3763                    (text, line_start, false, truncated)
3764                }
3765            }
3766        }
3767    };
3768    let returned_chars = text.chars().count();
3769    let has_text_before = line_has_text_before || budget_has_text_before;
3770    let has_text_after = line_has_text_after || budget_has_text_after;
3771    let field_total_chars = original_chars_if_counted;
3772    Ok(MessageFieldView {
3773        text,
3774        field_start_char,
3775        field_end_char_exclusive: field_start_char.saturating_add(returned_chars),
3776        markers: Vec::new(),
3777        extent: FieldViewExtent {
3778            additional_field_text: additional_field_text(has_text_before, has_text_after),
3779            field_total_chars,
3780            coordinate_unit: CoordinateUnit::UnicodeScalar,
3781        },
3782    })
3783}
3784
3785const fn additional_field_text(has_text_before: bool, has_text_after: bool) -> AdditionalFieldText {
3786    match (has_text_before, has_text_after) {
3787        (false, false) => AdditionalFieldText::None,
3788        (false, true) => AdditionalFieldText::After,
3789        (true, false) => AdditionalFieldText::Before,
3790        (true, true) => AdditionalFieldText::BeforeAndAfter,
3791    }
3792}
3793
3794enum PreparedMatchEvidence {
3795    All,
3796    Literal {
3797        lowered_query: String,
3798    },
3799    Regex(regex::Regex),
3800    Fuzzy {
3801        pattern: Pattern,
3802        matcher: NucleoMatcher,
3803        utf32_buf: Vec<char>,
3804        indices: Vec<u32>,
3805    },
3806}
3807
3808impl PreparedMatchEvidence {
3809    fn new(query: &MessageQuery) -> anyhow::Result<Self> {
3810        Ok(match query {
3811            MessageQuery::All => Self::All,
3812            MessageQuery::Literal(query) => Self::Literal {
3813                lowered_query: crate::util::fold_caseless(query.as_str()),
3814            },
3815            MessageQuery::Regex(query) => Self::Regex(regex::Regex::new(query.as_str())?),
3816            MessageQuery::Fuzzy(query) => Self::Fuzzy {
3817                pattern: Pattern::new(
3818                    query.as_str(),
3819                    CaseMatching::Ignore,
3820                    Normalization::Smart,
3821                    AtomKind::Fuzzy,
3822                ),
3823                matcher: NucleoMatcher::new(NucleoConfig::DEFAULT),
3824                utf32_buf: Vec::new(),
3825                indices: Vec::new(),
3826            },
3827        })
3828    }
3829
3830    fn build(
3831        &mut self,
3832        selected: &str,
3833        match_view: MatchViewBudget,
3834    ) -> Option<MessageMatchEvidence> {
3835        let field_total_chars = selected.chars().count();
3836        match self {
3837            Self::All => None,
3838            Self::Literal { lowered_query } => {
3839                let range = literal_char_range(selected, lowered_query)?;
3840                let maximum = match_view_max_chars(
3841                    match_view,
3842                    field_total_chars,
3843                    std::slice::from_ref(&range),
3844                );
3845                Some(character_evidence(
3846                    selected,
3847                    field_total_chars,
3848                    maximum,
3849                    vec![range],
3850                    false,
3851                ))
3852            }
3853            Self::Regex(regex) => {
3854                let matched = regex.find(selected)?;
3855                let start = selected[..matched.start()].chars().count();
3856                let end = start + matched.as_str().chars().count();
3857                if start == end {
3858                    let maximum = match match_view {
3859                        MatchViewBudget::MinimalSpan => 0,
3860                        MatchViewBudget::MaxChars { max_chars } => {
3861                            max_chars.get().min(field_total_chars.max(1))
3862                        }
3863                    };
3864                    Some(boundary_evidence(
3865                        selected,
3866                        field_total_chars,
3867                        maximum,
3868                        start,
3869                    ))
3870                } else {
3871                    let range = FieldCharRange {
3872                        field_start_char: start,
3873                        field_end_char_exclusive: end,
3874                    };
3875                    let maximum = match_view_max_chars(
3876                        match_view,
3877                        field_total_chars,
3878                        std::slice::from_ref(&range),
3879                    );
3880                    Some(character_evidence(
3881                        selected,
3882                        field_total_chars,
3883                        maximum,
3884                        vec![range],
3885                        false,
3886                    ))
3887                }
3888            }
3889            Self::Fuzzy {
3890                pattern,
3891                matcher,
3892                utf32_buf,
3893                indices,
3894            } => {
3895                utf32_buf.clear();
3896                indices.clear();
3897                let matcher_input = Utf32Str::new(selected, utf32_buf);
3898                pattern.indices(matcher_input, matcher, indices)?;
3899                let matcher_unit_ranges = scalar_ranges_by_matcher_unit(selected, matcher_input);
3900                let ranges = indices
3901                    .iter()
3902                    .map(|index| matcher_unit_ranges.get(*index as usize).copied())
3903                    .collect::<Option<Vec<_>>>()?;
3904                let ranges = coalesce_character_ranges(ranges);
3905                let maximum = match_view_max_chars(match_view, field_total_chars, &ranges);
3906                Some(character_evidence(
3907                    selected,
3908                    field_total_chars,
3909                    maximum,
3910                    ranges,
3911                    true,
3912                ))
3913            }
3914        }
3915    }
3916}
3917
3918fn match_view_max_chars(
3919    budget: MatchViewBudget,
3920    field_total_chars: usize,
3921    ranges: &[FieldCharRange],
3922) -> usize {
3923    match budget {
3924        MatchViewBudget::MaxChars { max_chars } => max_chars.get().min(field_total_chars.max(1)),
3925        MatchViewBudget::MinimalSpan => ranges
3926            .first()
3927            .zip(ranges.last())
3928            .map(|(first, last)| {
3929                last.field_end_char_exclusive
3930                    .saturating_sub(first.field_start_char)
3931            })
3932            .unwrap_or(0),
3933    }
3934}
3935
3936/// Where `lowered_query` sits in `selected`, in source characters.
3937///
3938/// `lowered_query` is folded by [`crate::util::fold_caseless`] and this folds the text the same
3939/// way, so the range it reports is the range the matcher that selected the row agreed on. Folding
3940/// the two sides by different rules located nothing for text they spelled differently, and the
3941/// caller then showed the head of the field as evidence for a match found elsewhere in it.
3942fn literal_char_range(selected: &str, lowered_query: &str) -> Option<FieldCharRange> {
3943    let mut lowered = String::new();
3944    let mut original_char_for_lowered = Vec::new();
3945    for (original_char, value) in selected.chars().enumerate() {
3946        for lowered_char in crate::util::fold_caseless_char(value) {
3947            lowered.push(lowered_char);
3948            original_char_for_lowered.push(original_char);
3949        }
3950    }
3951    let start_byte = lowered.find(lowered_query)?;
3952    let start_lowered = lowered[..start_byte].chars().count();
3953    let end_lowered = start_lowered + lowered_query.chars().count();
3954    Some(FieldCharRange {
3955        field_start_char: original_char_for_lowered[start_lowered],
3956        field_end_char_exclusive: original_char_for_lowered[end_lowered - 1] + 1,
3957    })
3958}
3959
3960fn scalar_ranges_by_grapheme(selected: &str) -> Vec<FieldCharRange> {
3961    let mut scalar_start = 0;
3962    selected
3963        .graphemes(true)
3964        .map(|grapheme| {
3965            let scalar_end = scalar_start + grapheme.chars().count();
3966            let range = FieldCharRange {
3967                field_start_char: scalar_start,
3968                field_end_char_exclusive: scalar_end,
3969            };
3970            scalar_start = scalar_end;
3971            range
3972        })
3973        .collect()
3974}
3975
3976fn scalar_ranges_by_matcher_unit(
3977    selected: &str,
3978    matcher_input: Utf32Str<'_>,
3979) -> Vec<FieldCharRange> {
3980    match matcher_input {
3981        Utf32Str::Unicode(_) => scalar_ranges_by_grapheme(selected),
3982        Utf32Str::Ascii(_) => {
3983            let mut scalar_start = 0;
3984            let mut ranges = Vec::with_capacity(selected.len());
3985            for grapheme in selected.graphemes(true) {
3986                let scalar_end = scalar_start + grapheme.chars().count();
3987                let range = FieldCharRange {
3988                    field_start_char: scalar_start,
3989                    field_end_char_exclusive: scalar_end,
3990                };
3991                ranges.extend(std::iter::repeat_n(range, grapheme.len()));
3992                scalar_start = scalar_end;
3993            }
3994            ranges
3995        }
3996    }
3997}
3998
3999fn coalesce_character_ranges(mut ranges: Vec<FieldCharRange>) -> Vec<FieldCharRange> {
4000    ranges.sort_by_key(|range| (range.field_start_char, range.field_end_char_exclusive));
4001    let mut coalesced = Vec::new();
4002    for range in ranges {
4003        match coalesced.last_mut() {
4004            Some(FieldCharRange {
4005                field_end_char_exclusive,
4006                ..
4007            }) if range.field_start_char <= *field_end_char_exclusive => {
4008                *field_end_char_exclusive =
4009                    (*field_end_char_exclusive).max(range.field_end_char_exclusive);
4010            }
4011            _ => coalesced.push(range),
4012        }
4013    }
4014    coalesced
4015}
4016
4017fn character_evidence(
4018    selected: &str,
4019    field_total_chars: usize,
4020    maximum: usize,
4021    ranges: Vec<FieldCharRange>,
4022    densest_window: bool,
4023) -> MessageMatchEvidence {
4024    let matched_chars_total = ranges
4025        .iter()
4026        .map(|range| range.field_end_char_exclusive - range.field_start_char)
4027        .sum();
4028    let field_start_char = if field_total_chars <= maximum {
4029        0
4030    } else if densest_window {
4031        densest_excerpt_start(&ranges, maximum, field_total_chars)
4032    } else {
4033        let first = ranges[0];
4034        let width = first.field_end_char_exclusive - first.field_start_char;
4035        first
4036            .field_start_char
4037            .saturating_sub(maximum.saturating_sub(width) / 2)
4038            .min(field_total_chars - maximum)
4039    };
4040    let field_end_char_exclusive = (field_start_char + maximum).min(field_total_chars);
4041    let shown = ranges
4042        .iter()
4043        .filter_map(|range| {
4044            let start = range.field_start_char.max(field_start_char);
4045            let end = range.field_end_char_exclusive.min(field_end_char_exclusive);
4046            (start < end).then_some(ViewCharRange {
4047                view_start_char: start - field_start_char,
4048                view_end_char_exclusive: end - field_start_char,
4049            })
4050        })
4051        .collect::<Vec<_>>();
4052    let matched_chars_shown = shown
4053        .iter()
4054        .map(|range| range.view_end_char_exclusive - range.view_start_char)
4055        .sum();
4056    MessageMatchEvidence {
4057        view_text: selected
4058            .chars()
4059            .skip(field_start_char)
4060            .take(field_end_char_exclusive - field_start_char)
4061            .collect(),
4062        field_start_char,
4063        field_total_chars,
4064        markers: MessageMatchViewMarkers::Characters {
4065            ranges: shown,
4066            matched_chars_total,
4067            matched_chars_shown,
4068        },
4069    }
4070}
4071
4072fn densest_excerpt_start(
4073    ranges: &[FieldCharRange],
4074    maximum: usize,
4075    field_total_chars: usize,
4076) -> usize {
4077    let indices = ranges
4078        .iter()
4079        .flat_map(|range| range.field_start_char..range.field_end_char_exclusive)
4080        .collect::<Vec<_>>();
4081    let mut best = (0, 0);
4082    let mut right = 0;
4083    for left in 0..indices.len() {
4084        while right < indices.len() && indices[right] < indices[left] + maximum {
4085            right += 1;
4086        }
4087        let candidate = (right - left, indices[left].min(field_total_chars - maximum));
4088        if candidate.0 > best.0 || (candidate.0 == best.0 && candidate.1 < best.1) {
4089            best = candidate;
4090        }
4091    }
4092    best.1
4093}
4094
4095fn boundary_evidence(
4096    selected: &str,
4097    field_total_chars: usize,
4098    maximum: usize,
4099    boundary: usize,
4100) -> MessageMatchEvidence {
4101    let field_start_char = boundary
4102        .saturating_sub(maximum / 2)
4103        .min(field_total_chars.saturating_sub(maximum));
4104    let field_end_char_exclusive = (field_start_char + maximum).min(field_total_chars);
4105    MessageMatchEvidence {
4106        view_text: selected
4107            .chars()
4108            .skip(field_start_char)
4109            .take(field_end_char_exclusive - field_start_char)
4110            .collect(),
4111        field_start_char,
4112        field_total_chars,
4113        markers: MessageMatchViewMarkers::Boundary {
4114            view_at_char: boundary - field_start_char,
4115        },
4116    }
4117}
4118
4119#[cfg(test)]
4120mod tests {
4121    use super::*;
4122    use chrono::TimeZone;
4123
4124    fn hit(content: impl Into<String>) -> crate::models::MessageHit {
4125        crate::models::MessageHit {
4126            session_id: "claude:evidence".into(),
4127            provider: Provider::Claude,
4128            seq: 7,
4129            role: Role::Tool,
4130            kind: MessageKind::ToolCall,
4131            ts: None,
4132            tool_name: Some("exec_command".into()),
4133            tool_call_id: Some("tool-7".into()),
4134            fuzzy_score: None,
4135            content: content.into(),
4136        }
4137    }
4138
4139    #[test]
4140    fn query_and_target_constructors_validate_input() {
4141        assert_eq!(
4142            MessageQuery::literal("").unwrap_err().code(),
4143            "invalid-query"
4144        );
4145        assert_eq!(
4146            MessageQuery::regex("[").unwrap_err().code(),
4147            "invalid-query"
4148        );
4149        assert_eq!(
4150            MessageQuery::fuzzy("ab").unwrap_err().code(),
4151            "invalid-query"
4152        );
4153        assert!(MessageQuery::fuzzy("ašŸ¦€b").is_ok());
4154        assert!(MessageTarget::tool_argument("").is_ok());
4155        assert!(MessageTarget::tool_argument("/request/path").is_ok());
4156        assert_eq!(
4157            MessageTarget::tool_argument("request/path")
4158                .unwrap_err()
4159                .code(),
4160            "invalid-json-pointer"
4161        );
4162        assert!(MessageTarget::tool_argument("/~0key/~1path").is_ok());
4163        assert!(MessageTarget::tool_argument("/~bad").is_err());
4164        assert_eq!(
4165            MessageQuery::fuzzy(" \t\n").unwrap_err().code(),
4166            "invalid-query"
4167        );
4168    }
4169
4170    #[test]
4171    fn builder_rejects_cross_field_conflicts() {
4172        let sequence = SequenceRange::new(Some(1), Some(2)).unwrap();
4173        let error = MessageSearchRequest::builder(
4174            MessageQuery::literal("needle").unwrap(),
4175            MessageTarget::content(),
4176        )
4177        .sequence(sequence)
4178        .build()
4179        .unwrap_err();
4180        assert_eq!(error.code(), "parameter-conflict");
4181
4182        let error = MessageSearchRequest::builder(
4183            MessageQuery::All,
4184            MessageTarget::tool_argument("/cmd").unwrap(),
4185        )
4186        .kind(MessageKind::Conversation)
4187        .build()
4188        .unwrap_err();
4189        assert_eq!(error.code(), "parameter-conflict");
4190
4191        let error = MessageSearchRequest::builder(
4192            MessageQuery::literal("needle").unwrap(),
4193            MessageTarget::content(),
4194        )
4195        .role(Role::Compaction)
4196        .include_compaction(false)
4197        .build()
4198        .unwrap_err();
4199        assert_eq!(error.code(), "parameter-conflict");
4200
4201        let error = MessageSearchRequest::builder(MessageQuery::All, MessageTarget::content())
4202            .match_view(MatchViewBudget::max_chars(20).unwrap())
4203            .build()
4204            .unwrap_err();
4205        assert!(error.to_string().contains("requires a literal"));
4206    }
4207
4208    #[test]
4209    fn selected_tool_argument_evidence_exposes_a_match_beyond_raw_boundaries() {
4210        let command = format!("{}Trash{}", "x".repeat(855), "y".repeat(400));
4211        let content = serde_json::json!({"args": {"command": command}}).to_string();
4212        let evidence = attach_match_evidence(
4213            &MessageQuery::literal("trash").unwrap(),
4214            &MessageTarget::tool_argument("/command").unwrap(),
4215            MatchViewBudget::MaxChars {
4216                max_chars: NonZeroUsize::new(40).unwrap(),
4217            },
4218            vec![hit(content)],
4219        )
4220        .unwrap();
4221
4222        let evidence = evidence[0].match_evidence().unwrap();
4223        assert!(evidence.view_text.to_lowercase().contains("trash"));
4224        assert!(evidence.field_start_char > 220);
4225        assert_eq!(evidence.view_text.chars().count(), 40);
4226        assert_eq!(evidence.field_total_chars, 1_260);
4227    }
4228
4229    #[test]
4230    fn batch_enrichment_checks_cancellation_between_hits_and_phases() {
4231        let query = MessageQuery::literal("needle").unwrap();
4232        let target = MessageTarget::content();
4233        let source_hits = vec![hit("needle zero"), hit("needle one"), hit("needle two")];
4234        let mut evidence_checks = 0;
4235        let evidence_error = attach_match_evidence_cancellable(
4236            &query,
4237            &target,
4238            MatchViewBudget::MinimalSpan,
4239            source_hits.clone(),
4240            || {
4241                evidence_checks += 1;
4242                anyhow::ensure!(evidence_checks < 3, "injected evidence cancellation");
4243                Ok(())
4244            },
4245        )
4246        .unwrap_err();
4247        assert_eq!(evidence_checks, 3);
4248        assert!(evidence_error
4249            .to_string()
4250            .contains("injected evidence cancellation"));
4251
4252        let mut hits =
4253            attach_match_evidence(&query, &target, MatchViewBudget::MinimalSpan, source_hits)
4254                .unwrap();
4255        let presentation = ResolvedMessagePresentation {
4256            include_refs: false,
4257            message_lines: LineWindow::Full,
4258            match_evidence_max_chars: NonZeroUsize::new(32).unwrap(),
4259            detail: None,
4260            field_view: FieldViewBudget::NoCharLimit,
4261            match_view: MatchViewBudget::MinimalSpan,
4262        };
4263        let mut presentation_checks = 0;
4264        let presentation_error =
4265            apply_message_presentation_cancellable(&target, presentation, &mut hits, || {
4266                presentation_checks += 1;
4267                anyhow::ensure!(
4268                    presentation_checks < 3,
4269                    "injected presentation cancellation"
4270                );
4271                Ok(())
4272            })
4273            .unwrap_err();
4274        assert_eq!(presentation_checks, 3);
4275        assert!(presentation_error
4276            .to_string()
4277            .contains("injected presentation cancellation"));
4278    }
4279
4280    #[test]
4281    fn long_literal_keeps_complete_field_occurrence_beside_bounded_evidence() {
4282        let literal = "Needle".repeat(80);
4283        let content = format!("prefix {literal} suffix");
4284        let hits = attach_match_evidence(
4285            &MessageQuery::literal(literal.to_lowercase()).unwrap(),
4286            &MessageTarget::content(),
4287            MatchViewBudget::MaxChars {
4288                max_chars: NonZeroUsize::new(40).unwrap(),
4289            },
4290            vec![hit(content)],
4291        )
4292        .unwrap();
4293
4294        let hit = &hits[0];
4295        assert_eq!(hit.match_evidence().unwrap().view_text.chars().count(), 40);
4296        let occurrence = hit.literal_match().expect("literal field occurrence");
4297        assert_eq!(occurrence.text, literal);
4298        assert_eq!(occurrence.field_start_char, 7);
4299        assert_eq!(
4300            occurrence.field_end_char_exclusive,
4301            7 + literal.chars().count()
4302        );
4303    }
4304
4305    #[test]
4306    fn minimal_match_view_contains_the_entire_literal_regex_or_fuzzy_span() {
4307        let literal = "Needle".repeat(80);
4308        let content = format!("prefix {literal} suffix");
4309        for query in [
4310            MessageQuery::literal(literal.to_lowercase()).unwrap(),
4311            MessageQuery::regex(r"(?:Needle){80}").unwrap(),
4312        ] {
4313            let hits = attach_match_evidence(
4314                &query,
4315                &MessageTarget::content(),
4316                MatchViewBudget::MinimalSpan,
4317                vec![hit(content.clone())],
4318            )
4319            .unwrap();
4320            assert_eq!(hits[0].match_evidence().unwrap().view_text, literal);
4321        }
4322
4323        let fuzzy = attach_match_evidence(
4324            &MessageQuery::fuzzy("tst").unwrap(),
4325            &MessageTarget::content(),
4326            MatchViewBudget::MinimalSpan,
4327            vec![hit("prefix test suffix")],
4328        )
4329        .unwrap();
4330        assert_eq!(fuzzy[0].match_evidence().unwrap().view_text, "test");
4331
4332        let boundary = attach_match_evidence(
4333            &MessageQuery::regex(r"(?m)^").unwrap(),
4334            &MessageTarget::content(),
4335            MatchViewBudget::MinimalSpan,
4336            vec![hit("line")],
4337        )
4338        .unwrap();
4339        assert_eq!(boundary[0].match_evidence().unwrap().view_text, "");
4340    }
4341
4342    #[test]
4343    fn field_view_range_extent_and_count_cover_full_start_end_middle_and_unicode() {
4344        let full =
4345            selected_field_view("aĆ©šŸ™‚", LineWindow::Full, FieldViewBudget::NoCharLimit, None)
4346                .unwrap();
4347        assert_eq!(full.text(), "aĆ©šŸ™‚");
4348        assert_eq!(
4349            (full.field_start_char(), full.field_end_char_exclusive()),
4350            (0, 3)
4351        );
4352        assert_eq!(
4353            full.extent().additional_field_text(),
4354            AdditionalFieldText::None
4355        );
4356        assert_eq!(full.extent().field_total_chars(), Some(3));
4357
4358        let start = selected_field_view(
4359            "alpha\nbeta",
4360            LineWindow::Head(NonZeroUsize::new(1).unwrap()),
4361            FieldViewBudget::NoCharLimit,
4362            None,
4363        )
4364        .unwrap();
4365        assert_eq!(start.text(), "alpha");
4366        assert_eq!(
4367            (start.field_start_char(), start.field_end_char_exclusive()),
4368            (0, 5)
4369        );
4370        assert_eq!(
4371            start.extent().additional_field_text(),
4372            AdditionalFieldText::After
4373        );
4374        assert_eq!(start.extent().field_total_chars(), None);
4375
4376        let end = selected_field_view(
4377            "alpha\nbeta",
4378            LineWindow::Tail(NonZeroUsize::new(1).unwrap()),
4379            FieldViewBudget::NoCharLimit,
4380            None,
4381        )
4382        .unwrap();
4383        assert_eq!(end.text(), "beta");
4384        assert_eq!(
4385            (end.field_start_char(), end.field_end_char_exclusive()),
4386            (6, 10)
4387        );
4388        assert_eq!(
4389            end.extent().additional_field_text(),
4390            AdditionalFieldText::Before
4391        );
4392        assert_eq!(end.extent().field_total_chars(), None);
4393
4394        let middle = evidence_field_view(&MessageMatchEvidence {
4395            view_text: "cd".into(),
4396            field_start_char: 2,
4397            field_total_chars: 6,
4398            markers: MessageMatchViewMarkers::Characters {
4399                ranges: vec![ViewCharRange {
4400                    view_start_char: 0,
4401                    view_end_char_exclusive: 2,
4402                }],
4403                matched_chars_total: 2,
4404                matched_chars_shown: 2,
4405            },
4406        });
4407        assert_eq!(
4408            (middle.field_start_char(), middle.field_end_char_exclusive()),
4409            (2, 4)
4410        );
4411        assert_eq!(
4412            middle.extent().additional_field_text(),
4413            AdditionalFieldText::BeforeAndAfter
4414        );
4415        assert_eq!(middle.extent().field_total_chars(), Some(6));
4416
4417        let empty =
4418            selected_field_view("", LineWindow::Full, FieldViewBudget::NoCharLimit, None).unwrap();
4419        assert_eq!(
4420            (empty.field_start_char(), empty.field_end_char_exclusive()),
4421            (0, 0)
4422        );
4423        assert_eq!(
4424            empty.extent().additional_field_text(),
4425            AdditionalFieldText::None
4426        );
4427        assert_eq!(empty.extent().field_total_chars(), Some(0));
4428
4429        for view in [&full, &start, &end, &middle, &empty] {
4430            assert_eq!(
4431                view.field_end_char_exclusive() - view.field_start_char(),
4432                view.text().chars().count(),
4433                "absolute field range must equal returned Unicode scalar count"
4434            );
4435        }
4436    }
4437
4438    #[test]
4439    fn content_extent_uses_absolute_ranges_and_one_additional_text_direction() {
4440        let original = "alpha\nbeta\ngamma";
4441        let cases = [
4442            (
4443                LineWindow::Full,
4444                FieldViewBudget::NoCharLimit,
4445                "alpha\nbeta\ngamma",
4446                0,
4447                16,
4448                "none",
4449                Some(16),
4450            ),
4451            (
4452                LineWindow::Head(NonZeroUsize::new(1).unwrap()),
4453                FieldViewBudget::NoCharLimit,
4454                "alpha",
4455                0,
4456                5,
4457                "after",
4458                None,
4459            ),
4460            (
4461                LineWindow::Tail(NonZeroUsize::new(1).unwrap()),
4462                FieldViewBudget::NoCharLimit,
4463                "gamma",
4464                11,
4465                16,
4466                "before",
4467                None,
4468            ),
4469            (
4470                LineWindow::Full,
4471                FieldViewBudget::max_chars(6).unwrap(),
4472                "alpha\n",
4473                0,
4474                6,
4475                "after",
4476                None,
4477            ),
4478        ];
4479        for (lines, budget, text, start, end, additional, total) in cases {
4480            let view = selected_field_view(original, lines, budget, None).unwrap();
4481            let (actual_text, extent) = view.into_content_and_extent();
4482            let extent = serde_json::to_value(extent).unwrap();
4483            assert_eq!(actual_text, text);
4484            assert_eq!(extent["field_start_char"], start);
4485            assert_eq!(extent["field_end_char_exclusive"], end);
4486            assert_eq!(extent["additional_field_text"], additional);
4487            assert_eq!(extent["field_total_chars"], serde_json::json!(total));
4488            // This shape is serialized into get_session, which has no shared root to hoist to,
4489            // so it keeps the unit. The search response's views do not.
4490            assert_eq!(extent["coordinate_unit"], "unicode_scalar");
4491            for rejected in [
4492                "complete",
4493                "omitted_start",
4494                "omitted_end",
4495                "returned_chars",
4496                "original_chars",
4497            ] {
4498                assert!(
4499                    extent.get(rejected).is_none(),
4500                    "{rejected} leaked: {extent}"
4501                );
4502            }
4503        }
4504
4505        let trailing_newline = selected_field_view(
4506            "alpha\nbeta\n",
4507            LineWindow::Tail(NonZeroUsize::new(1).unwrap()),
4508            FieldViewBudget::NoCharLimit,
4509            None,
4510        )
4511        .unwrap();
4512        let (text, extent) = trailing_newline.into_content_and_extent();
4513        let extent = serde_json::to_value(extent).unwrap();
4514        assert_eq!(text, "beta");
4515        assert_eq!(extent["field_start_char"], 6);
4516        assert_eq!(extent["field_end_char_exclusive"], 10);
4517        assert_eq!(extent["additional_field_text"], "before_and_after");
4518    }
4519
4520    #[test]
4521    fn regex_zero_width_and_fuzzy_matches_have_typed_markers() {
4522        let regex = attach_match_evidence(
4523            &MessageQuery::regex(r"(?m)^").unwrap(),
4524            &MessageTarget::content(),
4525            MatchViewBudget::MaxChars {
4526                max_chars: NonZeroUsize::new(8).unwrap(),
4527            },
4528            vec![hit("abcdefghijk")],
4529        )
4530        .unwrap();
4531        assert!(matches!(
4532            regex[0].match_evidence().unwrap().markers,
4533            MessageMatchViewMarkers::Boundary { view_at_char: 0 }
4534        ));
4535
4536        let fuzzy = attach_match_evidence(
4537            &MessageQuery::fuzzy("tst").unwrap(),
4538            &MessageTarget::content(),
4539            MatchViewBudget::MaxChars {
4540                max_chars: NonZeroUsize::new(12).unwrap(),
4541            },
4542            vec![hit("prefix test suffix")],
4543        )
4544        .unwrap();
4545        let evidence = fuzzy[0].match_evidence().unwrap();
4546        assert!(matches!(
4547            evidence.markers,
4548            MessageMatchViewMarkers::Characters {
4549                matched_chars_total: 3,
4550                matched_chars_shown: 3,
4551                ..
4552            }
4553        ));
4554    }
4555
4556    #[test]
4557    fn fuzzy_grapheme_indices_map_to_complete_scalar_ranges() {
4558        for (selected, expected) in [
4559            (
4560                "e\u{301} prefix test",
4561                vec![
4562                    ViewCharRange {
4563                        view_start_char: 10,
4564                        view_end_char_exclusive: 11,
4565                    },
4566                    ViewCharRange {
4567                        view_start_char: 12,
4568                        view_end_char_exclusive: 14,
4569                    },
4570                ],
4571            ),
4572            (
4573                "Ć© e\u{301} prefix test",
4574                vec![
4575                    ViewCharRange {
4576                        view_start_char: 12,
4577                        view_end_char_exclusive: 13,
4578                    },
4579                    ViewCharRange {
4580                        view_start_char: 14,
4581                        view_end_char_exclusive: 16,
4582                    },
4583                ],
4584            ),
4585            (
4586                "šŸ‘©ā€šŸ’» test",
4587                vec![
4588                    ViewCharRange {
4589                        view_start_char: 4,
4590                        view_end_char_exclusive: 5,
4591                    },
4592                    ViewCharRange {
4593                        view_start_char: 6,
4594                        view_end_char_exclusive: 8,
4595                    },
4596                ],
4597            ),
4598        ] {
4599            let fuzzy = attach_match_evidence(
4600                &MessageQuery::fuzzy("tst").unwrap(),
4601                &MessageTarget::content(),
4602                MatchViewBudget::MaxChars {
4603                    max_chars: NonZeroUsize::new(30).unwrap(),
4604                },
4605                vec![hit(selected)],
4606            )
4607            .unwrap();
4608            let MessageMatchViewMarkers::Characters { ranges, .. } =
4609                &fuzzy[0].match_evidence().unwrap().markers
4610            else {
4611                panic!("fuzzy evidence must use character ranges");
4612            };
4613            assert_eq!(ranges, &expected);
4614        }
4615    }
4616
4617    #[test]
4618    fn queryless_hits_omit_evidence_without_projecting_selected_fields() {
4619        let hits = attach_match_evidence(
4620            &MessageQuery::All,
4621            &MessageTarget::tool_argument("/missing").unwrap(),
4622            MatchViewBudget::MaxChars {
4623                max_chars: NonZeroUsize::new(20).unwrap(),
4624            },
4625            vec![hit("not-json")],
4626        )
4627        .unwrap();
4628        assert!(hits[0].match_evidence().is_none());
4629    }
4630
4631    #[test]
4632    fn fuzzy_extent_and_latest_window_rules_are_explicit() {
4633        let fuzzy = MessageQuery::fuzzy("needle").unwrap();
4634        assert!(
4635            MessageSearchRequest::builder(fuzzy.clone(), MessageTarget::content())
4636                .build()
4637                .is_ok()
4638        );
4639        assert!(
4640            MessageSearchRequest::builder(fuzzy.clone(), MessageTarget::content())
4641                .extent(RequestedExtent::page(Some(10), 1).unwrap())
4642                .build()
4643                .is_ok(),
4644            "deterministically ranked fuzzy results support numeric offset pages"
4645        );
4646        assert!(
4647            MessageSearchRequest::builder(fuzzy, MessageTarget::content())
4648                .extent(RequestedExtent::page(Some(10), 0).unwrap())
4649                .build()
4650                .is_ok()
4651        );
4652
4653        let latest = MessageSearchRequest::builder(
4654            MessageQuery::regex("needle").unwrap(),
4655            MessageTarget::content(),
4656        )
4657        .match_window(MatchWindow::Latest)
4658        .build()
4659        .unwrap_err();
4660        assert!(latest.to_string().contains("requires one session"));
4661
4662        assert!(
4663            MessageSearchRequest::builder(MessageQuery::All, MessageTarget::content())
4664                .session_id("claude:session")
4665                .unwrap()
4666                .match_window(MatchWindow::Latest)
4667                .build()
4668                .is_ok()
4669        );
4670    }
4671
4672    #[test]
4673    fn provider_sets_are_non_empty_deduplicated_and_canonically_ordered() {
4674        let empty = MessageSearchRequest::builder(
4675            MessageQuery::literal("needle").unwrap(),
4676            MessageTarget::content(),
4677        )
4678        .providers(Vec::new())
4679        .err()
4680        .expect("an empty provider set must fail");
4681        assert!(empty.to_string().contains("providers"), "{empty}");
4682        assert!(empty.to_string().contains("at least one"), "{empty}");
4683
4684        let request = MessageSearchRequest::builder(
4685            MessageQuery::literal("needle").unwrap(),
4686            MessageTarget::content(),
4687        )
4688        .providers(vec![
4689            Provider::Codex,
4690            Provider::Claude,
4691            Provider::Codex,
4692            Provider::GeminiCli,
4693        ])
4694        .unwrap()
4695        .build()
4696        .unwrap();
4697        assert_eq!(
4698            request.predicates().providers(),
4699            Some([Provider::Claude, Provider::Codex, Provider::GeminiCli].as_slice())
4700        );
4701    }
4702
4703    #[test]
4704    fn signed_line_window_preserves_full_head_and_tail() {
4705        assert_eq!(LineWindow::from_signed(0).unwrap(), LineWindow::Full);
4706        assert_eq!(LineWindow::from_signed(3).unwrap().to_signed().unwrap(), 3);
4707        assert_eq!(
4708            LineWindow::from_signed(-3).unwrap().to_signed().unwrap(),
4709            -3
4710        );
4711        assert!(LineWindow::from_signed(i64::MIN).is_err());
4712    }
4713
4714    #[test]
4715    fn complete_valid_request_preserves_every_orthogonal_concept() {
4716        let time = RequestedTimeRange::new(
4717            Some(Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()),
4718            Some(Utc.with_ymd_and_hms(2026, 7, 22, 0, 0, 0).unwrap()),
4719        )
4720        .unwrap();
4721        let request = MessageSearchRequest::builder(
4722            MessageQuery::literal("cargo test").unwrap(),
4723            MessageTarget::tool_argument("/cmd").unwrap(),
4724        )
4725        .role(Role::Tool)
4726        .kind(MessageKind::ToolCall)
4727        .provider(Provider::Claude)
4728        .session_id("claude:session")
4729        .unwrap()
4730        .workspace_path_prefix("/workspace")
4731        .unwrap()
4732        .transcript_path_prefix("/transcripts")
4733        .unwrap()
4734        .exclude_workspace_path_prefix("/workspace/vendor")
4735        .unwrap()
4736        .exclude_transcript_path_prefix("/transcripts/archive")
4737        .unwrap()
4738        .exclude_session_id("claude:excluded")
4739        .unwrap()
4740        .time(time)
4741        .sequence(SequenceRange::new(Some(2), Some(8)).unwrap())
4742        .tool_name_contains("exec")
4743        .unwrap()
4744        .include_compaction(true)
4745        .match_window(MatchWindow::Latest)
4746        .context(ContextWindow::new(1, 3))
4747        .includes([MessageSearchInclude::ParsedReferences])
4748        .message_lines(LineWindow::from_signed(-8).unwrap())
4749        .extent(RequestedExtent::page(Some(25), 5).unwrap())
4750        .purpose(PurposeSelection::new("historical-audit", NonZeroU32::new(2)).unwrap())
4751        .receipt_level(ReceiptLevel::Full)
4752        .build()
4753        .unwrap();
4754
4755        assert_eq!(request.query().text(), Some("cargo test"));
4756        assert_eq!(request.target().field(), SearchField::ToolArgument);
4757        assert_eq!(
4758            request.target().argument_path().map(JsonPointer::as_str),
4759            Some("/cmd")
4760        );
4761        assert_eq!(request.predicates().session(), Some("claude:session"));
4762        assert_eq!(request.predicates().sequence().unwrap().from(), Some(2));
4763        assert_eq!(request.context(), Some(ContextWindow::new(1, 3)));
4764        assert_eq!(
4765            request.includes(),
4766            Some([MessageSearchInclude::ParsedReferences].as_slice())
4767        );
4768        assert_eq!(request.match_window(), Some(MatchWindow::Latest));
4769        assert_eq!(request.receipt_level(), Some(ReceiptLevel::Full));
4770        assert_eq!(request.purpose().unwrap().name(), "historical-audit");
4771    }
4772
4773    #[test]
4774    fn range_extent_and_purpose_failures_have_stable_codes() {
4775        assert_eq!(
4776            RequestedExtent::page(Some(0), 0).unwrap_err().code(),
4777            "invalid-parameter"
4778        );
4779        assert_eq!(
4780            SequenceRange::new(Some(2), Some(1)).unwrap_err().code(),
4781            "invalid-parameter"
4782        );
4783        assert_eq!(
4784            RequestedTimeRange::new(
4785                Some(Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0).unwrap()),
4786                Some(Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()),
4787            )
4788            .unwrap_err()
4789            .code(),
4790            "invalid-parameter"
4791        );
4792        assert_eq!(
4793            PurposeSelection::new("hard2parse", None)
4794                .unwrap_err()
4795                .code(),
4796            "invalid-parameter"
4797        );
4798    }
4799
4800    #[test]
4801    fn registered_rules_reject_the_named_parameter_combinations() {
4802        let literal = || {
4803            MessageSearchRequest::builder(
4804                MessageQuery::literal("needle").unwrap(),
4805                MessageTarget::content(),
4806            )
4807        };
4808        let cases = [
4809            (
4810                MessageSearchRule::DetailOwnsPresentationBudgets,
4811                literal()
4812                    .detail(DetailLevel::Full)
4813                    .message_lines(LineWindow::Full)
4814                    .build(),
4815            ),
4816            (
4817                MessageSearchRule::SequenceRequiresSession,
4818                literal()
4819                    .sequence(SequenceRange::new(Some(1), None).unwrap())
4820                    .build(),
4821            ),
4822            (
4823                MessageSearchRule::KindsMustRemainSatisfiable,
4824                literal().kinds(Vec::new()).build(),
4825            ),
4826            (
4827                MessageSearchRule::CompactionRoleRequiresCompactionKind,
4828                literal()
4829                    .role(Role::Compaction)
4830                    .kinds(vec![MessageKind::Conversation])
4831                    .build(),
4832            ),
4833            (
4834                MessageSearchRule::ToolArgumentRequiresToolCallKind,
4835                MessageSearchRequest::builder(
4836                    MessageQuery::literal("needle").unwrap(),
4837                    MessageTarget::tool_argument("/cmd").unwrap(),
4838                )
4839                .kinds(vec![MessageKind::Conversation])
4840                .build(),
4841            ),
4842            (
4843                MessageSearchRule::MatchViewRequiresQuery,
4844                MessageSearchRequest::builder(MessageQuery::All, MessageTarget::content())
4845                    .match_view(MatchViewBudget::MinimalSpan)
4846                    .build(),
4847            ),
4848            (
4849                MessageSearchRule::FuzzyRejectsMatchWindow,
4850                MessageSearchRequest::builder(
4851                    MessageQuery::fuzzy("needle").unwrap(),
4852                    MessageTarget::content(),
4853                )
4854                .match_window(MatchWindow::Earliest)
4855                .extent(RequestedExtent::page(Some(1), 0).unwrap())
4856                .build(),
4857            ),
4858            (
4859                MessageSearchRule::LatestWindowRequiresSession,
4860                literal().match_window(MatchWindow::Latest).build(),
4861            ),
4862            (
4863                MessageSearchRule::FuzzyRejectsAllResults,
4864                MessageSearchRequest::builder(
4865                    MessageQuery::fuzzy("needle").unwrap(),
4866                    MessageTarget::content(),
4867                )
4868                .extent(RequestedExtent::all_results())
4869                .build(),
4870            ),
4871        ];
4872
4873        assert_eq!(
4874            MessageSearchParameterRegistry::current().rules(),
4875            MessageSearchRule::ALL
4876        );
4877        for (rule, result) in cases {
4878            let error = result.unwrap_err();
4879            assert_eq!(error.code(), "parameter-conflict");
4880            assert!(
4881                error.to_string().contains(rule.as_str()),
4882                "{} must identify executable rule {}",
4883                error,
4884                rule.as_str()
4885            );
4886            assert!(error.to_string().contains(rule.message()));
4887        }
4888    }
4889
4890    #[test]
4891    fn parameter_registry_derives_closed_vocabularies_and_names_surface_availability() {
4892        fn serialized_variants<T>() -> Vec<String>
4893        where
4894            T: clap::ValueEnum + Copy + Serialize + 'static,
4895        {
4896            T::value_variants()
4897                .iter()
4898                .map(|value| {
4899                    serde_json::to_value(*value)
4900                        .unwrap()
4901                        .as_str()
4902                        .unwrap()
4903                        .to_owned()
4904                })
4905                .collect()
4906        }
4907
4908        let registry = MessageSearchParameterRegistry::current();
4909        for (parameter, expected) in [
4910            (
4911                MessageSearchParameter::QueryMode,
4912                serialized_variants::<MessageSearchMode>(),
4913            ),
4914            (
4915                MessageSearchParameter::Field,
4916                serialized_variants::<SearchField>(),
4917            ),
4918            (MessageSearchParameter::Role, serialized_variants::<Role>()),
4919            (
4920                MessageSearchParameter::Kinds,
4921                serialized_variants::<MessageKind>(),
4922            ),
4923            (
4924                MessageSearchParameter::MatchWindow,
4925                serialized_variants::<MatchWindow>(),
4926            ),
4927            (
4928                MessageSearchParameter::Detail,
4929                serialized_variants::<DetailLevel>(),
4930            ),
4931            (
4932                MessageSearchParameter::Include,
4933                serialized_variants::<MessageSearchInclude>(),
4934            ),
4935            (
4936                MessageSearchParameter::ReceiptLevel,
4937                serialized_variants::<ReceiptLevel>(),
4938            ),
4939        ] {
4940            assert_eq!(
4941                registry
4942                    .parameter(parameter)
4943                    .unwrap_or_else(|| panic!("missing registry parameter {}", parameter.as_str()))
4944                    .accepted_values(),
4945                expected,
4946                "{} must derive its vocabulary from the executable enum",
4947                parameter.as_str()
4948            );
4949        }
4950        assert_eq!(
4951            registry
4952                .parameter(MessageSearchParameter::Providers)
4953                .unwrap()
4954                .accepted_values(),
4955            crate::source::PROVIDERS
4956                .iter()
4957                .map(|provider| provider.as_str().to_owned())
4958                .collect::<Vec<_>>(),
4959            "provider vocabulary and canonical order must come from source::PROVIDERS"
4960        );
4961        assert_eq!(
4962            registry
4963                .parameter(MessageSearchParameter::Query)
4964                .unwrap()
4965                .domain(),
4966            &MessageSearchParameterDomain::Text { non_empty: false },
4967            "empty is the cross-surface queryless-search spelling and must not be advertised as invalid"
4968        );
4969        assert_eq!(
4970            registry
4971                .parameter(MessageSearchParameter::Detail)
4972                .unwrap()
4973                .surfaces(),
4974            SearchSurface::ALL,
4975            "the finalized registry must not freeze provisional CLI/Python presentation gaps"
4976        );
4977        assert_eq!(registry.rule_descriptors().len(), registry.rules().len());
4978        for (descriptor, rule) in registry.rule_descriptors().iter().zip(registry.rules()) {
4979            assert_eq!(descriptor.rule(), *rule);
4980            assert_eq!(descriptor.message(), rule.message());
4981        }
4982    }
4983}