1use 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;
22pub 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#[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#[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#[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#[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
388pub 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
419pub 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#[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 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#[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#[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 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#[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#[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#[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 PageSide::Present
1828 } else {
1829 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#[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#[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 field_total_chars: Option<usize>,
1992 #[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#[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 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 pub fn results(&self) -> &[MessageSearchHit] {
2337 &self.hits
2338 }
2339
2340 pub fn into_hits(self) -> Vec<MessageSearchHit> {
2341 self.hits
2342 }
2343
2344 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 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 pub const fn document(&self) -> MessageSearchDocument<'_> {
2397 MessageSearchDocument {
2398 response: self,
2399 cancellation: None,
2400 }
2401 }
2402
2403 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 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 pub const fn page_document(&self) -> MessageSearchPageDocument {
2433 MessageSearchPageDocument(self.page)
2434 }
2435
2436 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 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
2472pub(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 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#[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 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#[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#[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#[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 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 pub fn kind(mut self, kind: MessageKind) -> Self {
3230 self.request.predicates.kinds = Some(vec![kind]);
3231 self
3232 }
3233
3234 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 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 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 (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
3576pub(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
3618pub(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
3657pub(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
3936fn 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 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}