Skip to main content

ai_session_search/
messages.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! `messages` command group: search, read, and timeline per-message rows.
5//!
6//! Thin command glue over [`crate::db::Db`] + [`crate::render`], so `cli.rs` stays a
7//! dispatcher. Literal/regex `--limit 0` means unlimited; fuzzy requires a positive finite page.
8//! Date filtering (`--since/--until/--when`) is the shared [`crate::dates::DateRange`],
9//! which accepts EDTF / ISO / duration / natural language.
10
11use 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
40/// Max characters of content shown in tabular formats (json/jsonl keep full content).
41const TABLE_CONTENT_CHARS: usize = 120;
42const CLI_MESSAGE_SEARCH_BATCH_ROWS: usize = 256;
43
44/// Which end of the session a `--limit` row window is taken from. `oldest` keeps the first N
45/// by sequence, `newest` keeps the last N; both are then printed oldest-first. Order drives
46/// SELECTION, not just display, so `--order newest --limit 5` is the last 5 messages, not the
47/// first 5 shown backwards (the `git log --reverse` trap). A signed `--limit` is deliberately
48/// NOT used: a negative count is a CLI parser hazard and unprecedented among leading tools;
49/// the sign convention is reserved for per-item depth (--lines-per-message, --summary-items).
50#[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/// A message rendered as part of a `--context` window: like [`MessageHit`] plus a
110/// `match` marker (`*` for the matched row, blank for surrounding context).
111#[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/// Ordered union of overlapping context windows from chronological search anchors.
138///
139/// A row through the current anchor can be emitted immediately: a later anchor has a greater
140/// sequence within the same session, so only the current window's trailing rows can later become
141/// matches. Session changes flush the prior tail because SQL orders anchors by `(session_id, seq)`.
142///
143/// # Complexity
144///
145/// For one context window containing `W` rows and `A` retained trailing rows, insertion and
146/// deduplication take `O(W log(A + 1))` time and retain `O(A)` rows. `A` is bounded by the requested
147/// messages-after window; the separately owned service batch remains bounded by its configured
148/// hit count and per-hit context windows.
149#[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        // Reserve room for the caret and both possible ASCII omission markers. The selected range
417        // or boundary stays visible even when the configured evidence excerpt exceeds this
418        // terminal column's width.
419        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        // Refs come from the full content so a per-message line cap never hides references.
461        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        // Refs come from the full content so a per-message line cap never hides references.
484        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 messages by content / role / date across sessions.
496    Search(Box<MessageSearchArgs>),
497    /// Read messages from one session, or a focused seq/context window.
498    Get(MessageGetArgs),
499    /// Print one session's messages in order (optionally filtered by role/grep/regex).
500    Timeline(TimelineArgs),
501    /// Compact session evidence: purpose, tool activity, refs, changed files, follow-ups.
502    ///
503    /// Use `aise messages search` to find the message first, or `aise show` for the whole session.
504    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
582/// Help sections for `aise messages search`: thirty-nine options in one alphabetical block hid the
583/// six a caller needs first. Sections separate what to find, which messages, when, how many, how
584/// they are shown, and the diagnostics only scripts and maintainers reach for. Presentation
585/// options change how matches are shown and leave matching itself unchanged (REQ004); the heading
586/// says so.
587const 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    /// Text to find in the selected field. Literal mode is the default; choose regex or fuzzy with
640    /// --query-mode. Punctuation is significant in literal mode. Omit the query to list all. Pass
641    /// a leading-dash query via `-e` or after `--`.
642    #[arg(value_name = "QUERY", conflicts_with = "query_arg")]
643    pub positional_query: Option<String>,
644    /// Text to find. Use this for leading-dash strings, e.g. `-e --path`. Omit both this and the
645    /// positional query to list every message the other filters select.
646    #[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    /// Filter by role: user (non-command prompts), assistant, tool (calls/results),
655    /// slash (human-entered commands), or compaction. Omit for every role.
656    #[arg(help_heading = FILTER_HEADING, long = "role", value_enum)]
657    pub role: Option<Role>,
658    /// Restrict by semantic message kind; tool calls and results are distinct. One-value alias
659    /// for --kinds.
660    #[arg(help_heading = FILTER_HEADING, long, value_enum)]
661    pub kind: Option<MessageKind>,
662    /// Message classes to return. Omit for every class except harness-notice, which is what the
663    /// harness told the agent (Stop-hook feedback, PreToolUse blocks, local-command caveats,
664    /// task notifications) rather than what the user wrote. Pass harness-notice to answer why an
665    /// agent stopped, looped, or was blocked. This is the single class filter; --kind selects one.
666    #[arg(help_heading = FILTER_HEADING, long, value_enum, num_args = 1.., value_delimiter = ',', conflicts_with = "kind")]
667    pub kinds: Vec<MessageKind>,
668    /// QUERY searches only this field: content, canonical tool name, or one tool-argument path.
669    #[arg(help_heading = QUERY_HEADING, long, value_enum, default_value_t = SearchField::Content)]
670    pub field: SearchField,
671    /// RFC 6901 JSON pointer relative to tool-call args, e.g. /cmd or /request/path. Required with
672    /// `--field tool-argument`; omit it for any other field, where it selects nothing.
673    #[arg(help_heading = QUERY_HEADING, long)]
674    pub argument_path: Option<String>,
675    /// Restrict to these indexed session sources. Repeat --provider or pass a comma-separated
676    /// list; omit it to include all nine.
677    #[arg(help_heading = FILTER_HEADING, long = "provider", value_enum, value_delimiter = ',', action = clap::ArgAction::Append)]
678    pub providers: Vec<Provider>,
679    /// Interpret QUERY as a literal substring, Rust regex, or bounded fuzzy pattern.
680    #[arg(help_heading = QUERY_HEADING, long, value_enum, default_value_t = CliMessageQueryMode::Literal)]
681    pub query_mode: CliMessageQueryMode,
682    /// Scope to one exact session id or unique prefix. Omit to search every session.
683    #[arg(help_heading = FILTER_HEADING, long)]
684    pub session_id: Option<String>,
685    /// Restrict to sessions whose working directory or repository root is this directory or a
686    /// descendant of it (a component boundary: `project` matches `project/src`, never
687    /// `project-other`). Omit to search every root.
688    #[arg(help_heading = FILTER_HEADING, long)]
689    pub workspace_path: Option<String>,
690    /// Restrict to sessions whose transcript file is this path or under this directory (a
691    /// component boundary, so `logs` matches `logs/a.jsonl`, never `logs-old/`). Omit to search
692    /// every transcript path.
693    #[arg(help_heading = FILTER_HEADING, long)]
694    pub transcript_path: Option<String>,
695    /// Exclude sessions whose working directory or repository root is this directory or a
696    /// descendant of it (component boundary). Repeatable; omit to exclude no root.
697    #[arg(help_heading = FILTER_HEADING, long = "exclude-workspace-path")]
698    pub exclude_workspace_paths: Vec<String>,
699    /// Exclude sessions whose transcript file is this path or under this directory (component
700    /// boundary). Repeatable; omit to exclude no path.
701    #[arg(help_heading = FILTER_HEADING, long = "exclude-transcript-path")]
702    pub exclude_transcript_paths: Vec<String>,
703    /// Exclude one exact session id. Repeat to exclude multiple sessions; omit to exclude none.
704    #[arg(help_heading = FILTER_HEADING, long = "exclude-session")]
705    pub exclude_sessions: Vec<String>,
706    /// Also require canonical tool_name to contain this case-insensitive substring, independent
707    /// of --field (e.g. `exec` matches Codex `exec_command`; `edit` matches Claude `Edit`). Omit
708    /// to place no requirement on the tool name.
709    #[arg(help_heading = QUERY_HEADING, long)]
710    pub tool_name_contains: Option<String>,
711    #[command(flatten)]
712    pub dates: DateRange,
713    /// Lower inclusive message sequence bound. Only valid with --session-id because
714    /// seq numbers are local to each session. Omit to start at the first message.
715    #[arg(help_heading = FILTER_HEADING, long)]
716    pub seq_from: Option<i64>,
717    /// Upper inclusive message sequence bound. Only valid with --session-id because
718    /// seq numbers are local to each session. Omit to run to the last message.
719    #[arg(help_heading = FILTER_HEADING, long)]
720    pub seq_to: Option<i64>,
721    /// Include context-compaction messages. Pass an explicit boolean.
722    #[arg(help_heading = FILTER_HEADING, long, default_value_t = true, action = clap::ArgAction::Set)]
723    pub include_compaction: bool,
724    /// Which matching messages a bounded page keeps: earliest keeps the first N in
725    /// session/sequence order; latest keeps the last N matching messages of that session
726    /// (requires --session-id) and still returns them oldest first. Omit for earliest.
727    #[arg(help_heading = RESULTS_HEADING, long, value_enum)]
728    pub match_window: Option<MatchWindow>,
729    /// Show this many neighboring messages (0 or greater) on both sides of each match;
730    /// 0 (the default) shows only the match.
731    #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
732    pub context: Option<i64>,
733    /// Show this many neighboring messages (0 or greater) before each match
734    /// (overrides --context for before). Omit to follow --context, which is 0 when it is also
735    /// omitted.
736    #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
737    pub context_before: Option<i64>,
738    /// Show this many neighboring messages (0 or greater) after each match
739    /// (overrides --context for after). Omit to follow --context, which is 0 when it is also
740    /// omitted.
741    #[arg(help_heading = RESULTS_HEADING, long, value_parser = parse_context_count)]
742    pub context_after: Option<i64>,
743    /// Positive page size. Literal and regex select earliest matches unless --match-window latest
744    /// is used with one session. With no configured operation/purpose default, omission returns
745    /// every literal, regex, or no-text CLI match; MCP alone supplies an implicit finite page.
746    /// Fuzzy ranks every eligible match and requires an explicit/configured finite page.
747    /// Use --all-results to state an unbounded read explicitly in scripts.
748    #[arg(help_heading = RESULTS_HEADING, long, conflicts_with = "all_results")]
749    pub limit: Option<usize>,
750    /// Return every literal, regex, or no-text match. Fuzzy search is always bounded.
751    #[arg(help_heading = RESULTS_HEADING, long, conflicts_with = "limit")]
752    pub all_results: bool,
753    /// Skip this many matching messages before returning results. Fuzzy offsets apply after the
754    /// deterministic relevance order on the complete eligible corpus.
755    #[arg(help_heading = RESULTS_HEADING, long, default_value_t = 0)]
756    pub offset: usize,
757    /// Optional payload groups. Repeat or comma-delimit names; --include none requests only the
758    /// semantic core and cannot be combined with another name. Omit for no optional group on this
759    /// surface, which is where the CLI differs from MCP: MCP adds normalized session metadata by
760    /// default because it answers across arbitrary sessions, while a CLI caller can see the paths
761    /// they searched.
762    #[arg(help_heading = PRESENTATION_HEADING, long, value_delimiter = ',', action = clap::ArgAction::Append)]
763    pub include: Option<Vec<CliMessageSearchInclude>>,
764    /// Limit each returned message's displayed content without changing which messages return.
765    #[arg(help_heading = PRESENTATION_HEADING, long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
766    pub lines_per_message: Option<i64>,
767    /// Compact or full presentation preset. Conflicts with explicit line or character budgets.
768    /// Omit for this surface's own policy, which is the complete selected field with a match
769    /// window of 220 characters unless search.message_search.match_evidence_max_chars sets
770    /// another; presets exist to override that in one word.
771    #[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    /// Selected-field boundary view: no-char-limit or a positive Unicode-scalar count. Omit for
774    /// no-char-limit, so the CLI returns the complete field and leaves paging to the shell.
775    #[arg(help_heading = PRESENTATION_HEADING, long)]
776    pub field_view_chars: Option<CliFieldViewChars>,
777    /// Match-centered view: minimal or a positive Unicode-scalar count. Omit for 220 characters
778    /// around the match, unless search.message_search.match_evidence_max_chars sets another.
779    #[arg(help_heading = PRESENTATION_HEADING, long)]
780    pub match_view_chars: Option<CliMatchViewChars>,
781    /// Select a configured purpose bundle. Omit for none, which is also the only option until
782    /// `[search.purposes.<name>]` is configured.
783    #[arg(help_heading = ADVANCED_HEADING, long)]
784    pub purpose: Option<String>,
785    /// Require a specific configured purpose version. Omit to accept whichever version the named
786    /// purpose currently resolves to; it has no meaning without --purpose.
787    #[arg(help_heading = ADVANCED_HEADING, long, requires = "purpose")]
788    pub purpose_version: Option<std::num::NonZeroU32>,
789    /// Select receipt detail: none omits diagnostics, summary includes planner diagnostics, and
790    /// full adds resolved parameter origins.
791    #[arg(help_heading = ADVANCED_HEADING, long, value_enum)]
792    pub receipt_level: Option<ReceiptLevel>,
793    /// Print the executable parameter catalogue and configured CLI defaults without searching the
794    /// index. Search parameters conflict with this flag.
795    #[arg(help_heading = ADVANCED_HEADING, long, conflicts_with = "search_request")]
796    pub describe: bool,
797    /// Resolve defaults for one caller surface. Requires --describe; omission describes this CLI.
798    #[arg(help_heading = ADVANCED_HEADING, long, value_enum, requires = "describe")]
799    pub describe_surface: Option<SearchSurface>,
800    /// Output format. Search defaults to table; --describe defaults to and requires json. `plain`
801    /// is headerless and tab-separated; `csv` includes the table header. JSON is one document;
802    /// JSONL is an incrementally consumable stream.
803    #[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    /// Session id or prefix.
824    pub id: String,
825    /// Select one message by its 0-based sequence number and return a focused window instead of
826    /// the whole session. A sequence past the session's end returns no rows. Omit to read the
827    /// whole session.
828    #[arg(long)]
829    pub seq: Option<i64>,
830    /// With --seq, include this many neighboring messages (0 or greater) before and after the
831    /// selected seq; 0 (the default) shows only the selected message.
832    #[arg(long, default_value_t = 0, value_parser = parse_context_count)]
833    pub context: i64,
834    /// Include extracted URL references in output for the focused --seq window or whole session.
835    #[arg(long)]
836    pub refs: bool,
837    /// Filter by role. Omit for every role.
838    #[arg(long = "role", value_enum)]
839    pub role: Option<Role>,
840    #[command(flatten)]
841    pub dates: DateRange,
842    /// Maximum messages to return; 0 (the default) returns all. Selection is oldest-first by
843    /// sequence unless --order newest, so `--limit 75 --order newest` reads the 75 most recent
844    /// messages. Pair with --seq-from/--seq-to or --offset to page.
845    #[arg(long, default_value_t = 0)]
846    pub limit: usize,
847    /// Which end the --limit window is taken from: oldest (first N by sequence, the default) or
848    /// newest (last N). Results are always printed oldest-first; order selects, it does not just
849    /// reverse display.
850    #[arg(long, value_enum, default_value_t = ReadOrder::Oldest)]
851    pub order: ReadOrder,
852    /// Skip this many messages from the leading edge of the window before returning (count
853    /// pagination companion to --limit). For guaranteed non-overlapping chunked reads prefer
854    /// --seq-from/--seq-to, which pin absolute sequence numbers rather than a moving offset.
855    #[arg(long, default_value_t = 0)]
856    pub offset: usize,
857    /// Lower inclusive sequence bound (sequences are 0-based). With --seq-to this reads an
858    /// absolute \[from,to\] range, so successive chunks (0..499, then 500..999) never re-read
859    /// the same messages. Omit to start at the first message.
860    #[arg(long)]
861    pub seq_from: Option<i64>,
862    /// Upper inclusive sequence bound. See --seq-from. Omit to run to the last message.
863    #[arg(long)]
864    pub seq_to: Option<i64>,
865    /// Limit each returned message's displayed content without changing which messages return.
866    #[arg(long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
867    pub lines_per_message: Option<i64>,
868    /// Output format. Without --refs, get has 7 fields:
869    /// session, provider, seq, role, tool, ts, content. `plain` is headerless and
870    /// tab-separated; `csv` includes the same header as `table`. --refs inserts refs before
871    /// content. Content is always last. JSON/JSONL keep complete content unless
872    /// --lines-per-message caps it.
873    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
874    pub format: OutputFormat,
875}
876
877#[derive(Debug, Args)]
878pub struct TimelineArgs {
879    /// Session id or prefix.
880    pub id: String,
881    /// Filter by role. Omit for every role.
882    #[arg(long = "role", value_enum)]
883    pub role: Option<Role>,
884    /// Keep only messages containing this literal substring. Omit to keep every message the other
885    /// filters select. Mutually exclusive with `--regex` (which would otherwise silently win).
886    #[arg(long, conflicts_with = "regex")]
887    pub grep: Option<String>,
888    /// Keep only messages matching this Rust regex. Omit to keep every message the other filters
889    /// select.
890    #[arg(long)]
891    pub regex: Option<String>,
892    /// Include extracted URL references in output for timeline rows.
893    #[arg(long)]
894    pub refs: bool,
895    /// Lower inclusive message sequence bound. Omit to start at the first message.
896    #[arg(long)]
897    pub seq_from: Option<i64>,
898    /// Upper inclusive message sequence bound. Omit to run to the last message.
899    #[arg(long)]
900    pub seq_to: Option<i64>,
901    /// Exclude context-compaction messages.
902    #[arg(long)]
903    pub no_compaction: bool,
904    /// Message classes to show. Omit for every class except harness-notice, which is what the
905    /// harness told the agent rather than what the user wrote. This is the single class filter.
906    #[arg(long, value_enum, num_args = 1.., value_delimiter = ',')]
907    pub kinds: Vec<MessageKind>,
908    #[command(flatten)]
909    pub dates: DateRange,
910    /// Maximum messages to return; 0 (the default) returns all. Selection is oldest-first by
911    /// sequence unless --order newest. To read a long session in chunks, advance --seq-from
912    /// (next chunk starts at the last seq + 1) rather than growing --limit, which re-sends what
913    /// you already read.
914    #[arg(long, default_value_t = 0)]
915    pub limit: usize,
916    /// Which end the --limit window is taken from: oldest (first N by sequence, the default) or
917    /// newest (last N). Results are always printed oldest-first; order selects, it does not just
918    /// reverse display.
919    #[arg(long, value_enum, default_value_t = ReadOrder::Oldest)]
920    pub order: ReadOrder,
921    /// Skip this many messages from the leading edge of the window (count pagination companion to
922    /// --limit). Prefer --seq-from/--seq-to for guaranteed non-overlapping chunked reads.
923    #[arg(long, default_value_t = 0)]
924    pub offset: usize,
925    /// Limit each returned message's displayed content without changing which messages return.
926    #[arg(long, allow_hyphen_values = true, long_help = LINES_PER_MESSAGE_HELP)]
927    pub lines_per_message: Option<i64>,
928    /// Output format. Without --refs, timeline has 7 fields:
929    /// session, provider, seq, role, tool, ts, content. `plain` is headerless and
930    /// tab-separated; `csv` includes the same header as `table`. --refs inserts refs before
931    /// content. Content is always last. JSON/JSONL keep complete content unless
932    /// --lines-per-message caps it.
933    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
934    pub format: OutputFormat,
935}
936
937#[derive(Debug, Args)]
938pub struct MessageEvidenceArgs {
939    /// Session id or unique prefix.
940    pub id: String,
941    /// Maximum characters per preview in the compact summary (1 or greater). Omit to use
942    /// `[cli].evidence_preview_chars` from config.
943    #[arg(long, value_parser = crate::cli::parse_positive_usize)]
944    pub preview_chars: Option<usize>,
945    /// Aggregate evidence window: positive=first, negative=last, 0=all. Omit to use
946    /// `[cli].summary_items` from config.
947    #[arg(long, allow_hyphen_values = true)]
948    pub summary_items: Option<i64>,
949    /// Add bounded optional evidence sections. Repeatable; omit to return only the core evidence.
950    #[arg(long, value_enum)]
951    pub include: Vec<EvidenceInclude>,
952    /// Output format. `json`/`jsonl` return one structured inspection object; table/csv/plain
953    /// flatten it into section/key/value rows.
954    #[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            // Order selects which N (oldest vs newest); a newest-first fetch is reversed back to
1049            // chronological so the timeline always prints oldest→newest.
1050            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
1079/// Handle configured message-search introspection before the CLI opens or refreshes an index.
1080///
1081/// Returning `true` means the command was fully handled. Search execution remains in [`run`].
1082pub(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    // clap enforces that --kind and --kinds are not both given, so this cannot silently
1146    // discard one: they are two spellings of one selection, not two filters that combine.
1147    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
1615/// Structured CLI formats return self-describing output rather than a bare row array.
1616///
1617/// JSONL has explicit metadata, row, and `search_end` records. The end record proves natural stream
1618/// termination; its separate result-page extent says whether the page covers all matching rows.
1619fn 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        // MessageSearchResponse delegates to the canonical semantic document. Encoding directly
1627        // avoids the former O(P) second serde_json::Value tree, where P is encoded response bytes.
1628        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            // Encode one borrowed canonical result at a time. This retains only the response's
1669            // existing rows plus the serializer buffer; it never builds a second all-results tree.
1670            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
1708/// Clap value parser for context counts: 0 or greater, matching the MCP schema
1709/// (`minimum: 0`) and the Python binding, which both reject negatives. A negative
1710/// context has no meaning, so clamping it quietly would hide a caller mistake.
1711fn 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            // Refs come from the full content so a per-message line cap never hides references.
1757            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        // CSV leads with the header row; content is the last column.
2048        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        // Plain is headerless, tab-separated, seven fields with content last.
2058        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        // JSON keeps the serde field names and full untruncated content.
2068        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        // Table prints the header row then the padded body.
2074        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        // ContextRow marks the matched row with `*` and a non-match with blank.
2088        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        // MessageHitWithRefs inserts a `refs` column before content.
2098        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        // ContextRowWithRefs carries both the `match` and `refs` columns.
2118        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        // All range/paging/order read flags coexist on one invocation.
2355        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        // --order is a closed enum: only oldest/newest, never a free value or a sign.
2373        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        // Newest N is `--limit N --order newest`, NOT a signed limit. A negative --limit is a
2380        // parser hazard and unprecedented among leading CLIs, so it must be rejected.
2381        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        // The CLI ReadOrder enum drives the db read direction; verify the mapping,
2408        // not just that the flag parses. oldest -> OldestFirst, newest -> NewestFirst.
2409        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        // The default direction is oldest-first, matching --order's default and the
2418        // documented "oldest-first unless --order newest" contract.
2419        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        // 0 is the load-bearing value: the match alone, no neighbors.
2429        assert_eq!(parse_context_count("0").unwrap(), 0);
2430        assert_eq!(parse_context_count("5").unwrap(), 5);
2431        // A large value is accepted; saturating to session bounds is the reader's job.
2432        assert_eq!(parse_context_count("1000000").unwrap(), 1_000_000);
2433        // Negative is rejected with actionable guidance that names the 0 case.
2434        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        // Non-integer and empty are rejected as integers, never silently coerced to 0.
2438        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        // Negative limit is the parser hazard; newest-N is --order newest, not a sign.
2470        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}