1use std::collections::{BTreeMap, HashSet};
12use std::io::{self, Write};
13use std::num::NonZeroUsize;
14
15use anyhow::{bail, Result};
16use clap::{ArgGroup, Args, Subcommand};
17use serde::Serialize;
18
19use crate::config::Config;
20use crate::dates::DateRange;
21use crate::db::Db;
22use crate::inspect::{inspection_rows, InspectionOptions};
23use crate::message_search::{
24 ContextWindow, DetailLevel, FieldViewBudget, LineWindow, MatchViewBudget, MatchWindow,
25 MessageQuery, MessageSearchHit, MessageSearchInclude, MessageSearchRequest,
26 MessageSearchResponse, MessageTarget, PurposeSelection, ReceiptLevel, RequestedExtent,
27 RequestedTimeRange, ResolvedExtent, SearchSurface, SequenceRange,
28 MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
29};
30use crate::models::{
31 MessageFilters, MessageHit, MessageKind, MessageSearchMode, Provider, Role, SearchField,
32};
33use crate::refs::{extract_refs_from_text, ref_summary, MessageRef};
34use crate::render::{render, OutputFormat, Row, RowBatchRenderer};
35use crate::service::{CatalogService, MessageService};
36use crate::util::{select_message_lines, truncate_for_display};
37
38const LINES_PER_MESSAGE_HELP: &str = "Limit each returned message's displayed content: positive keeps its first N lines, negative keeps its last N lines, and 0 keeps its complete content. This presentation window does not change matches, ranking, result count, pagination, context membership, or reference extraction. Use it to keep many search hits or long tool outputs skimmable without discarding hits. Omit it to use [cli].lines_per_message; use aise show --transcript-lines to window one whole session transcript.";
39
40const TABLE_CONTENT_CHARS: usize = 120;
42const CLI_MESSAGE_SEARCH_BATCH_ROWS: usize = 256;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
51pub enum ReadOrder {
52 #[default]
53 Oldest,
54 Newest,
55}
56
57impl ReadOrder {
58 fn to_message_order(self) -> crate::db::MessageOrder {
59 match self {
60 ReadOrder::Oldest => crate::db::MessageOrder::OldestFirst,
61 ReadOrder::Newest => crate::db::MessageOrder::NewestFirst,
62 }
63 }
64}
65
66impl Row for MessageHit {
67 fn headers() -> &'static [&'static str] {
68 &[
69 "session", "provider", "seq", "role", "tool", "ts", "content",
70 ]
71 }
72
73 fn cells(&self) -> Vec<String> {
74 vec![
75 self.session_id.clone(),
76 self.provider.as_str().to_string(),
77 self.seq.to_string(),
78 self.role.as_str().to_string(),
79 self.tool_name.clone().unwrap_or_default(),
80 self.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
81 truncate_for_display(&self.content, TABLE_CONTENT_CHARS),
82 ]
83 }
84}
85
86impl Row for MessageSearchHit {
87 fn headers() -> &'static [&'static str] {
88 &[
89 "session", "provider", "seq", "role", "tool", "ts", "match", "content",
90 ]
91 }
92
93 fn cells(&self) -> Vec<String> {
94 vec![
95 self.session_id.clone(),
96 self.provider.as_str().to_string(),
97 self.seq.to_string(),
98 self.role.as_str().to_string(),
99 self.tool_name.clone().unwrap_or_default(),
100 self.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
101 self.match_evidence()
102 .map(format_match_evidence)
103 .unwrap_or_default(),
104 truncate_for_display(&self.content, TABLE_CONTENT_CHARS),
105 ]
106 }
107}
108
109#[derive(Debug, Clone, Serialize)]
112struct ContextRow {
113 #[serde(flatten)]
114 hit: MessageHit,
115 is_match: bool,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 match_evidence: Option<crate::message_search::MessageMatchEvidence>,
118}
119
120#[derive(Debug)]
121struct PendingContextRow {
122 hit: MessageHit,
123 is_match: bool,
124 match_evidence: Option<crate::message_search::MessageMatchEvidence>,
125}
126
127impl PendingContextRow {
128 fn surrounding(hit: MessageHit) -> Self {
129 Self {
130 hit,
131 is_match: false,
132 match_evidence: None,
133 }
134 }
135}
136
137#[derive(Debug, Default)]
150struct ContextWindowUnion {
151 current_session: Option<String>,
152 last_anchor_seq: Option<i64>,
153 last_emitted_seq: Option<i64>,
154 pending: BTreeMap<i64, PendingContextRow>,
155}
156
157impl ContextWindowUnion {
158 fn push_anchor(
159 &mut self,
160 anchor: &MessageSearchHit,
161 window: &[MessageHit],
162 ) -> Result<Vec<PendingContextRow>> {
163 let session_id = anchor.message().session_id.as_str();
164 let mut ready = Vec::new();
165 if self.current_session.as_deref() != Some(session_id) {
166 if let Some(current_session) = self.current_session.as_deref() {
167 anyhow::ensure!(
168 current_session < session_id,
169 "batched context anchors are not ordered by session_id: {session_id:?} followed {current_session:?}"
170 );
171 }
172 ready.append(&mut self.drain_pending());
173 self.current_session = Some(session_id.to_owned());
174 self.last_anchor_seq = None;
175 self.last_emitted_seq = None;
176 }
177 if let Some(last_anchor_seq) = self.last_anchor_seq {
178 anyhow::ensure!(
179 anchor.seq > last_anchor_seq,
180 "batched context anchors are not strictly ordered by sequence in session {session_id:?}: {} followed {last_anchor_seq}",
181 anchor.seq
182 );
183 }
184
185 for hit in window {
186 anyhow::ensure!(
187 hit.session_id == session_id,
188 "context row session {:?} does not match anchor session {session_id:?}",
189 hit.session_id
190 );
191 if self
192 .last_emitted_seq
193 .is_none_or(|last_emitted| hit.seq > last_emitted)
194 {
195 self.pending
196 .entry(hit.seq)
197 .or_insert_with(|| PendingContextRow::surrounding(hit.clone()));
198 }
199 }
200
201 anyhow::ensure!(
202 self.last_emitted_seq
203 .is_none_or(|last_emitted| anchor.seq > last_emitted),
204 "batched context anchor {} in session {session_id:?} was already emitted",
205 anchor.seq
206 );
207 let matched = self
208 .pending
209 .entry(anchor.seq)
210 .or_insert_with(|| PendingContextRow::surrounding(anchor.message().clone()));
211 matched.is_match = true;
212 matched.match_evidence = anchor.match_evidence().cloned();
213
214 while self
215 .pending
216 .first_key_value()
217 .is_some_and(|(seq, _)| *seq <= anchor.seq)
218 {
219 let (_, row) = self
220 .pending
221 .pop_first()
222 .expect("first_key_value proved a pending context row exists");
223 self.last_emitted_seq = Some(row.hit.seq);
224 ready.push(row);
225 }
226 self.last_anchor_seq = Some(anchor.seq);
227 Ok(ready)
228 }
229
230 fn finish(mut self) -> Vec<PendingContextRow> {
231 self.drain_pending()
232 }
233
234 fn drain_pending(&mut self) -> Vec<PendingContextRow> {
235 std::mem::take(&mut self.pending).into_values().collect()
236 }
237}
238
239impl Row for ContextRow {
240 fn headers() -> &'static [&'static str] {
241 &[
242 "session", "provider", "seq", "role", "tool", "ts", "match", "content",
243 ]
244 }
245 fn cells(&self) -> Vec<String> {
246 vec![
247 self.hit.session_id.clone(),
248 self.hit.provider.as_str().to_string(),
249 self.hit.seq.to_string(),
250 self.hit.role.as_str().to_string(),
251 self.hit.tool_name.clone().unwrap_or_default(),
252 self.hit.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
253 self.match_evidence
254 .as_ref()
255 .map(format_match_evidence)
256 .unwrap_or_else(|| {
257 if self.is_match {
258 "*".into()
259 } else {
260 String::new()
261 }
262 }),
263 truncate_for_display(&self.hit.content, TABLE_CONTENT_CHARS),
264 ]
265 }
266}
267
268impl ContextRow {
269 fn from_match(hit: MessageHit, is_match: bool, lines_per_message: i64) -> Self {
270 let content = select_message_lines(&hit.content, lines_per_message);
271 Self {
272 hit: MessageHit { content, ..hit },
273 is_match,
274 match_evidence: None,
275 }
276 }
277
278 fn from_hit(
279 hit: MessageHit,
280 matched_rows: &HashSet<(String, i64)>,
281 lines_per_message: i64,
282 ) -> Self {
283 let key = (hit.session_id.clone(), hit.seq);
284 Self::from_match(hit, matched_rows.contains(&key), lines_per_message)
285 }
286
287 fn with_match_evidence(
288 mut self,
289 evidence: Option<crate::message_search::MessageMatchEvidence>,
290 ) -> Self {
291 self.match_evidence = evidence;
292 self
293 }
294}
295
296#[derive(Serialize)]
297struct MessageHitWithRefs {
298 #[serde(flatten)]
299 hit: MessageHit,
300 ref_summary: String,
301 refs: Vec<MessageRef>,
302}
303
304#[derive(Serialize)]
305struct MessageSearchHitWithRefs {
306 #[serde(flatten)]
307 hit: MessageSearchHit,
308 ref_summary: String,
309 refs: Vec<MessageRef>,
310}
311
312impl Row for MessageSearchHitWithRefs {
313 fn headers() -> &'static [&'static str] {
314 &[
315 "session", "provider", "seq", "role", "tool", "ts", "match", "refs", "content",
316 ]
317 }
318
319 fn cells(&self) -> Vec<String> {
320 let mut cells = self.hit.cells();
321 cells.insert(cells.len() - 1, self.ref_summary.clone());
322 cells
323 }
324}
325
326impl Row for MessageHitWithRefs {
327 fn headers() -> &'static [&'static str] {
328 &[
329 "session", "provider", "seq", "role", "tool", "ts", "refs", "content",
330 ]
331 }
332
333 fn cells(&self) -> Vec<String> {
334 vec![
335 self.hit.session_id.clone(),
336 self.hit.provider.as_str().to_string(),
337 self.hit.seq.to_string(),
338 self.hit.role.as_str().to_string(),
339 self.hit.tool_name.clone().unwrap_or_default(),
340 self.hit.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
341 self.ref_summary.clone(),
342 truncate_for_display(&self.hit.content, TABLE_CONTENT_CHARS),
343 ]
344 }
345}
346
347#[derive(Clone, Serialize)]
348struct ContextRowWithRefs {
349 #[serde(flatten)]
350 row: ContextRow,
351 ref_summary: String,
352 refs: Vec<MessageRef>,
353}
354
355impl Row for ContextRowWithRefs {
356 fn headers() -> &'static [&'static str] {
357 &[
358 "session", "provider", "seq", "role", "tool", "ts", "match", "refs", "content",
359 ]
360 }
361
362 fn cells(&self) -> Vec<String> {
363 vec![
364 self.row.hit.session_id.clone(),
365 self.row.hit.provider.as_str().to_string(),
366 self.row.hit.seq.to_string(),
367 self.row.hit.role.as_str().to_string(),
368 self.row.hit.tool_name.clone().unwrap_or_default(),
369 self.row
370 .hit
371 .ts
372 .map(|ts| ts.to_rfc3339())
373 .unwrap_or_default(),
374 self.row
375 .match_evidence
376 .as_ref()
377 .map(format_match_evidence)
378 .unwrap_or_else(|| {
379 if self.row.is_match {
380 "*".into()
381 } else {
382 String::new()
383 }
384 }),
385 self.ref_summary.clone(),
386 truncate_for_display(&self.row.hit.content, TABLE_CONTENT_CHARS),
387 ]
388 }
389}
390
391fn format_match_evidence(evidence: &crate::message_search::MessageMatchEvidence) -> String {
392 let (focus_start, focus_end, boundary) = match &evidence.markers {
393 crate::message_search::MessageMatchViewMarkers::Characters { ranges, .. } => {
394 let first = ranges
395 .first()
396 .copied()
397 .unwrap_or(crate::message_search::ViewCharRange {
398 view_start_char: 0,
399 view_end_char_exclusive: 0,
400 });
401 (first.view_start_char, first.view_end_char_exclusive, None)
402 }
403 crate::message_search::MessageMatchViewMarkers::Boundary { view_at_char } => {
404 (*view_at_char, *view_at_char, Some(*view_at_char))
405 }
406 };
407 let total = evidence.view_text.chars().count();
408 let caret_chars = usize::from(boundary.is_some());
409 let rendered = if total + caret_chars <= TABLE_CONTENT_CHARS {
410 let mut rendered = evidence.view_text.clone();
411 if let Some(boundary) = boundary {
412 insert_caret(&mut rendered, boundary);
413 }
414 rendered
415 } else {
416 let visible_chars = TABLE_CONTENT_CHARS.saturating_sub(caret_chars + 6).max(1);
420 let focus_width = focus_end.saturating_sub(focus_start);
421 let start = focus_start
422 .saturating_sub(visible_chars.saturating_sub(focus_width) / 2)
423 .min(total.saturating_sub(visible_chars));
424 let end = (start + visible_chars).min(total);
425 let mut rendered = evidence
426 .view_text
427 .chars()
428 .skip(start)
429 .take(end - start)
430 .collect::<String>();
431 if let Some(boundary) = boundary {
432 insert_caret(&mut rendered, boundary.saturating_sub(start));
433 }
434 if start > 0 {
435 rendered.insert_str(0, "...");
436 }
437 if end < total {
438 rendered.push_str("...");
439 }
440 rendered
441 };
442 crate::util::compact_whitespace(&rendered)
443}
444
445fn insert_caret(rendered: &mut String, at_char: usize) {
446 let byte = rendered
447 .char_indices()
448 .nth(at_char)
449 .map(|(byte, _)| byte)
450 .unwrap_or(rendered.len());
451 rendered.insert(byte, '^');
452}
453
454impl ContextRowWithRefs {
455 fn from_hit(
456 hit: MessageHit,
457 matched_rows: &HashSet<(String, i64)>,
458 lines_per_message: i64,
459 ) -> Self {
460 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
462 Self {
463 row: ContextRow::from_hit(hit, matched_rows, lines_per_message),
464 ref_summary: ref_summary(&refs),
465 refs,
466 }
467 }
468}
469
470trait ContextBatchRow: Row {
471 fn from_pending(row: PendingContextRow, lines_per_message: i64) -> Self;
472}
473
474impl ContextBatchRow for ContextRow {
475 fn from_pending(row: PendingContextRow, lines_per_message: i64) -> Self {
476 ContextRow::from_match(row.hit, row.is_match, lines_per_message)
477 .with_match_evidence(row.match_evidence)
478 }
479}
480
481impl ContextBatchRow for ContextRowWithRefs {
482 fn from_pending(row: PendingContextRow, lines_per_message: i64) -> Self {
483 let refs = extract_refs_from_text(&row.hit.content, row.hit.tool_name.as_deref());
485 Self {
486 row: ContextRow::from_pending(row, lines_per_message),
487 ref_summary: ref_summary(&refs),
488 refs,
489 }
490 }
491}
492
493#[derive(Debug, Subcommand)]
494pub enum MessagesCmd {
495 Search(Box<MessageSearchArgs>),
497 Get(MessageGetArgs),
499 Timeline(TimelineArgs),
501 Evidence(MessageEvidenceArgs),
505}
506
507#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
508pub enum CliMessageQueryMode {
509 #[default]
510 Literal,
511 Regex,
512 Fuzzy,
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum CliFieldViewChars {
517 NoCharLimit,
518 MaxChars(NonZeroUsize),
519}
520
521impl std::str::FromStr for CliFieldViewChars {
522 type Err = String;
523
524 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
525 if value == "no-char-limit" {
526 return Ok(Self::NoCharLimit);
527 }
528 value
529 .parse::<NonZeroUsize>()
530 .map(Self::MaxChars)
531 .map_err(|_| "expected no-char-limit or a positive character count".to_string())
532 }
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub enum CliMatchViewChars {
537 Minimal,
538 MaxChars(NonZeroUsize),
539}
540
541impl std::str::FromStr for CliMatchViewChars {
542 type Err = String;
543
544 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
545 if value == "minimal" {
546 return Ok(Self::Minimal);
547 }
548 value
549 .parse::<NonZeroUsize>()
550 .map(Self::MaxChars)
551 .map_err(|_| "expected minimal or a positive character count".to_string())
552 }
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556pub enum CliMessageSearchInclude {
557 None,
558 Value(MessageSearchInclude),
559}
560
561impl std::str::FromStr for CliMessageSearchInclude {
562 type Err = String;
563
564 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
565 let include = match value {
566 "none" => return Ok(Self::None),
567 "normalized_session_metadata" => MessageSearchInclude::NormalizedSessionMetadata,
568 "parsed_references" => MessageSearchInclude::ParsedReferences,
569 "raw_provider_metadata" => MessageSearchInclude::RawProviderMetadata,
570 "runtime_diagnostics" => MessageSearchInclude::RuntimeDiagnostics,
571 _ => {
572 return Err(
573 "expected none, normalized_session_metadata, parsed_references, raw_provider_metadata, or runtime_diagnostics"
574 .to_string(),
575 )
576 }
577 };
578 Ok(Self::Value(include))
579 }
580}
581
582const QUERY_HEADING: &str = "Query (what to find)";
588const FILTER_HEADING: &str = "Filters (which messages)";
589const RESULTS_HEADING: &str = "Result window and context (how many, from where)";
590const PRESENTATION_HEADING: &str =
591 "Presentation and output (how matches are shown; matching stays the same)";
592const ADVANCED_HEADING: &str = "Advanced (purpose bundles, receipts, self-description)";
593
594#[derive(Debug, Args)]
595#[command(group(
596 ArgGroup::new("search_request")
597 .args([
598 "positional_query",
599 "query_arg",
600 "role",
601 "kind",
602 "kinds",
603 "field",
604 "argument_path",
605 "providers",
606 "query_mode",
607 "session_id",
608 "workspace_path",
609 "transcript_path",
610 "exclude_workspace_paths",
611 "exclude_transcript_paths",
612 "exclude_sessions",
613 "tool_name_contains",
614 "since",
615 "until",
616 "when",
617 "seq_from",
618 "seq_to",
619 "include",
620 "include_compaction",
621 "match_window",
622 "purpose",
623 "purpose_version",
624 "receipt_level",
625 "context",
626 "context_before",
627 "context_after",
628 "limit",
629 "all_results",
630 "offset",
631 "lines_per_message",
632 "detail",
633 "field_view_chars",
634 "match_view_chars",
635 ])
636 .multiple(true)
637))]
638pub struct MessageSearchArgs {
639 #[arg(value_name = "QUERY", conflicts_with = "query_arg")]
643 pub positional_query: Option<String>,
644 #[arg(
647 help_heading = QUERY_HEADING,
648 short = 'e',
649 long = "query",
650 value_name = "QUERY",
651 allow_hyphen_values = true
652 )]
653 pub query_arg: Option<String>,
654 #[arg(help_heading = FILTER_HEADING, long = "role", value_enum)]
657 pub role: Option<Role>,
658 #[arg(help_heading = FILTER_HEADING, long, value_enum)]
661 pub kind: Option<MessageKind>,
662 #[arg(help_heading = FILTER_HEADING, long, value_enum, num_args = 1.., value_delimiter = ',', conflicts_with = "kind")]
667 pub kinds: Vec<MessageKind>,
668 #[arg(help_heading = QUERY_HEADING, long, value_enum, default_value_t = SearchField::Content)]
670 pub field: SearchField,
671 #[arg(help_heading = QUERY_HEADING, long)]
674 pub argument_path: Option<String>,
675 #[arg(help_heading = FILTER_HEADING, long = "provider", value_enum, value_delimiter = ',', action = clap::ArgAction::Append)]
678 pub providers: Vec<Provider>,
679 #[arg(help_heading = QUERY_HEADING, long, value_enum, default_value_t = CliMessageQueryMode::Literal)]
681 pub query_mode: CliMessageQueryMode,
682 #[arg(help_heading = FILTER_HEADING, long)]
684 pub session_id: Option<String>,
685 #[arg(help_heading = FILTER_HEADING, long)]
689 pub workspace_path: Option<String>,
690 #[arg(help_heading = FILTER_HEADING, long)]
694 pub transcript_path: Option<String>,
695 #[arg(help_heading = FILTER_HEADING, long = "exclude-workspace-path")]
698 pub exclude_workspace_paths: Vec<String>,
699 #[arg(help_heading = FILTER_HEADING, long = "exclude-transcript-path")]
702 pub exclude_transcript_paths: Vec<String>,
703 #[arg(help_heading = FILTER_HEADING, long = "exclude-session")]
705 pub exclude_sessions: Vec<String>,
706 #[arg(help_heading = QUERY_HEADING, long)]
710 pub tool_name_contains: Option<String>,
711 #[command(flatten)]
712 pub dates: DateRange,
713 #[arg(help_heading = FILTER_HEADING, long)]
716 pub seq_from: Option<i64>,
717 #[arg(help_heading = FILTER_HEADING, long)]
720 pub seq_to: Option<i64>,
721 #[arg(help_heading = FILTER_HEADING, long, default_value_t = true, action = clap::ArgAction::Set)]
723 pub include_compaction: bool,
724 #[arg(help_heading = RESULTS_HEADING, long, value_enum)]
728 pub match_window: Option<MatchWindow>,
729 #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
732 pub context: Option<i64>,
733 #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
737 pub context_before: Option<i64>,
738 #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
742 pub context_after: Option<i64>,
743 #[arg(help_heading = RESULTS_HEADING, long, conflicts_with = "all_results")]
749 pub limit: Option<usize>,
750 #[arg(help_heading = RESULTS_HEADING, long, conflicts_with = "limit")]
752 pub all_results: bool,
753 #[arg(help_heading = RESULTS_HEADING, long, default_value_t = 0)]
756 pub offset: usize,
757 #[arg(help_heading = PRESENTATION_HEADING, long, value_delimiter = ',', action = clap::ArgAction::Append)]
763 pub include: Option<Vec<CliMessageSearchInclude>>,
764 #[arg(help_heading = PRESENTATION_HEADING, long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
766 pub lines_per_message: Option<i64>,
767 #[arg(help_heading = PRESENTATION_HEADING, long, value_enum, conflicts_with_all = ["lines_per_message", "field_view_chars", "match_view_chars"])]
772 pub detail: Option<DetailLevel>,
773 #[arg(help_heading = PRESENTATION_HEADING, long)]
776 pub field_view_chars: Option<CliFieldViewChars>,
777 #[arg(help_heading = PRESENTATION_HEADING, long)]
780 pub match_view_chars: Option<CliMatchViewChars>,
781 #[arg(help_heading = ADVANCED_HEADING, long)]
784 pub purpose: Option<String>,
785 #[arg(help_heading = ADVANCED_HEADING, long, requires = "purpose")]
788 pub purpose_version: Option<std::num::NonZeroU32>,
789 #[arg(help_heading = ADVANCED_HEADING, long, value_enum)]
792 pub receipt_level: Option<ReceiptLevel>,
793 #[arg(help_heading = ADVANCED_HEADING, long, conflicts_with = "search_request")]
796 pub describe: bool,
797 #[arg(help_heading = ADVANCED_HEADING, long, value_enum, requires = "describe")]
799 pub describe_surface: Option<SearchSurface>,
800 #[arg(help_heading = PRESENTATION_HEADING, long, value_enum)]
804 pub format: Option<OutputFormat>,
805}
806
807impl MessageSearchArgs {
808 fn output_format(&self) -> Result<OutputFormat> {
809 match (self.describe, self.format) {
810 (true, None | Some(OutputFormat::Json)) => Ok(OutputFormat::Json),
811 (true, Some(format)) => bail!(
812 "--describe emits one structured specification and requires --format json; got --format {}",
813 format.as_str()
814 ),
815 (false, Some(format)) => Ok(format),
816 (false, None) => Ok(OutputFormat::Table),
817 }
818 }
819}
820
821#[derive(Debug, Args)]
822pub struct MessageGetArgs {
823 pub id: String,
825 #[arg(long)]
829 pub seq: Option<i64>,
830 #[arg(long, default_value_t = 0, value_parser = parse_context_count)]
833 pub context: i64,
834 #[arg(long)]
836 pub refs: bool,
837 #[arg(long = "role", value_enum)]
839 pub role: Option<Role>,
840 #[command(flatten)]
841 pub dates: DateRange,
842 #[arg(long, default_value_t = 0)]
846 pub limit: usize,
847 #[arg(long, value_enum, default_value_t = ReadOrder::Oldest)]
851 pub order: ReadOrder,
852 #[arg(long, default_value_t = 0)]
856 pub offset: usize,
857 #[arg(long)]
861 pub seq_from: Option<i64>,
862 #[arg(long)]
864 pub seq_to: Option<i64>,
865 #[arg(long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
867 pub lines_per_message: Option<i64>,
868 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
874 pub format: OutputFormat,
875}
876
877#[derive(Debug, Args)]
878pub struct TimelineArgs {
879 pub id: String,
881 #[arg(long = "role", value_enum)]
883 pub role: Option<Role>,
884 #[arg(long, conflicts_with = "regex")]
887 pub grep: Option<String>,
888 #[arg(long)]
891 pub regex: Option<String>,
892 #[arg(long)]
894 pub refs: bool,
895 #[arg(long)]
897 pub seq_from: Option<i64>,
898 #[arg(long)]
900 pub seq_to: Option<i64>,
901 #[arg(long)]
903 pub no_compaction: bool,
904 #[arg(long, value_enum, num_args = 1.., value_delimiter = ',')]
907 pub kinds: Vec<MessageKind>,
908 #[command(flatten)]
909 pub dates: DateRange,
910 #[arg(long, default_value_t = 0)]
915 pub limit: usize,
916 #[arg(long, value_enum, default_value_t = ReadOrder::Oldest)]
920 pub order: ReadOrder,
921 #[arg(long, default_value_t = 0)]
924 pub offset: usize,
925 #[arg(long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
927 pub lines_per_message: Option<i64>,
928 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
934 pub format: OutputFormat,
935}
936
937#[derive(Debug, Args)]
938pub struct MessageEvidenceArgs {
939 pub id: String,
941 #[arg(long, value_parser = crate::cli::parse_positive_usize)]
944 pub preview_chars: Option<usize>,
945 #[arg(long, allow_hyphen_values = true)]
948 pub summary_items: Option<i64>,
949 #[arg(long, value_enum)]
951 pub include: Vec<EvidenceInclude>,
952 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
955 pub format: OutputFormat,
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
959pub enum EvidenceInclude {
960 TimeProfile,
961}
962
963pub fn run(db: &Db, cmd: &MessagesCmd, config: &Config) -> Result<()> {
964 let cli = &config.cli;
965 match cmd {
966 MessagesCmd::Search(args) => run_search(db, args, config),
967 MessagesCmd::Get(args) => {
968 let lines_per_message = args.lines_per_message.unwrap_or(cli.lines_per_message);
969 let session = db.resolve_session_record(&args.id)?;
970 if let Some(seq) = args.seq {
971 if args.role.is_some()
972 || args.limit > 0
973 || args.order != ReadOrder::Oldest
974 || args.offset > 0
975 || args.seq_from.is_some()
976 || args.seq_to.is_some()
977 || args.dates.since.is_some()
978 || args.dates.until.is_some()
979 || args.dates.when.is_some()
980 {
981 bail!(
982 "--seq selects one message by sequence, so it takes only --context; \
983 drop --role, --limit, --order, --offset, --seq-from, --seq-to, --since, \
984 --until, and --when, or omit --seq to read a range of messages"
985 );
986 }
987 let context = args.context;
988 let matched_rows: HashSet<(String, i64)> =
989 HashSet::from([(session.id.clone(), seq)]);
990 let rows = db.message_context(&session.id, seq, context, context)?;
991 if args.refs {
992 let rows = rows
993 .into_iter()
994 .map(|ctx| {
995 ContextRowWithRefs::from_hit(ctx, &matched_rows, lines_per_message)
996 })
997 .collect::<Vec<_>>();
998 return emit(&rows, args.format);
999 }
1000 let rows = rows
1001 .into_iter()
1002 .map(|ctx| ContextRow::from_hit(ctx, &matched_rows, lines_per_message))
1003 .collect::<Vec<_>>();
1004 return emit(&rows, args.format);
1005 }
1006 if args.context != 0 {
1007 bail!("--context requires --seq");
1008 }
1009 validate_seq_bounds(args.seq_from, args.seq_to)?;
1010 let (since, until) = args.dates.resolve_now()?;
1011 let filters = MessageFilters {
1012 role: args.role,
1013 session_id: Some(session.id),
1014 since,
1015 until,
1016 seq_from: args.seq_from,
1017 seq_to: args.seq_to,
1018 limit: args.limit,
1019 offset: args.offset,
1020 ..Default::default()
1021 };
1022 let hits = db.read_session_messages(&filters, args.order.to_message_order())?;
1023 emit_message_hits(&hits, args.refs, args.format, lines_per_message)
1024 }
1025 MessagesCmd::Timeline(args) => {
1026 let session = db.resolve_session_record(&args.id)?;
1027 validate_seq_bounds(args.seq_from, args.seq_to)?;
1028 let (since, until) = args.dates.resolve_now()?;
1029 let query = args.regex.as_deref().or(args.grep.as_deref()).unwrap_or("");
1030 let filters = MessageFilters {
1031 role: args.role,
1032 session_id: Some(session.id),
1033 since,
1034 until,
1035 seq_from: args.seq_from,
1036 seq_to: args.seq_to,
1037 limit: args.limit,
1038 offset: args.offset,
1039 match_mode: if args.regex.is_some() {
1040 MessageSearchMode::Regex
1041 } else {
1042 MessageSearchMode::Literal
1043 },
1044 no_compaction: args.no_compaction,
1045 kinds: (!args.kinds.is_empty()).then(|| args.kinds.clone()),
1046 ..Default::default()
1047 };
1048 let order = args.order.to_message_order();
1051 let mut hits = db.search_messages_ordered(query, &filters, order)?;
1052 if order == crate::db::MessageOrder::NewestFirst {
1053 hits.reverse();
1054 }
1055 emit_message_hits(
1056 &hits,
1057 args.refs,
1058 args.format,
1059 args.lines_per_message.unwrap_or(cli.lines_per_message),
1060 )
1061 }
1062 MessagesCmd::Evidence(args) => {
1063 let options = InspectionOptions {
1064 preview_chars: args
1065 .preview_chars
1066 .unwrap_or(cli.evidence_preview_chars)
1067 .max(1),
1068 evidence_window: crate::inspect::EvidenceWindow::from_signed_items(
1069 args.summary_items.unwrap_or(cli.summary_items),
1070 )?,
1071 include_time_profile: args.include.contains(&EvidenceInclude::TimeProfile),
1072 };
1073 let inspection = CatalogService::new(db).inspect(&args.id, options)?;
1074 emit_inspection(&inspection, options, args.format)
1075 }
1076 }
1077}
1078
1079pub(crate) fn run_index_independent(cmd: &MessagesCmd, config: &Config) -> Result<bool> {
1083 let MessagesCmd::Search(args) = cmd else {
1084 return Ok(false);
1085 };
1086 if !args.describe {
1087 return Ok(false);
1088 }
1089 let format = args.output_format()?;
1090 debug_assert_eq!(format, OutputFormat::Json);
1091 emit_message_search_spec(config, args.describe_surface.unwrap_or(SearchSurface::Cli))?;
1092 Ok(true)
1093}
1094
1095fn emit_message_search_spec(config: &Config, surface: SearchSurface) -> Result<()> {
1096 let specification = MessageService::message_search_spec_for_config(config, surface)?;
1097 let stdout = io::stdout();
1098 let mut out = stdout.lock();
1099 serde_json::to_writer_pretty(&mut out, &specification)?;
1100 writeln!(out)?;
1101 out.flush()?;
1102 Ok(())
1103}
1104
1105fn run_search(db: &Db, args: &MessageSearchArgs, config: &Config) -> Result<()> {
1106 let format = args.output_format()?;
1107 if args.describe {
1108 emit_message_search_spec(config, args.describe_surface.unwrap_or(SearchSurface::Cli))?;
1109 return Ok(());
1110 }
1111 let (since, until) = args.dates.resolve_now()?;
1112 let query_text = args
1113 .query_arg
1114 .as_deref()
1115 .or(args.positional_query.as_deref())
1116 .unwrap_or("");
1117 let query = match (args.query_mode, query_text.is_empty()) {
1118 (CliMessageQueryMode::Literal, true) => MessageQuery::All,
1119 (CliMessageQueryMode::Literal, false) => MessageQuery::literal(query_text)?,
1120 (CliMessageQueryMode::Regex, false) => MessageQuery::regex(query_text)?,
1121 (CliMessageQueryMode::Fuzzy, false) => MessageQuery::fuzzy(query_text)?,
1122 (mode, true) => bail!("--query-mode {mode:?} requires QUERY or --query <QUERY>"),
1123 };
1124 if args.argument_path.is_some() && args.field != SearchField::ToolArgument {
1125 bail!("argument_path requires field='tool_argument'");
1126 }
1127 let target = match args.field {
1128 SearchField::Content => MessageTarget::content(),
1129 SearchField::ToolName => MessageTarget::tool_name(),
1130 SearchField::ToolArgument => {
1131 MessageTarget::tool_argument(args.argument_path.clone().unwrap_or_default())?
1132 }
1133 };
1134 let mut builder = MessageSearchRequest::builder(query, target)
1135 .time(RequestedTimeRange::new(since, until)?)
1136 .include_compaction(args.include_compaction)
1137 .extent(if args.all_results {
1138 RequestedExtent::all_results_from(args.offset)
1139 } else {
1140 RequestedExtent::page(args.limit, args.offset)?
1141 });
1142 if let Some(role) = args.role {
1143 builder = builder.role(role);
1144 }
1145 if let Some(kind) = args.kind {
1148 builder = builder.kind(kind);
1149 } else if !args.kinds.is_empty() {
1150 builder = builder.kinds(args.kinds.clone());
1151 }
1152 if !args.providers.is_empty() {
1153 builder = builder.providers(args.providers.clone())?;
1154 }
1155 if let Some(session) = &args.session_id {
1156 builder = builder.session_id(session)?;
1157 }
1158 if let Some(path) = &args.workspace_path {
1159 builder = builder.workspace_path_prefix(path)?;
1160 }
1161 if let Some(path) = &args.transcript_path {
1162 builder = builder.transcript_path_prefix(path)?;
1163 }
1164 for path in &args.exclude_workspace_paths {
1165 builder = builder.exclude_workspace_path_prefix(path)?;
1166 }
1167 for path in &args.exclude_transcript_paths {
1168 builder = builder.exclude_transcript_path_prefix(path)?;
1169 }
1170 for session in &args.exclude_sessions {
1171 builder = builder.exclude_session_id(session)?;
1172 }
1173 if args.seq_from.is_some() || args.seq_to.is_some() {
1174 builder = builder.sequence(SequenceRange::new(args.seq_from, args.seq_to)?);
1175 }
1176 if let Some(tool) = &args.tool_name_contains {
1177 builder = builder.tool_name_contains(tool)?;
1178 }
1179 if let Some(window) = args.match_window {
1180 builder = builder.match_window(window);
1181 }
1182 if args.context.is_some() || args.context_before.is_some() || args.context_after.is_some() {
1183 let symmetric = args.context.unwrap_or(0) as usize;
1184 builder = builder.context(ContextWindow::new(
1185 args.context_before
1186 .map(|value| value as usize)
1187 .unwrap_or(symmetric),
1188 args.context_after
1189 .map(|value| value as usize)
1190 .unwrap_or(symmetric),
1191 ));
1192 }
1193 if let Some(includes) = &args.include {
1194 let has_none = includes.contains(&CliMessageSearchInclude::None);
1195 if has_none && includes.len() != 1 {
1196 bail!("--include none cannot be combined with another include; use none alone for the semantic core");
1197 }
1198 builder = builder.includes(includes.iter().filter_map(|include| match include {
1199 CliMessageSearchInclude::None => None,
1200 CliMessageSearchInclude::Value(value) => Some(*value),
1201 }));
1202 }
1203 if let Some(lines) = args.lines_per_message {
1204 builder = builder.message_lines(LineWindow::from_signed(lines)?);
1205 }
1206 if let Some(detail) = args.detail {
1207 builder = builder.detail(detail);
1208 }
1209 if let Some(view) = args.field_view_chars {
1210 builder = builder.field_view(match view {
1211 CliFieldViewChars::NoCharLimit => FieldViewBudget::NoCharLimit,
1212 CliFieldViewChars::MaxChars(max_chars) => FieldViewBudget::MaxChars { max_chars },
1213 });
1214 }
1215 if let Some(view) = args.match_view_chars {
1216 builder = builder.match_view(match view {
1217 CliMatchViewChars::Minimal => MatchViewBudget::MinimalSpan,
1218 CliMatchViewChars::MaxChars(max_chars) => MatchViewBudget::MaxChars { max_chars },
1219 });
1220 }
1221 if let Some(purpose) = &args.purpose {
1222 builder = builder.purpose(PurposeSelection::new(purpose, args.purpose_version)?);
1223 }
1224 if let Some(receipt) = args.receipt_level {
1225 builder = builder.receipt_level(receipt);
1226 }
1227
1228 let request = builder.build()?;
1229 let service = MessageService::new(config, db, SearchSurface::Cli);
1230 let plan = service.plan(request.clone())?;
1231 if matches!(plan.extent(), ResolvedExtent::AllResults { .. }) && format == OutputFormat::Jsonl {
1232 let batches = service.search_batches(
1233 request,
1234 NonZeroUsize::new(CLI_MESSAGE_SEARCH_BATCH_ROWS)
1235 .expect("CLI message-search batch size is positive"),
1236 )?;
1237 return emit_message_search_jsonl_batches(batches);
1238 }
1239 if matches!(plan.extent(), ResolvedExtent::AllResults { .. })
1240 && matches!(
1241 format,
1242 OutputFormat::Table | OutputFormat::Csv | OutputFormat::Plain
1243 )
1244 {
1245 let batches = service.search_batches(
1246 request,
1247 NonZeroUsize::new(CLI_MESSAGE_SEARCH_BATCH_ROWS)
1248 .expect("CLI message-search batch size is positive"),
1249 )?;
1250 return emit_message_search_human_batches(batches, format);
1251 }
1252 let response = service.search(request)?;
1253 if let Some(explain) = response.search_explanation() {
1254 let has_content_query = !query_text.is_empty();
1255 eprintln!("{}", explain.summary(has_content_query));
1256 }
1257 if let Some(origins) = response.parameter_origins() {
1258 eprintln!("[origins] {}", serde_json::to_string(origins)?);
1259 }
1260 if let Some(next_offset) = response.page().next_offset() {
1261 if args.query_mode == CliMessageQueryMode::Fuzzy {
1262 eprintln!(
1263 "[more] additional fuzzy matches are available; rerun the same search with \
1264 --offset {next_offset}"
1265 );
1266 } else {
1267 eprintln!(
1268 "[more] additional matches are available; rerun the same search with \
1269 --offset {next_offset}, or pass --all-results to return every eligible match"
1270 );
1271 }
1272 }
1273 if matches!(format, OutputFormat::Json | OutputFormat::Jsonl) {
1274 return emit_message_search_machine_response(&response, format);
1275 }
1276 let hits = response.hits();
1277 let include_refs = response.presentation().include_refs();
1278 let lines_per_message = response.presentation().message_lines().to_signed()?;
1279 if response.context_windows().is_empty() {
1280 return emit_message_search_hits(hits, include_refs, format, lines_per_message);
1281 }
1282
1283 let matched: HashSet<(String, i64)> =
1284 hits.iter().map(|h| (h.session_id.clone(), h.seq)).collect();
1285 let match_evidence = hits
1286 .iter()
1287 .filter_map(|hit| {
1288 hit.match_evidence()
1289 .cloned()
1290 .map(|evidence| ((hit.session_id.clone(), hit.seq), evidence))
1291 })
1292 .collect::<BTreeMap<_, _>>();
1293 let context_hits = context_hits_in_encounter_order(response.context_windows());
1294 if include_refs {
1295 let windowed = context_hits
1296 .into_iter()
1297 .map(|ctx| {
1298 let evidence = match_evidence
1299 .get(&(ctx.session_id.clone(), ctx.seq))
1300 .cloned();
1301 let mut row = ContextRowWithRefs::from_hit(ctx, &matched, lines_per_message);
1302 row.row = row.row.with_match_evidence(evidence);
1303 row
1304 })
1305 .collect::<Vec<_>>();
1306 emit(&windowed, format)
1307 } else {
1308 let windowed = context_hits
1309 .into_iter()
1310 .map(|ctx| {
1311 let evidence = match_evidence
1312 .get(&(ctx.session_id.clone(), ctx.seq))
1313 .cloned();
1314 ContextRow::from_hit(ctx, &matched, lines_per_message).with_match_evidence(evidence)
1315 })
1316 .collect::<Vec<_>>();
1317 emit(&windowed, format)
1318 }
1319}
1320
1321fn context_hits_in_encounter_order(windows: &[Vec<MessageHit>]) -> Vec<MessageHit> {
1322 let mut seen = HashSet::new();
1323 windows
1324 .iter()
1325 .flatten()
1326 .filter(|hit| seen.insert((hit.session_id.clone(), hit.seq)))
1327 .cloned()
1328 .collect()
1329}
1330
1331fn emit_message_search_jsonl_batches(mut batches: crate::MessageSearchBatches) -> Result<()> {
1332 let stdout = io::stdout();
1333 let mut out = stdout.lock();
1334 if let Err(error) = write_message_search_jsonl_batches(&mut batches, &mut out) {
1335 return Err(close_batches_after_output_error(&mut batches, error));
1336 }
1337 out.flush()?;
1338 Ok(())
1339}
1340
1341fn close_batches_after_output_error(
1342 batches: &mut crate::MessageSearchBatches,
1343 output_error: anyhow::Error,
1344) -> anyhow::Error {
1345 match batches.close() {
1346 Ok(()) => output_error,
1347 Err(cleanup_error) => cleanup_error.context(format!(
1348 "message-search output failed before its batch producer could be cleaned up: {output_error:#}"
1349 )),
1350 }
1351}
1352
1353fn write_message_search_jsonl_batches<W: Write>(
1354 batches: &mut crate::MessageSearchBatches,
1355 out: &mut W,
1356) -> Result<()> {
1357 #[derive(Serialize)]
1358 struct SearchMetadata<'a> {
1359 #[serde(rename = "type")]
1360 record_type: &'static str,
1361 response_schema_version: u32,
1362 coordinate_unit: crate::message_search::CoordinateUnit,
1363 effective_request: &'a crate::message_search::ResolvedMessageSearchRequest,
1364 #[serde(skip_serializing_if = "Option::is_none")]
1365 included: Option<SearchMetadataIncluded<'a>>,
1366 }
1367
1368 #[derive(Serialize)]
1369 struct SearchMetadataIncluded<'a> {
1370 runtime_diagnostics: &'a crate::message_search::MessageSearchRuntimeDiagnostics,
1371 }
1372
1373 #[derive(Serialize)]
1374 struct SearchIncludedRecord<'a> {
1375 #[serde(rename = "type")]
1376 record_type: &'static str,
1377 included: &'a crate::message_search::MessageSearchIncludedData,
1378 }
1379
1380 #[derive(Serialize)]
1381 struct SearchResultRecord<'a> {
1382 #[serde(rename = "type")]
1383 record_type: &'static str,
1384 index: usize,
1385 result: crate::message_search::MessageSearchResultDocument<'a>,
1386 }
1387
1388 #[derive(Serialize)]
1389 struct SearchEnd<'a> {
1390 #[serde(rename = "type")]
1391 record_type: &'static str,
1392 response_schema_version: u32,
1393 page: crate::message_search::MessageSearchPageDocument,
1394 #[serde(skip_serializing_if = "Option::is_none")]
1395 receipt: Option<crate::message_search::MessageSearchReceiptDocument<'a>>,
1396 }
1397
1398 let request = batches.request().clone();
1399 serde_json::to_writer(
1400 &mut *out,
1401 &SearchMetadata {
1402 record_type: "search_metadata",
1403 response_schema_version: MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
1404 coordinate_unit: crate::message_search::CoordinateUnit::UnicodeScalar,
1405 effective_request: &request,
1406 included: batches.runtime_diagnostics().map(|runtime_diagnostics| {
1407 SearchMetadataIncluded {
1408 runtime_diagnostics,
1409 }
1410 }),
1411 },
1412 )?;
1413 writeln!(out)?;
1414
1415 let mut index = 0_usize;
1416 while let Some(batch) = batches.next_batch()? {
1417 if !batch.included().is_empty() {
1418 serde_json::to_writer(
1419 &mut *out,
1420 &SearchIncludedRecord {
1421 record_type: "search_included",
1422 included: batch.included(),
1423 },
1424 )?;
1425 writeln!(out)?;
1426 }
1427 for batch_index in 0..batch.results().len() {
1428 let result = batch
1429 .result_document(&request, batch_index)
1430 .expect("index comes from canonical batch result length");
1431 serde_json::to_writer(
1432 &mut *out,
1433 &SearchResultRecord {
1434 record_type: "hit",
1435 index,
1436 result,
1437 },
1438 )?;
1439 writeln!(out)?;
1440 index = index
1441 .checked_add(1)
1442 .ok_or_else(|| anyhow::anyhow!("CLI JSONL result index overflows usize"))?;
1443 }
1444 }
1445
1446 let completion = batches.completion()?;
1447 serde_json::to_writer(
1448 &mut *out,
1449 &SearchEnd {
1450 record_type: "search_end",
1451 response_schema_version: MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
1452 page: completion.page_document(),
1453 receipt: completion.receipt_document(&request),
1454 },
1455 )?;
1456 writeln!(out)?;
1457 Ok(())
1458}
1459
1460fn emit_message_search_human_batches(
1461 mut batches: crate::MessageSearchBatches,
1462 format: OutputFormat,
1463) -> Result<()> {
1464 let stdout = io::stdout();
1465 let mut out = stdout.lock();
1466 if let Err(error) = write_message_search_human_batches(&mut batches, format, &mut out) {
1467 return Err(close_batches_after_output_error(&mut batches, error));
1468 }
1469 out.flush()?;
1470 Ok(())
1471}
1472
1473fn write_message_search_human_batches<W: Write>(
1474 batches: &mut crate::MessageSearchBatches,
1475 format: OutputFormat,
1476 out: &mut W,
1477) -> Result<()> {
1478 let include_refs = batches
1479 .request()
1480 .include()
1481 .contains(&crate::message_search::MessageSearchInclude::ParsedReferences);
1482 let lines_per_message = batches.request().presentation().lines_per_message();
1483 let has_context = batches.request().context() != ContextWindow::default();
1484
1485 if has_context {
1486 if include_refs {
1487 write_context_message_search_batches::<ContextRowWithRefs, _>(
1488 batches,
1489 format,
1490 lines_per_message,
1491 out,
1492 )?;
1493 } else {
1494 write_context_message_search_batches::<ContextRow, _>(
1495 batches,
1496 format,
1497 lines_per_message,
1498 out,
1499 )?;
1500 }
1501 } else if include_refs {
1502 let mut renderer = RowBatchRenderer::<MessageSearchHitWithRefs>::new(format);
1503 while let Some(batch) = batches.next_batch()? {
1504 let rows = batch
1505 .results()
1506 .iter()
1507 .cloned()
1508 .map(|mut hit| {
1509 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
1510 hit.message.content =
1511 select_message_lines(&hit.message.content, lines_per_message);
1512 MessageSearchHitWithRefs {
1513 hit,
1514 ref_summary: ref_summary(&refs),
1515 refs,
1516 }
1517 })
1518 .collect::<Vec<_>>();
1519 renderer.write_batch(&rows, &mut *out)?;
1520 }
1521 renderer.finish(&mut *out)?;
1522 } else {
1523 let mut renderer = RowBatchRenderer::<MessageSearchHit>::new(format);
1524 while let Some(batch) = batches.next_batch()? {
1525 let rows = batch
1526 .results()
1527 .iter()
1528 .cloned()
1529 .map(|mut hit| {
1530 hit.message.content =
1531 select_message_lines(&hit.message.content, lines_per_message);
1532 hit
1533 })
1534 .collect::<Vec<_>>();
1535 renderer.write_batch(&rows, &mut *out)?;
1536 }
1537 renderer.finish(&mut *out)?;
1538 }
1539
1540 let completion = batches.completion()?;
1541 if let Some(explain) = completion.search_explanation() {
1542 eprintln!(
1543 "{}",
1544 explain.summary(
1545 batches
1546 .request()
1547 .query()
1548 .is_some_and(|query| !query.is_empty())
1549 )
1550 );
1551 }
1552 if let Some(origins) = completion.parameter_origins() {
1553 eprintln!("[origins] {}", serde_json::to_string(origins)?);
1554 }
1555 Ok(())
1556}
1557
1558fn write_context_message_search_batches<T: ContextBatchRow, W: Write>(
1559 batches: &mut crate::MessageSearchBatches,
1560 format: OutputFormat,
1561 lines_per_message: i64,
1562 out: &mut W,
1563) -> Result<()> {
1564 let mut renderer = RowBatchRenderer::<T>::new(format);
1565 let mut union = ContextWindowUnion::default();
1566 while let Some(batch) = batches.next_batch()? {
1567 anyhow::ensure!(
1568 batch.results().len() == batch.context_windows().len(),
1569 "message-search batch returned {} hits but {} context windows",
1570 batch.results().len(),
1571 batch.context_windows().len()
1572 );
1573 for (anchor, window) in batch.results().iter().zip(batch.context_windows()) {
1574 let rows = union
1575 .push_anchor(anchor, window)?
1576 .into_iter()
1577 .map(|row| T::from_pending(row, lines_per_message))
1578 .collect::<Vec<_>>();
1579 renderer.write_batch(&rows, &mut *out)?;
1580 }
1581 }
1582 let rows = union
1583 .finish()
1584 .into_iter()
1585 .map(|row| T::from_pending(row, lines_per_message))
1586 .collect::<Vec<_>>();
1587 renderer.write_batch(&rows, &mut *out)?;
1588 renderer.finish(out)
1589}
1590
1591#[cfg(test)]
1592fn presented_message_value(
1593 hit: &MessageHit,
1594 lines_per_message: i64,
1595 include_refs: bool,
1596) -> Result<serde_json::Value> {
1597 let view = crate::message_search::selected_field_view(
1598 &hit.content,
1599 LineWindow::from_signed(lines_per_message)?,
1600 FieldViewBudget::NoCharLimit,
1601 None,
1602 )?;
1603 let (content, extent) = view.into_content_and_extent();
1604 let mut value = serde_json::to_value(hit)?;
1605 value["content"] = serde_json::json!(content);
1606 value["content_extent"] = serde_json::to_value(extent)?;
1607 if include_refs {
1608 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
1609 value["ref_summary"] = serde_json::json!(ref_summary(&refs));
1610 value["refs"] = serde_json::to_value(refs)?;
1611 }
1612 Ok(value)
1613}
1614
1615fn emit_message_search_machine_response(
1620 response: &MessageSearchResponse,
1621 format: OutputFormat,
1622) -> Result<()> {
1623 let stdout = io::stdout();
1624 let mut out = stdout.lock();
1625 match format {
1626 OutputFormat::Json => serde_json::to_writer_pretty(&mut out, response)?,
1629 OutputFormat::Jsonl => {
1630 #[derive(Serialize)]
1631 struct SearchMetadata<'a> {
1632 #[serde(rename = "type")]
1633 record_type: &'static str,
1634 response_schema_version: u32,
1635 coordinate_unit: crate::message_search::CoordinateUnit,
1636 effective_request: &'a crate::message_search::ResolvedMessageSearchRequest,
1637 #[serde(skip_serializing_if = "Option::is_none")]
1638 included: Option<&'a crate::message_search::MessageSearchIncludedData>,
1639 }
1640
1641 #[derive(Serialize)]
1642 struct SearchResultRecord<'a> {
1643 #[serde(rename = "type")]
1644 record_type: &'static str,
1645 index: usize,
1646 result: crate::message_search::MessageSearchResultDocument<'a>,
1647 }
1648
1649 #[derive(Serialize)]
1650 struct SearchEnd<'a> {
1651 #[serde(rename = "type")]
1652 record_type: &'static str,
1653 response_schema_version: u32,
1654 page: crate::message_search::MessageSearchPageDocument,
1655 #[serde(skip_serializing_if = "Option::is_none")]
1656 receipt: Option<crate::message_search::MessageSearchReceiptDocument<'a>>,
1657 }
1658
1659 let metadata = SearchMetadata {
1660 record_type: "search_metadata",
1661 response_schema_version: MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
1662 coordinate_unit: crate::message_search::CoordinateUnit::UnicodeScalar,
1663 effective_request: response.request(),
1664 included: response.has_included_data().then(|| response.included()),
1665 };
1666 serde_json::to_writer(&mut out, &metadata)?;
1667 writeln!(out)?;
1668 for index in 0..response.results().len() {
1671 let row = SearchResultRecord {
1672 record_type: "hit",
1673 index,
1674 result: response
1675 .result_document(index)
1676 .expect("index comes from canonical result length"),
1677 };
1678 serde_json::to_writer(&mut out, &row)?;
1679 writeln!(out)?;
1680 }
1681 let terminal = SearchEnd {
1682 record_type: "search_end",
1683 response_schema_version: MESSAGE_SEARCH_RESPONSE_SCHEMA_VERSION,
1684 page: response.page_document(),
1685 receipt: response.receipt_document(),
1686 };
1687 serde_json::to_writer(&mut out, &terminal)?;
1688 }
1689 _ => unreachable!("machine response is only used for JSON and JSONL"),
1690 }
1691 writeln!(out)?;
1692 out.flush()?;
1693 Ok(())
1694}
1695
1696fn validate_seq_bounds(seq_from: Option<i64>, seq_to: Option<i64>) -> Result<()> {
1697 if let (Some(from), Some(to)) = (seq_from, seq_to) {
1698 if from > to {
1699 bail!(
1700 "--seq-from must be <= --seq-to, got {from} > {to}; \
1701 swap the bounds or raise --seq-to to at least {from}"
1702 );
1703 }
1704 }
1705 Ok(())
1706}
1707
1708fn parse_context_count(raw: &str) -> std::result::Result<i64, String> {
1712 let value: i64 = raw
1713 .parse()
1714 .map_err(|_| String::from("must be an integer 0 or greater"))?;
1715 if value < 0 {
1716 return Err(String::from(
1717 "must be 0 or greater; pass how many neighboring messages to include, \
1718 or 0 for the match alone",
1719 ));
1720 }
1721 Ok(value)
1722}
1723
1724fn emit<T: Serialize + Row>(rows: &[T], format: OutputFormat) -> Result<()> {
1725 let stdout = io::stdout();
1726 let mut out = stdout.lock();
1727 render(rows, format, &mut out)?;
1728 out.flush()?;
1729 Ok(())
1730}
1731
1732fn emit_message_search_hits(
1733 hits: &[MessageSearchHit],
1734 include_refs: bool,
1735 format: OutputFormat,
1736 lines_per_message: i64,
1737) -> Result<()> {
1738 if !include_refs {
1739 if lines_per_message == 0 {
1740 return emit(hits, format);
1741 }
1742 let capped = hits
1743 .iter()
1744 .cloned()
1745 .map(|mut hit| {
1746 hit.message.content = select_message_lines(&hit.message.content, lines_per_message);
1747 hit
1748 })
1749 .collect::<Vec<_>>();
1750 return emit(&capped, format);
1751 }
1752 let rows = hits
1753 .iter()
1754 .cloned()
1755 .map(|mut hit| {
1756 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
1758 hit.message.content = select_message_lines(&hit.message.content, lines_per_message);
1759 MessageSearchHitWithRefs {
1760 hit,
1761 ref_summary: ref_summary(&refs),
1762 refs,
1763 }
1764 })
1765 .collect::<Vec<_>>();
1766 emit(&rows, format)
1767}
1768
1769fn emit_message_hits(
1770 hits: &[MessageHit],
1771 include_refs: bool,
1772 format: OutputFormat,
1773 lines_per_message: i64,
1774) -> Result<()> {
1775 if !include_refs {
1776 if lines_per_message == 0 {
1777 return emit(hits, format);
1778 }
1779 let capped = hits
1780 .iter()
1781 .cloned()
1782 .map(|mut hit| {
1783 hit.content = select_message_lines(&hit.content, lines_per_message);
1784 hit
1785 })
1786 .collect::<Vec<_>>();
1787 return emit(&capped, format);
1788 }
1789 let rows = hits
1790 .iter()
1791 .cloned()
1792 .map(|mut hit| {
1793 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
1794 hit.content = select_message_lines(&hit.content, lines_per_message);
1795 MessageHitWithRefs {
1796 hit,
1797 ref_summary: ref_summary(&refs),
1798 refs,
1799 }
1800 })
1801 .collect::<Vec<_>>();
1802 emit(&rows, format)
1803}
1804
1805fn emit_inspection(
1806 inspection: &crate::inspect::SessionInspection,
1807 options: InspectionOptions,
1808 format: OutputFormat,
1809) -> Result<()> {
1810 let stdout = io::stdout();
1811 let mut out = stdout.lock();
1812 crate::render::render_record(
1813 inspection,
1814 &inspection_rows(inspection, options),
1815 format,
1816 &mut out,
1817 )?;
1818 out.flush()?;
1819 Ok(())
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824 use super::*;
1825 use clap::Parser;
1826
1827 #[derive(Parser)]
1828 struct TestCli {
1829 #[command(subcommand)]
1830 cmd: MessagesCmd,
1831 }
1832
1833 fn assert_parses<const N: usize>(args: [&str; N]) {
1834 TestCli::try_parse_from(args)
1835 .unwrap_or_else(|err| panic!("expected messages args to parse: {args:?}: {err}"));
1836 }
1837
1838 fn assert_rejects<const N: usize>(args: [&str; N]) {
1839 assert!(
1840 TestCli::try_parse_from(args).is_err(),
1841 "expected messages args to be rejected: {args:?}"
1842 );
1843 }
1844
1845 #[test]
1846 fn message_search_describe_is_json_and_cannot_ignore_search_parameters() {
1847 assert_parses(["aise", "search", "--describe"]);
1848 assert_parses(["aise", "search", "--describe", "--format", "json"]);
1849 assert_parses(["aise", "search", "--describe", "--describe-surface", "mcp"]);
1850 assert_rejects(["aise", "search", "--describe-surface", "python"]);
1851 for request_argument in [
1852 ["query", "needle"],
1853 ["limit", "--limit=1"],
1854 ["explicit default field", "--field=content"],
1855 ["explicit default offset", "--offset=0"],
1856 ["date scope", "--since=2026-01-01"],
1857 ] {
1858 assert_rejects(["aise", "search", "--describe", request_argument[1]]);
1859 }
1860 let TestCli {
1861 cmd: MessagesCmd::Search(describe),
1862 } = TestCli::try_parse_from(["aise", "search", "--describe", "--format", "table"])
1863 .expect("clap parses the format before semantic validation")
1864 else {
1865 panic!("expected search command");
1866 };
1867 assert!(describe.output_format().is_err());
1868
1869 let TestCli {
1870 cmd: MessagesCmd::Search(describe),
1871 } = TestCli::try_parse_from(["aise", "search", "--describe"]).unwrap()
1872 else {
1873 panic!("expected search command");
1874 };
1875 assert_eq!(describe.output_format().unwrap(), OutputFormat::Json);
1876
1877 let TestCli {
1878 cmd: MessagesCmd::Search(search),
1879 } = TestCli::try_parse_from(["aise", "search"]).unwrap()
1880 else {
1881 panic!("expected search command");
1882 };
1883 assert_eq!(search.output_format().unwrap(), OutputFormat::Table);
1884 }
1885
1886 #[test]
1887 fn message_search_describe_conflict_group_covers_every_search_argument() {
1888 use clap::CommandFactory;
1889
1890 let command = TestCli::command();
1891 let search = command
1892 .find_subcommand("search")
1893 .expect("messages search command");
1894 let grouped = search
1895 .get_groups()
1896 .find(|group| group.get_id() == "search_request")
1897 .expect("search_request conflict group")
1898 .get_args()
1899 .map(ToString::to_string)
1900 .collect::<std::collections::BTreeSet<_>>();
1901 let expected = search
1902 .get_arguments()
1903 .map(|argument| argument.get_id().to_string())
1904 .filter(|id| {
1905 !matches!(
1906 id.as_str(),
1907 "describe" | "describe_surface" | "format" | "help"
1908 )
1909 })
1910 .collect::<std::collections::BTreeSet<_>>();
1911 assert_eq!(grouped, expected);
1912 }
1913
1914 fn sample_hit(seq: i64, content: &str) -> MessageHit {
1915 MessageHit {
1916 session_id: "claude:s1".to_string(),
1917 provider: Provider::Claude,
1918 seq,
1919 role: Role::User,
1920 kind: MessageKind::Conversation,
1921 ts: None,
1922 tool_name: None,
1923 tool_call_id: None,
1924 fuzzy_score: None,
1925 content: content.to_string(),
1926 }
1927 }
1928
1929 #[test]
1930 fn context_rows_keep_first_canonical_encounter_across_sessions() {
1931 let mut ranked_first = sample_hit(1, "ranked first");
1932 ranked_first.session_id = "claude:z-ranked-first".to_string();
1933 let mut ranked_second = sample_hit(2, "ranked second");
1934 ranked_second.session_id = "claude:a-ranked-second".to_string();
1935 let duplicate_first = ranked_first.clone();
1936
1937 let ordered = context_hits_in_encounter_order(&[
1938 vec![ranked_first, ranked_second],
1939 vec![duplicate_first],
1940 ]);
1941
1942 assert_eq!(
1943 ordered
1944 .iter()
1945 .map(|hit| (hit.session_id.as_str(), hit.seq))
1946 .collect::<Vec<_>>(),
1947 [("claude:z-ranked-first", 1), ("claude:a-ranked-second", 2),]
1948 );
1949 }
1950
1951 #[test]
1952 fn context_window_union_marks_future_anchors_before_emission_and_retains_only_the_tail() {
1953 let first = MessageSearchHit::from_parts(sample_hit(2, "match two"), None, None);
1954 let second = MessageSearchHit::from_parts(sample_hit(4, "match four"), None, None);
1955 let first_window = (0..=4)
1956 .map(|seq| sample_hit(seq, &format!("message {seq}")))
1957 .collect::<Vec<_>>();
1958 let second_window = (2..=6)
1959 .map(|seq| sample_hit(seq, &format!("message {seq}")))
1960 .collect::<Vec<_>>();
1961 let mut union = ContextWindowUnion::default();
1962
1963 let first_ready = union.push_anchor(&first, &first_window).unwrap();
1964 assert_eq!(
1965 first_ready
1966 .iter()
1967 .map(|row| (row.hit.seq, row.is_match))
1968 .collect::<Vec<_>>(),
1969 [(0, false), (1, false), (2, true)]
1970 );
1971 assert_eq!(union.pending.keys().copied().collect::<Vec<_>>(), [3, 4]);
1972
1973 let second_ready = union.push_anchor(&second, &second_window).unwrap();
1974 assert_eq!(
1975 second_ready
1976 .iter()
1977 .map(|row| (row.hit.seq, row.is_match))
1978 .collect::<Vec<_>>(),
1979 [(3, false), (4, true)]
1980 );
1981 assert_eq!(union.pending.keys().copied().collect::<Vec<_>>(), [5, 6]);
1982 assert_eq!(
1983 union
1984 .finish()
1985 .into_iter()
1986 .map(|row| (row.hit.seq, row.is_match))
1987 .collect::<Vec<_>>(),
1988 [(5, false), (6, false)]
1989 );
1990 }
1991
1992 #[test]
1993 fn context_window_union_flushes_session_tails_and_rejects_nonchronological_anchors() {
1994 let first = MessageSearchHit::from_parts(sample_hit(2, "first match"), None, None);
1995 let first_window = (1..=3)
1996 .map(|seq| sample_hit(seq, &format!("first {seq}")))
1997 .collect::<Vec<_>>();
1998 let mut second_message = sample_hit(1, "second match");
1999 second_message.session_id = "claude:s2".to_string();
2000 let second = MessageSearchHit::from_parts(second_message, None, None);
2001 let second_window = (0..=2)
2002 .map(|seq| {
2003 let mut hit = sample_hit(seq, &format!("second {seq}"));
2004 hit.session_id = "claude:s2".to_string();
2005 hit
2006 })
2007 .collect::<Vec<_>>();
2008 let mut union = ContextWindowUnion::default();
2009
2010 assert_eq!(
2011 union
2012 .push_anchor(&first, &first_window)
2013 .unwrap()
2014 .into_iter()
2015 .map(|row| (row.hit.session_id, row.hit.seq))
2016 .collect::<Vec<_>>(),
2017 [("claude:s1".to_string(), 1), ("claude:s1".to_string(), 2)]
2018 );
2019 assert_eq!(
2020 union
2021 .push_anchor(&second, &second_window)
2022 .unwrap()
2023 .into_iter()
2024 .map(|row| (row.hit.session_id, row.hit.seq))
2025 .collect::<Vec<_>>(),
2026 [
2027 ("claude:s1".to_string(), 3),
2028 ("claude:s2".to_string(), 0),
2029 ("claude:s2".to_string(), 1)
2030 ]
2031 );
2032
2033 let mut late_message = sample_hit(0, "late anchor");
2034 late_message.session_id = "claude:s2".to_string();
2035 let late = MessageSearchHit::from_parts(late_message, None, None);
2036 let error = union.push_anchor(&late, &[]).unwrap_err().to_string();
2037 assert!(
2038 error.contains("not strictly ordered by sequence"),
2039 "{error}"
2040 );
2041 }
2042
2043 #[test]
2044 fn render_message_hit_uses_documented_columns_across_formats() {
2045 let hit = sample_hit(7, "hello world");
2046
2047 let mut buf = Vec::new();
2049 render(std::slice::from_ref(&hit), OutputFormat::Csv, &mut buf).unwrap();
2050 let csv = String::from_utf8(buf).unwrap();
2051 assert_eq!(
2052 csv.lines().next().unwrap(),
2053 "session,provider,seq,role,tool,ts,content"
2054 );
2055 assert!(csv.trim_end().ends_with("hello world"));
2056
2057 let mut buf = Vec::new();
2059 render(std::slice::from_ref(&hit), OutputFormat::Plain, &mut buf).unwrap();
2060 let plain = String::from_utf8(buf).unwrap();
2061 let fields: Vec<&str> = plain.trim_end().split('\t').collect();
2062 assert_eq!(
2063 fields,
2064 ["claude:s1", "claude", "7", "user", "", "", "hello world"]
2065 );
2066
2067 let mut buf = Vec::new();
2069 render(std::slice::from_ref(&hit), OutputFormat::Json, &mut buf).unwrap();
2070 let json = String::from_utf8(buf).unwrap();
2071 assert!(json.contains("\"session_id\"") && json.contains("hello world"));
2072
2073 let mut buf = Vec::new();
2075 render(std::slice::from_ref(&hit), OutputFormat::Table, &mut buf).unwrap();
2076 let table = String::from_utf8(buf).unwrap();
2077 assert!(table.lines().next().unwrap().starts_with("session"));
2078 assert!(table.contains("hello world"));
2079 }
2080
2081 #[test]
2082 fn render_context_and_refs_rows_expose_match_and_refs_columns() {
2083 let hit = sample_hit(7, "see https://example.com now");
2084 let mut matched = HashSet::new();
2085 matched.insert((hit.session_id.clone(), hit.seq));
2086
2087 let ctx = ContextRow::from_hit(hit.clone(), &matched, 0);
2089 assert_eq!(
2090 <ContextRow as Row>::headers(),
2091 ["session", "provider", "seq", "role", "tool", "ts", "match", "content"]
2092 );
2093 assert_eq!(ctx.cells()[6], "*");
2094 let other = ContextRow::from_hit(sample_hit(8, "context line"), &matched, 0);
2095 assert_eq!(other.cells()[6], "");
2096
2097 let refs = extract_refs_from_text(&hit.content, hit.tool_name.as_deref());
2099 let with_refs = MessageHitWithRefs {
2100 hit: hit.clone(),
2101 ref_summary: ref_summary(&refs),
2102 refs,
2103 };
2104 assert_eq!(<MessageHitWithRefs as Row>::headers()[6], "refs");
2105 let mut buf = Vec::new();
2106 render(
2107 std::slice::from_ref(&with_refs),
2108 OutputFormat::Csv,
2109 &mut buf,
2110 )
2111 .unwrap();
2112 assert_eq!(
2113 String::from_utf8(buf).unwrap().lines().next().unwrap(),
2114 "session,provider,seq,role,tool,ts,refs,content"
2115 );
2116
2117 let ctx_refs = ContextRowWithRefs::from_hit(hit.clone(), &matched, 0);
2119 assert_eq!(
2120 <ContextRowWithRefs as Row>::headers(),
2121 ["session", "provider", "seq", "role", "tool", "ts", "match", "refs", "content"]
2122 );
2123 assert_eq!(ctx_refs.cells()[6], "*");
2124 }
2125
2126 #[test]
2127 fn structured_search_rows_disclose_additional_head_and_tail_text() {
2128 let hit = sample_hit(7, "alpha\nbeta\ngamma");
2129
2130 let head = presented_message_value(&hit, 1, false).unwrap();
2131 assert_eq!(head["content"], "alpha");
2132 assert_eq!(head["content_extent"]["field_start_char"], 0);
2133 assert_eq!(head["content_extent"]["field_end_char_exclusive"], 5);
2134 assert_eq!(head["content_extent"]["additional_field_text"], "after");
2135
2136 let tail = presented_message_value(&hit, -1, false).unwrap();
2137 assert_eq!(tail["content"], "gamma");
2138 assert_eq!(tail["content_extent"]["field_start_char"], 11);
2139 assert_eq!(tail["content_extent"]["field_end_char_exclusive"], 16);
2140 assert_eq!(tail["content_extent"]["additional_field_text"], "before");
2141 }
2142
2143 #[test]
2144 fn match_evidence_table_window_keeps_late_matches_and_boundaries_visible() {
2145 let excerpt = format!("{}NEED{}", "a".repeat(205), "z".repeat(10));
2146 let late_match = crate::message_search::MessageMatchEvidence {
2147 view_text: excerpt.clone(),
2148 field_start_char: 0,
2149 field_total_chars: excerpt.chars().count(),
2150 markers: crate::message_search::MessageMatchViewMarkers::Characters {
2151 ranges: vec![crate::message_search::ViewCharRange {
2152 view_start_char: 205,
2153 view_end_char_exclusive: 209,
2154 }],
2155 matched_chars_total: 4,
2156 matched_chars_shown: 4,
2157 },
2158 };
2159 let rendered = format_match_evidence(&late_match);
2160 assert!(
2161 rendered.contains("NEED"),
2162 "the late selected-field match must remain in the table window"
2163 );
2164 assert!(rendered.starts_with("..."));
2165 assert!(rendered.chars().count() <= TABLE_CONTENT_CHARS);
2166
2167 for at_char in [0, 109, excerpt.chars().count()] {
2168 let boundary = crate::message_search::MessageMatchEvidence {
2169 view_text: excerpt.clone(),
2170 field_start_char: 0,
2171 field_total_chars: excerpt.chars().count(),
2172 markers: crate::message_search::MessageMatchViewMarkers::Boundary {
2173 view_at_char: at_char,
2174 },
2175 };
2176 let rendered = format_match_evidence(&boundary);
2177 assert_eq!(
2178 rendered.matches('^').count(),
2179 1,
2180 "boundary {at_char} must remain visible"
2181 );
2182 assert!(rendered.chars().count() <= TABLE_CONTENT_CHARS);
2183 }
2184 }
2185
2186 #[test]
2187 fn context_table_rows_render_the_same_bounded_match_evidence() {
2188 let hit = sample_hit(7, "full raw message content");
2189 let matched = HashSet::from([(hit.session_id.clone(), hit.seq)]);
2190 let evidence = crate::message_search::MessageMatchEvidence {
2191 view_text: "x".repeat(220),
2192 field_start_char: 0,
2193 field_total_chars: 220,
2194 markers: crate::message_search::MessageMatchViewMarkers::Boundary { view_at_char: 220 },
2195 };
2196
2197 let row = ContextRow::from_hit(hit.clone(), &matched, 0)
2198 .with_match_evidence(Some(evidence.clone()));
2199 let row_with_refs = ContextRowWithRefs {
2200 row: ContextRow::from_hit(hit, &matched, 0).with_match_evidence(Some(evidence)),
2201 ref_summary: String::new(),
2202 refs: Vec::new(),
2203 };
2204
2205 assert!(row.cells()[6].contains('^'));
2206 assert!(row_with_refs.cells()[6].contains('^'));
2207 assert!(row.cells()[6].chars().count() <= TABLE_CONTENT_CHARS);
2208 assert!(row_with_refs.cells()[6].chars().count() <= TABLE_CONTENT_CHARS);
2209 }
2210
2211 #[test]
2212 fn match_evidence_cells_compact_control_whitespace_without_changing_structured_evidence() {
2213 let excerpt = format!("{}\r\n\tNEED\n{}", "a".repeat(180), "z".repeat(40));
2214 let evidence = crate::message_search::MessageMatchEvidence {
2215 view_text: excerpt.clone(),
2216 field_start_char: 0,
2217 field_total_chars: excerpt.chars().count(),
2218 markers: crate::message_search::MessageMatchViewMarkers::Characters {
2219 ranges: vec![crate::message_search::ViewCharRange {
2220 view_start_char: 183,
2221 view_end_char_exclusive: 187,
2222 }],
2223 matched_chars_total: 4,
2224 matched_chars_shown: 4,
2225 },
2226 };
2227 let rendered = format_match_evidence(&evidence);
2228 assert!(rendered.contains("NEED"));
2229 assert!(!rendered.contains(['\r', '\n', '\t']));
2230 assert!(rendered.chars().count() <= TABLE_CONTENT_CHARS);
2231 assert_eq!(
2232 evidence.view_text, excerpt,
2233 "structured JSON evidence must retain its original selected-field text"
2234 );
2235
2236 let hit = sample_hit(7, "raw content");
2237 let matched = HashSet::from([(hit.session_id.clone(), hit.seq)]);
2238 let search_hit = MessageSearchHit::from_parts(hit.clone(), Some(evidence.clone()), None);
2239 let context = ContextRow::from_hit(hit.clone(), &matched, 0)
2240 .with_match_evidence(Some(evidence.clone()));
2241 let context_with_refs = ContextRowWithRefs {
2242 row: ContextRow::from_hit(hit, &matched, 0).with_match_evidence(Some(evidence.clone())),
2243 ref_summary: String::new(),
2244 refs: Vec::new(),
2245 };
2246 for cell in [
2247 format_match_evidence(&evidence),
2248 context.cells()[6].clone(),
2249 context_with_refs.cells()[6].clone(),
2250 ] {
2251 assert!(!cell.contains(['\r', '\n', '\t']));
2252 assert!(cell.chars().count() <= TABLE_CONTENT_CHARS);
2253 }
2254
2255 let mut plain = Vec::new();
2256 render(
2257 std::slice::from_ref(&search_hit),
2258 OutputFormat::Plain,
2259 &mut plain,
2260 )
2261 .unwrap();
2262 let plain = String::from_utf8(plain).unwrap();
2263 assert_eq!(plain.lines().count(), 1);
2264 assert_eq!(plain.trim_end().split('\t').count(), 8);
2265
2266 let structured = serde_json::to_value(search_hit).unwrap();
2267 assert_eq!(structured["match_evidence"]["view_text"], excerpt);
2268 }
2269
2270 #[test]
2271 fn search_query_mode_uses_one_closed_interpretation_axis() {
2272 assert_parses(["sg", "search", "foo"]);
2273 assert_parses(["sg", "search", "foo", "--query-mode", "regex"]);
2274 assert_parses(["sg", "search", "--query", "foo", "--query-mode", "regex"]);
2275 assert_parses(["sg", "search", "--query-mode", "regex", "bar"]);
2276 assert_parses([
2277 "sg",
2278 "search",
2279 "TODO|FIXME",
2280 "--query-mode",
2281 "regex",
2282 "--role",
2283 "user",
2284 ]);
2285 assert_rejects(["sg", "search", "foo", "--query-mode", "unknown"]);
2286 }
2287
2288 #[test]
2289 fn search_provider_option_accepts_repeated_and_comma_separated_sets() {
2290 let parsed = TestCli::try_parse_from([
2291 "sg",
2292 "search",
2293 "needle",
2294 "--provider",
2295 "codex,claude",
2296 "--provider",
2297 "gemini-cli",
2298 ])
2299 .unwrap();
2300 let MessagesCmd::Search(arguments) = parsed.cmd else {
2301 panic!("expected messages search command");
2302 };
2303 assert_eq!(
2304 arguments.providers,
2305 [Provider::Codex, Provider::Claude, Provider::GeminiCli]
2306 );
2307 }
2308
2309 #[test]
2310 fn search_view_bounds_accept_named_unbounded_or_minimal_values_and_positive_counts() {
2311 assert_parses([
2312 "sg",
2313 "search",
2314 "needle",
2315 "--field-view-chars",
2316 "no-char-limit",
2317 ]);
2318 assert_parses(["sg", "search", "needle", "--field-view-chars", "80"]);
2319 assert_parses(["sg", "search", "needle", "--match-view-chars", "minimal"]);
2320 assert_parses(["sg", "search", "needle", "--match-view-chars", "80"]);
2321 assert_rejects(["sg", "search", "needle", "--field-view-chars", "0"]);
2322 assert_rejects(["sg", "search", "needle", "--match-view-chars", "0"]);
2323 }
2324
2325 #[test]
2326 fn search_include_accepts_closed_groups_and_explicit_none() {
2327 assert_parses(["sg", "search", "needle", "--include", "parsed_references"]);
2328 assert_parses(["sg", "search", "needle", "--include", "none"]);
2329 assert_rejects(["sg", "search", "needle", "--include", "refs"]);
2330 }
2331
2332 #[test]
2333 fn search_accepts_session_scoped_seq_bounds() {
2334 assert_parses([
2335 "sg",
2336 "search",
2337 "needle",
2338 "--session-id",
2339 "claude:s1",
2340 "--seq-from",
2341 "2",
2342 "--seq-to",
2343 "5",
2344 ]);
2345 }
2346
2347 #[test]
2348 fn get_accepts_focused_seq_window() {
2349 assert_parses(["sg", "get", "claude:s1", "--seq", "2", "--context", "1"]);
2350 }
2351
2352 #[test]
2353 fn get_accepts_full_read_flag_combination_and_validates_order() {
2354 assert_parses([
2356 "sg",
2357 "get",
2358 "claude:s1",
2359 "--role",
2360 "user",
2361 "--limit",
2362 "20",
2363 "--order",
2364 "newest",
2365 "--offset",
2366 "10",
2367 "--seq-from",
2368 "100",
2369 "--seq-to",
2370 "200",
2371 ]);
2372 assert_rejects(["sg", "get", "claude:s1", "--order", "recent"]);
2374 assert_rejects(["sg", "get", "claude:s1", "--order", "-1"]);
2375 }
2376
2377 #[test]
2378 fn get_selects_trailing_window_via_order_not_a_signed_limit() {
2379 assert_parses([
2382 "sg",
2383 "get",
2384 "claude:s1",
2385 "--limit",
2386 "75",
2387 "--order",
2388 "newest",
2389 ]);
2390 assert_parses([
2391 "sg",
2392 "get",
2393 "claude:s1",
2394 "--limit",
2395 "75",
2396 "--order",
2397 "oldest",
2398 ]);
2399 assert_parses(["sg", "get", "claude:s1", "--limit", "75"]);
2400 assert_rejects(["sg", "get", "claude:s1", "--limit", "-75"]);
2401 assert_rejects(["sg", "get", "claude:s1", "--limit=-75"]);
2402 }
2403
2404 #[test]
2405 fn read_order_maps_to_db_message_order_and_defaults_oldest() {
2406 use crate::db::MessageOrder;
2407 assert_eq!(
2410 ReadOrder::Oldest.to_message_order(),
2411 MessageOrder::OldestFirst
2412 );
2413 assert_eq!(
2414 ReadOrder::Newest.to_message_order(),
2415 MessageOrder::NewestFirst
2416 );
2417 assert_eq!(ReadOrder::default(), ReadOrder::Oldest);
2420 assert_eq!(
2421 ReadOrder::default().to_message_order(),
2422 MessageOrder::OldestFirst
2423 );
2424 }
2425
2426 #[test]
2427 fn parse_context_count_accepts_zero_and_rejects_negative_and_noninteger() {
2428 assert_eq!(parse_context_count("0").unwrap(), 0);
2430 assert_eq!(parse_context_count("5").unwrap(), 5);
2431 assert_eq!(parse_context_count("1000000").unwrap(), 1_000_000);
2433 let neg = parse_context_count("-1").unwrap_err();
2435 assert!(neg.contains("0 or greater"), "{neg}");
2436 assert!(neg.contains("0 for the match alone"), "{neg}");
2437 assert!(parse_context_count("abc")
2439 .unwrap_err()
2440 .contains("integer 0 or greater"));
2441 assert!(parse_context_count("")
2442 .unwrap_err()
2443 .contains("integer 0 or greater"));
2444 assert!(parse_context_count("3.5")
2445 .unwrap_err()
2446 .contains("integer 0 or greater"));
2447 }
2448
2449 #[test]
2450 fn timeline_accepts_limit_offset_and_order_for_paged_single_session_reads() {
2451 assert_parses([
2452 "sg",
2453 "timeline",
2454 "claude:s1",
2455 "--limit",
2456 "50",
2457 "--order",
2458 "newest",
2459 ]);
2460 assert_parses([
2461 "sg",
2462 "timeline",
2463 "claude:s1",
2464 "--limit",
2465 "50",
2466 "--offset",
2467 "50",
2468 ]);
2469 assert_rejects(["sg", "timeline", "claude:s1", "--limit", "-50"]);
2471 }
2472
2473 #[test]
2474 fn get_accepts_seq_range_and_offset_for_chunked_reads() {
2475 assert_parses([
2476 "sg",
2477 "get",
2478 "claude:s1",
2479 "--seq-from",
2480 "501",
2481 "--seq-to",
2482 "1000",
2483 ]);
2484 assert_parses([
2485 "sg",
2486 "get",
2487 "claude:s1",
2488 "--limit",
2489 "500",
2490 "--offset",
2491 "500",
2492 ]);
2493 }
2494
2495 #[test]
2496 fn message_commands_accept_reference_enrichment_controls() {
2497 assert_parses([
2498 "sg",
2499 "search",
2500 "https://example.com",
2501 "--include",
2502 "parsed_references",
2503 ]);
2504 assert_parses(["sg", "get", "claude:s1", "--refs"]);
2505 assert_parses(["sg", "timeline", "claude:s1", "--refs"]);
2506 }
2507
2508 #[test]
2509 fn structured_context_rows_reuse_complete_message_hit_metadata() {
2510 let hit = MessageHit {
2511 session_id: "codex:context-contract".to_string(),
2512 provider: Provider::Codex,
2513 seq: 7,
2514 role: Role::Tool,
2515 kind: MessageKind::ToolResult,
2516 ts: None,
2517 tool_name: Some("exec_command".to_string()),
2518 tool_call_id: Some("call-7".to_string()),
2519 fuzzy_score: None,
2520 content: "first\nsecond".to_string(),
2521 };
2522 let matched = HashSet::from([("codex:context-contract".to_string(), 7)]);
2523
2524 let value = serde_json::to_value(ContextRow::from_hit(hit, &matched, 1)).unwrap();
2525
2526 assert_eq!(value["session_id"], "codex:context-contract");
2527 assert_eq!(value["provider"], "codex");
2528 assert_eq!(value["seq"], 7);
2529 assert_eq!(value["role"], "tool");
2530 assert_eq!(value["kind"], "tool_result");
2531 assert_eq!(value["tool_name"], "exec_command");
2532 assert_eq!(value["tool_call_id"], "call-7");
2533 assert_eq!(value["content"], "first");
2534 assert_eq!(value["is_match"], true);
2535 }
2536
2537 #[test]
2538 fn timeline_grep_and_regex_are_mutually_exclusive() {
2539 assert_rejects(["sg", "timeline", "s1", "--grep", "foo", "--regex", "bar"]);
2540 assert_parses(["sg", "timeline", "s1", "--grep", "foo"]);
2541 assert_parses(["sg", "timeline", "s1", "--regex", "bar"]);
2542 }
2543
2544 #[test]
2545 fn timeline_accepts_session_local_seq_bounds() {
2546 assert_parses([
2547 "sg",
2548 "timeline",
2549 "claude:s1",
2550 "--seq-from",
2551 "2",
2552 "--seq-to",
2553 "5",
2554 ]);
2555 assert!(validate_seq_bounds(Some(5), Some(2)).is_err());
2556 }
2557
2558 #[test]
2559 fn evidence_accepts_session_id_preview_chars_and_format() {
2560 assert_parses([
2561 "sg",
2562 "evidence",
2563 "claude:s1",
2564 "--preview-chars",
2565 "80",
2566 "--format",
2567 "json",
2568 ]);
2569 }
2570}