1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::num::NonZeroUsize;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
10#[serde(rename_all = "lowercase")]
11pub enum Provider {
12 Claude,
13 #[serde(rename = "claude-desktop")]
14 #[clap(name = "claude-desktop", alias = "claude_desktop")]
15 ClaudeDesktop,
16 Codex,
17 Cursor,
18 Antigravity,
19 Pi,
20 #[serde(rename = "prime-agent")]
21 #[clap(name = "prime-agent", alias = "prime_agent")]
22 PrimeAgent,
23 #[serde(rename = "aistudio")]
24 #[clap(name = "aistudio", alias = "ai-studio")]
25 AiStudio,
26 #[serde(rename = "gemini-cli")]
27 #[clap(name = "gemini-cli", alias = "gemini_cli")]
28 GeminiCli,
29}
30
31impl Provider {
32 pub fn as_str(self) -> &'static str {
33 match self {
34 Self::Claude => "claude",
35 Self::ClaudeDesktop => "claude-desktop",
36 Self::Codex => "codex",
37 Self::Cursor => "cursor",
38 Self::Antigravity => "antigravity",
39 Self::Pi => "pi",
40 Self::PrimeAgent => "prime-agent",
41 Self::AiStudio => "aistudio",
42 Self::GeminiCli => "gemini-cli",
43 }
44 }
45
46 pub const fn display_name(self) -> &'static str {
48 match self {
49 Self::Claude => "Claude Code",
50 Self::ClaudeDesktop => "Claude Desktop local agent",
51 Self::Codex => "Codex",
52 Self::Cursor => "Cursor",
53 Self::Antigravity => "Antigravity",
54 Self::Pi => "Pi coding agent",
55 Self::PrimeAgent => "Prime Agent",
56 Self::AiStudio => "Google AI Studio",
57 Self::GeminiCli => "Gemini CLI",
58 }
59 }
60
61 pub const fn supports_native_resume(self) -> bool {
63 matches!(
64 self,
65 Self::Claude | Self::Codex | Self::Pi | Self::PrimeAgent
66 )
67 }
68
69 pub fn from_db_str(value: &str) -> Result<Self, String> {
77 value.parse().map_err(|_| {
78 format!(
79 "unrecognized provider in index: {value:?}; this aise {} does not recognize it, \
80 so a newer aise wrote the index: upgrade aise (`aise package update`) and retry",
81 env!("CARGO_PKG_VERSION")
82 )
83 })
84 }
85}
86
87impl std::fmt::Display for Provider {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str(self.as_str())
90 }
91}
92
93impl std::str::FromStr for Provider {
94 type Err = String;
95
96 fn from_str(value: &str) -> Result<Self, Self::Err> {
97 match value.to_ascii_lowercase().as_str() {
98 "claude" => Ok(Self::Claude),
99 "claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(Self::ClaudeDesktop),
100 "codex" => Ok(Self::Codex),
101 "cursor" => Ok(Self::Cursor),
102 "antigravity" => Ok(Self::Antigravity),
103 "pi" => Ok(Self::Pi),
104 "prime-agent" | "prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
105 "aistudio" | "ai-studio" | "ai_studio" => Ok(Self::AiStudio),
106 "gemini-cli" | "gemini_cli" | "geminicli" => Ok(Self::GeminiCli),
107 other => Err(format!(
110 "unsupported provider: {other} — must be one of \"claude\", \"claude-desktop\", \"codex\", \"cursor\", \"antigravity\", \"pi\", \"prime-agent\", \"aistudio\", \"gemini-cli\""
111 )),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
118#[serde(rename_all = "lowercase")]
119#[clap(rename_all = "lowercase")]
120pub enum Role {
121 User,
122 Assistant,
123 Tool,
124 Slash,
125 Compaction,
126}
127
128impl Role {
129 pub fn as_str(self) -> &'static str {
130 match self {
131 Self::User => "user",
132 Self::Assistant => "assistant",
133 Self::Tool => "tool",
134 Self::Slash => "slash",
135 Self::Compaction => "compaction",
136 }
137 }
138
139 pub fn from_db_str(value: &str) -> Result<Self, String> {
144 value.parse().map_err(|_| {
145 format!(
146 "unrecognized role in index: {value:?}; stop AISE processes, then run \
147 `aise reindex --full` and retry"
148 )
149 })
150 }
151}
152
153impl std::fmt::Display for Role {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.write_str(self.as_str())
156 }
157}
158
159impl std::str::FromStr for Role {
160 type Err = String;
161
162 fn from_str(value: &str) -> Result<Self, Self::Err> {
163 match value.to_ascii_lowercase().as_str() {
164 "user" => Ok(Self::User),
165 "assistant" => Ok(Self::Assistant),
166 "tool" => Ok(Self::Tool),
167 "slash" => Ok(Self::Slash),
168 "compaction" => Ok(Self::Compaction),
169 other => Err(format!(
172 "unknown role: {other} — must be one of \"user\", \"assistant\", \"tool\", \"slash\", \"compaction\""
173 )),
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum KindPredicate {
183 AllExcept(Vec<MessageKind>),
185 Only(Vec<MessageKind>),
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
196#[serde(rename_all = "snake_case")]
197#[clap(rename_all = "kebab-case")]
198pub enum MessageKind {
199 Conversation,
200 Compaction,
201 ToolCall,
202 ToolResult,
203 HarnessNotice,
209 Unknown,
210}
211
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
214#[serde(rename_all = "lowercase")]
215#[clap(rename_all = "lowercase")]
216pub enum MessageSearchMode {
217 #[default]
218 Literal,
219 Regex,
220 Fuzzy,
221}
222
223impl MessageSearchMode {
224 pub const fn as_str(self) -> &'static str {
225 match self {
226 Self::Literal => "literal",
227 Self::Regex => "regex",
228 Self::Fuzzy => "fuzzy",
229 }
230 }
231}
232
233impl std::str::FromStr for MessageSearchMode {
234 type Err = String;
235
236 fn from_str(value: &str) -> Result<Self, Self::Err> {
237 match value.to_ascii_lowercase().as_str() {
238 "literal" => Ok(Self::Literal),
239 "regex" => Ok(Self::Regex),
240 "fuzzy" => Ok(Self::Fuzzy),
241 other => Err(format!(
242 "unknown message search mode: {other}; expected literal, regex, or fuzzy"
243 )),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
249#[serde(rename_all = "snake_case")]
250#[clap(rename_all = "kebab-case")]
251pub enum SearchField {
252 Content,
253 ToolName,
254 ToolArgument,
255}
256
257impl SearchField {
258 pub const fn as_str(self) -> &'static str {
259 match self {
260 Self::Content => "content",
261 Self::ToolName => "tool_name",
262 Self::ToolArgument => "tool_argument",
263 }
264 }
265}
266
267impl std::str::FromStr for SearchField {
268 type Err = String;
269
270 fn from_str(value: &str) -> Result<Self, Self::Err> {
271 match value.to_ascii_lowercase().replace('-', "_").as_str() {
272 "content" => Ok(Self::Content),
273 "tool_name" => Ok(Self::ToolName),
274 "tool_argument" => Ok(Self::ToolArgument),
275 other => Err(format!(
276 "unknown message search field: {other} — must be one of \"content\", \"tool_name\", \"tool_argument\""
277 )),
278 }
279 }
280}
281
282impl MessageKind {
283 pub fn as_str(&self) -> &'static str {
284 match self {
285 Self::Conversation => "conversation",
286 Self::Compaction => "compaction",
287 Self::ToolCall => "tool_call",
288 Self::ToolResult => "tool_result",
289 Self::HarnessNotice => "harness_notice",
290 Self::Unknown => "unknown",
291 }
292 }
293
294 pub fn default_search_set() -> Vec<Self> {
299 use clap::ValueEnum;
300 Self::value_variants()
301 .iter()
302 .copied()
303 .filter(|kind| *kind != Self::HarnessNotice)
304 .collect()
305 }
306
307 pub const EXCLUDABLE_BY_DEFAULT: &'static [Self] = &[Self::Compaction, Self::HarnessNotice];
314
315 pub fn from_db_str(value: &str) -> Self {
331 value.parse().unwrap_or(Self::Unknown)
332 }
333}
334
335impl std::fmt::Display for MessageKind {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.write_str(self.as_str())
338 }
339}
340
341impl std::str::FromStr for MessageKind {
342 type Err = String;
343
344 fn from_str(value: &str) -> Result<Self, Self::Err> {
345 match value.to_ascii_lowercase().replace('-', "_").as_str() {
346 "conversation" => Ok(Self::Conversation),
347 "compaction" => Ok(Self::Compaction),
348 "tool_call" => Ok(Self::ToolCall),
349 "tool_result" => Ok(Self::ToolResult),
350 "harness_notice" => Ok(Self::HarnessNotice),
351 "unknown" => Ok(Self::Unknown),
352 other => Err(format!(
353 "unknown message kind: {other} — must be one of \"conversation\", \"compaction\", \"tool_call\", \"tool_result\", \"harness_notice\", \"unknown\""
354 )),
355 }
356 }
357}
358
359#[derive(
365 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum,
366)]
367#[serde(rename_all = "snake_case")]
368#[clap(rename_all = "kebab-case")]
369pub enum MessageAuthorship {
370 Human,
371 Agent,
372 Harness,
373 Generated,
374 Mixed,
375 #[default]
376 Unknown,
377}
378
379impl MessageAuthorship {
380 pub const fn as_str(self) -> &'static str {
381 match self {
382 Self::Human => "human",
383 Self::Agent => "agent",
384 Self::Harness => "harness",
385 Self::Generated => "generated",
386 Self::Mixed => "mixed",
387 Self::Unknown => "unknown",
388 }
389 }
390
391 pub fn from_db_str(value: &str) -> Self {
392 value.parse().unwrap_or(Self::Unknown)
393 }
394}
395
396impl std::str::FromStr for MessageAuthorship {
397 type Err = String;
398
399 fn from_str(value: &str) -> Result<Self, Self::Err> {
400 match value.to_ascii_lowercase().replace('-', "_").as_str() {
401 "human" => Ok(Self::Human),
402 "agent" => Ok(Self::Agent),
403 "harness" => Ok(Self::Harness),
404 "generated" => Ok(Self::Generated),
405 "mixed" => Ok(Self::Mixed),
406 "unknown" => Ok(Self::Unknown),
407 other => Err(format!(
408 "unknown message authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"mixed\", \"unknown\""
409 )),
410 }
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum ContentPartAuthorship {
421 Human,
422 Agent,
423 Harness,
424 Generated,
425 Unknown,
426}
427
428impl ContentPartAuthorship {
429 pub const fn as_str(self) -> &'static str {
430 match self {
431 Self::Human => "human",
432 Self::Agent => "agent",
433 Self::Harness => "harness",
434 Self::Generated => "generated",
435 Self::Unknown => "unknown",
436 }
437 }
438
439 pub fn from_db_str(value: &str) -> Self {
440 value.parse().unwrap_or(Self::Unknown)
441 }
442}
443
444impl std::str::FromStr for ContentPartAuthorship {
445 type Err = String;
446
447 fn from_str(value: &str) -> Result<Self, Self::Err> {
448 match value.to_ascii_lowercase().replace('-', "_").as_str() {
449 "human" => Ok(Self::Human),
450 "agent" => Ok(Self::Agent),
451 "harness" => Ok(Self::Harness),
452 "generated" => Ok(Self::Generated),
453 "unknown" => Ok(Self::Unknown),
454 other => Err(format!(
455 "unknown content-part authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"unknown\""
456 )),
457 }
458 }
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
464#[serde(rename_all = "snake_case")]
465pub enum ContentPartOrigin {
466 DirectInput,
467 QuotedContent,
468 HarnessContext,
469 GeneratedSummary,
470 ToolPayload,
471 Unknown,
472}
473
474impl ContentPartOrigin {
475 pub const fn as_str(self) -> &'static str {
476 match self {
477 Self::DirectInput => "direct_input",
478 Self::QuotedContent => "quoted_content",
479 Self::HarnessContext => "harness_context",
480 Self::GeneratedSummary => "generated_summary",
481 Self::ToolPayload => "tool_payload",
482 Self::Unknown => "unknown",
483 }
484 }
485
486 pub fn from_db_str(value: &str) -> Self {
487 value.parse().unwrap_or(Self::Unknown)
488 }
489}
490
491impl std::str::FromStr for ContentPartOrigin {
492 type Err = String;
493
494 fn from_str(value: &str) -> Result<Self, Self::Err> {
495 match value.to_ascii_lowercase().replace('-', "_").as_str() {
496 "direct_input" => Ok(Self::DirectInput),
497 "quoted_content" => Ok(Self::QuotedContent),
498 "harness_context" => Ok(Self::HarnessContext),
499 "generated_summary" => Ok(Self::GeneratedSummary),
500 "tool_payload" => Ok(Self::ToolPayload),
501 "unknown" => Ok(Self::Unknown),
502 other => Err(format!(
503 "unknown content-part origin: {other} — must be one of \"direct_input\", \"quoted_content\", \"harness_context\", \"generated_summary\", \"tool_payload\", \"unknown\""
504 )),
505 }
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct MessageContentPart {
515 pub ordinal: u32,
516 pub start_char: usize,
517 pub end_char: usize,
518 pub authorship: ContentPartAuthorship,
519 pub origin: ContentPartOrigin,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
528#[serde(rename_all = "snake_case")]
529pub enum MessageCorrelationAuthority {
530 Anthropic,
531 OpenAi,
532 Cursor,
533 Google,
534 Pi,
535 PrimeAgent,
536}
537
538impl MessageCorrelationAuthority {
539 pub const fn as_str(self) -> &'static str {
540 match self {
541 Self::Anthropic => "anthropic",
542 Self::OpenAi => "open_ai",
543 Self::Cursor => "cursor",
544 Self::Google => "google",
545 Self::Pi => "pi",
546 Self::PrimeAgent => "prime_agent",
547 }
548 }
549
550 pub fn from_db_str(value: &str) -> Option<Self> {
551 value.parse().ok()
552 }
553}
554
555impl std::str::FromStr for MessageCorrelationAuthority {
556 type Err = String;
557
558 fn from_str(value: &str) -> Result<Self, Self::Err> {
559 match value.to_ascii_lowercase().replace('-', "_").as_str() {
560 "anthropic" => Ok(Self::Anthropic),
561 "open_ai" | "openai" => Ok(Self::OpenAi),
562 "cursor" => Ok(Self::Cursor),
563 "google" => Ok(Self::Google),
564 "pi" => Ok(Self::Pi),
565 "prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
566 other => Err(format!(
567 "unknown message correlation authority: {other} — must be one of \"anthropic\", \"open_ai\", \"cursor\", \"google\", \"pi\", \"prime_agent\""
568 )),
569 }
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
578pub struct MessageCorrelationIdentity {
579 pub authority: MessageCorrelationAuthority,
580 pub scope: String,
581 pub id: String,
582}
583
584#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum MessageRecordRelation {
588 Original,
589 Mirror,
590 #[default]
591 Unknown,
592}
593
594impl MessageRecordRelation {
595 pub const fn as_str(self) -> &'static str {
596 match self {
597 Self::Original => "original",
598 Self::Mirror => "mirror",
599 Self::Unknown => "unknown",
600 }
601 }
602
603 pub fn from_db_str(value: &str) -> Option<Self> {
604 value.parse().ok()
605 }
606}
607
608impl std::str::FromStr for MessageRecordRelation {
609 type Err = String;
610
611 fn from_str(value: &str) -> Result<Self, Self::Err> {
612 match value.to_ascii_lowercase().as_str() {
613 "original" => Ok(Self::Original),
614 "mirror" => Ok(Self::Mirror),
615 "unknown" => Ok(Self::Unknown),
616 other => Err(format!(
617 "unknown correlated record relation: {other} — must be one of \"original\", \"mirror\", \"unknown\""
618 )),
619 }
620 }
621}
622
623#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
624pub struct MessageProvenance {
625 pub authorship: MessageAuthorship,
626 pub record_relation: MessageRecordRelation,
627 pub correlation_identity: Option<MessageCorrelationIdentity>,
628 pub content_parts: Vec<MessageContentPart>,
629}
630
631impl MessageProvenance {
632 pub fn validate(&self, content: &str) -> Result<(), String> {
634 if let Some(identity) = &self.correlation_identity {
635 if identity.scope.trim().is_empty() {
636 return Err("message correlation scope must not be empty".to_string());
637 }
638 if identity.id.trim().is_empty() {
639 return Err("message correlation id must not be empty".to_string());
640 }
641 }
642
643 if self.authorship != MessageAuthorship::Mixed {
644 if !self.content_parts.is_empty() {
645 return Err(
646 "content_parts are allowed only when message authorship is \"mixed\""
647 .to_string(),
648 );
649 }
650 return Ok(());
651 }
652 if self.content_parts.is_empty() {
653 return Err("mixed message authorship requires content_parts".to_string());
654 }
655
656 let content_chars = content.chars().count();
657 let mut expected_start = 0;
658 let mut first_authorship = None;
659 let mut has_distinct_authorship = false;
660 for (index, part) in self.content_parts.iter().enumerate() {
661 if part.ordinal as usize != index {
662 return Err(format!(
663 "content part ordinal {} must equal its zero-based position {index}",
664 part.ordinal
665 ));
666 }
667 if part.start_char != expected_start {
668 return Err(format!(
669 "content part {index} must start at character {expected_start}, not {}",
670 part.start_char
671 ));
672 }
673 if part.end_char <= part.start_char {
674 return Err(format!(
675 "content part {index} must have a non-empty half-open character range"
676 ));
677 }
678 if part.end_char > content_chars {
679 return Err(format!(
680 "content part {index} ends at character {}, beyond message length {content_chars}",
681 part.end_char
682 ));
683 }
684 if let Some(first) = first_authorship {
685 has_distinct_authorship |= first != part.authorship;
686 } else {
687 first_authorship = Some(part.authorship);
688 }
689 expected_start = part.end_char;
690 }
691 if expected_start != content_chars {
692 return Err(format!(
693 "content parts end at character {expected_start}, before message length {content_chars}; represent gaps as explicit \"unknown\" parts"
694 ));
695 }
696 if !has_distinct_authorship {
697 return Err(
698 "mixed message authorship requires at least two distinct part authorships"
699 .to_string(),
700 );
701 }
702 Ok(())
703 }
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct Message {
708 pub seq: i64,
709 pub role: Role,
710 pub ts: Option<DateTime<Utc>>,
711 pub tool_name: Option<String>,
712 pub kind: MessageKind,
713 pub tool_call_id: Option<String>,
714 pub is_compaction: bool,
715 pub content: String,
716 #[serde(default)]
717 pub provenance: MessageProvenance,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct SessionRecord {
722 pub id: String,
723 pub provider: Provider,
724 pub provider_session_id: String,
725 pub title: Option<String>,
726 pub summary: Option<String>,
727 pub cwd: Option<String>,
728 pub repo_root: Option<String>,
729 pub created_at: Option<DateTime<Utc>>,
730 pub updated_at: Option<DateTime<Utc>>,
731 pub last_message_at: Option<DateTime<Utc>>,
732 pub preview_text: String,
733 pub source_path: String,
734 pub message_count: Option<i64>,
735 pub parse_version: String,
736 pub raw_metadata_json: Option<String>,
737 pub parse_warning: Option<String>,
738 pub discovery_source: String,
739 pub parent_session_id: Option<String>,
759 pub agent_label: Option<String>,
764}
765
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768#[serde(transparent)]
769pub struct AnalysisCursor(String);
770
771impl AnalysisCursor {
772 pub fn as_str(&self) -> &str {
773 &self.0
774 }
775
776 pub(crate) fn after(session_id: String) -> Self {
777 Self(session_id)
778 }
779}
780
781#[derive(Debug, Clone, Serialize)]
787pub struct AnalysisDocument {
788 pub session: SessionRecord,
789 pub user_text: String,
790 pub first_user_text: Option<String>,
791 pub message_count: i64,
792 pub user_message_count: i64,
793}
794
795#[derive(Debug, Clone, Serialize)]
797pub struct AnalysisDocumentPage {
798 pub documents: Vec<AnalysisDocument>,
799 pub next_cursor: Option<AnalysisCursor>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct FileEdit {
808 pub seq: i64,
810 pub ts: Option<DateTime<Utc>>,
811 pub tool: String,
813 pub file_path: String,
814 pub file_name: String,
816 pub new_content: Option<String>,
818 pub edits: Vec<EditOp>,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct EditOp {
828 pub old: String,
829 pub new: String,
830 #[serde(default)]
832 pub replace_all: bool,
833}
834
835impl EditOp {
836 pub fn new(old: impl Into<String>, new: impl Into<String>) -> Self {
838 Self {
839 old: old.into(),
840 new: new.into(),
841 replace_all: false,
842 }
843 }
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct ParsedSession {
848 pub session: SessionRecord,
849 pub transcript_text: String,
850 pub messages: Vec<Message>,
852 pub file_edits: Vec<FileEdit>,
854}
855
856#[derive(Debug, Clone)]
857pub struct SourceFile {
858 pub provider: Provider,
859 pub path: std::path::PathBuf,
860 pub mtime_ns: i64,
861 pub size_bytes: i64,
862}
863
864#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
886#[serde(rename_all = "snake_case")]
887#[clap(rename_all = "kebab-case")]
888pub enum SessionKind {
889 User,
892 Subagent,
898}
899
900impl SessionKind {
901 pub fn as_str(self) -> &'static str {
902 match self {
903 Self::User => "user",
904 Self::Subagent => "subagent",
905 }
906 }
907
908 pub fn all() -> Vec<Self> {
911 use clap::ValueEnum;
912 Self::value_variants().to_vec()
913 }
914
915 pub fn default_search_set() -> Vec<Self> {
923 Self::all()
924 }
925
926 pub fn sql_predicate(self, alias: &str) -> String {
929 match self {
930 Self::User => format!("{alias}.parent_session_id is null"),
931 Self::Subagent => format!("{alias}.parent_session_id is not null"),
932 }
933 }
934}
935
936impl std::fmt::Display for SessionKind {
937 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938 f.write_str(self.as_str())
939 }
940}
941
942impl std::str::FromStr for SessionKind {
943 type Err = String;
944
945 fn from_str(value: &str) -> Result<Self, Self::Err> {
946 match value.to_ascii_lowercase().replace('-', "_").as_str() {
947 "user" => Ok(Self::User),
948 "subagent" => Ok(Self::Subagent),
949 other => Err(format!(
950 "unknown session kind: {other} — must be one of \"user\" (a session you started) or \"subagent\" (a run one of those spawned)"
951 )),
952 }
953 }
954}
955
956#[derive(Debug, Clone, Default)]
957pub struct SearchFilters {
958 pub provider: Option<Provider>,
959 pub session_kinds: Option<Vec<SessionKind>>,
966 pub path_prefix: Option<String>,
967 pub exclude_path_prefixes: Vec<String>,
968 pub exclude_session_ids: Vec<String>,
969 pub parent_session_id: Option<String>,
973 pub since: Option<DateTime<Utc>>,
976 pub until: Option<DateTime<Utc>>,
979 pub limit: usize,
980 pub warnings_only: bool,
981}
982
983impl SearchFilters {
984 pub fn effective_session_kinds(&self) -> Vec<SessionKind> {
987 self.session_kinds
988 .clone()
989 .unwrap_or_else(SessionKind::default_search_set)
990 }
991
992 pub fn validate(&self) -> anyhow::Result<()> {
994 let only_user_sessions = self.session_kinds.as_ref().is_some_and(|kinds| {
995 !kinds.is_empty() && kinds.iter().all(|kind| *kind == SessionKind::User)
996 });
997 if self.parent_session_id.is_some() && only_user_sessions {
998 anyhow::bail!(
999 "session_kinds cannot contain only \"user\" when parent_session_id is set; pass [\"subagent\"], omit session_kinds to use both classes, or omit parent_session_id to search user-started sessions"
1000 );
1001 }
1002 Ok(())
1003 }
1004}
1005
1006#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1011#[serde(tag = "kind", rename_all = "snake_case")]
1012pub enum AnalysisSessionSelection {
1013 #[default]
1015 AllEligible,
1016 FirstCanonicalSessions { max_sessions: NonZeroUsize },
1018}
1019
1020impl AnalysisSessionSelection {
1021 pub(crate) const fn max_sessions(self) -> Option<NonZeroUsize> {
1022 match self {
1023 Self::AllEligible => None,
1024 Self::FirstCanonicalSessions { max_sessions } => Some(max_sessions),
1025 }
1026 }
1027}
1028
1029#[derive(Debug, Clone)]
1031pub struct AnalysisRequest {
1032 scope: SearchFilters,
1033 selection: AnalysisSessionSelection,
1034}
1035
1036impl AnalysisRequest {
1037 pub fn new(scope: SearchFilters, selection: AnalysisSessionSelection) -> anyhow::Result<Self> {
1039 scope.validate()?;
1040 if scope.limit != 0 {
1041 anyhow::bail!(
1042 "analysis does not accept SearchFilters.limit={}; set it to 0 and choose \
1043 AnalysisSessionSelection::FirstCanonicalSessions {{ max_sessions }} for the \
1044 explicitly labeled canonical-order prefix, or AllEligible for every session in \
1045 scope",
1046 scope.limit
1047 );
1048 }
1049 Ok(Self { scope, selection })
1050 }
1051
1052 pub const fn scope(&self) -> &SearchFilters {
1054 &self.scope
1055 }
1056
1057 pub const fn selection(&self) -> AnalysisSessionSelection {
1059 self.selection
1060 }
1061}
1062
1063#[derive(Debug, Clone, Serialize)]
1064pub struct SessionWithTranscript {
1065 #[serde(flatten)]
1066 pub session: SessionRecord,
1067 pub transcript_text: String,
1068}
1069
1070#[derive(Debug, Clone, Serialize)]
1071pub struct SearchHit {
1072 #[serde(flatten)]
1073 pub session: SessionRecord,
1074 pub score: i64,
1075 pub match_source: String,
1076 pub match_snippet: String,
1077}
1078
1079#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1082#[serde(default, deny_unknown_fields)]
1083pub struct MessageFilters {
1084 pub role: Option<Role>,
1085 pub kinds: Option<Vec<MessageKind>>,
1097 pub field: Option<SearchField>,
1100 pub argument_path: Option<String>,
1102 pub providers: Option<Vec<Provider>>,
1105 pub session_id: Option<String>,
1109 pub path_prefix: Option<String>,
1114 pub exclude_path_prefixes: Vec<String>,
1117 #[doc(hidden)]
1120 pub workspace_path_prefix: Option<String>,
1121 #[doc(hidden)]
1123 pub transcript_path_prefix: Option<String>,
1124 #[doc(hidden)]
1125 pub exclude_workspace_path_prefixes: Vec<String>,
1126 #[doc(hidden)]
1127 pub exclude_transcript_path_prefixes: Vec<String>,
1128 pub exclude_session_ids: Vec<String>,
1130 pub since: Option<DateTime<Utc>>,
1131 pub until: Option<DateTime<Utc>>,
1132 pub seq_from: Option<i64>,
1135 pub seq_to: Option<i64>,
1138 pub match_mode: MessageSearchMode,
1142 pub tool: Option<String>,
1146 pub no_compaction: bool,
1150 pub session_kinds: Option<Vec<SessionKind>>,
1162 pub limit: usize,
1163 pub offset: usize,
1164}
1165
1166impl MessageFilters {
1167 pub fn validate(&self, query: &str) -> anyhow::Result<()> {
1171 use anyhow::{bail, ensure};
1172
1173 if self.match_mode == MessageSearchMode::Fuzzy {
1174 ensure!(
1175 query.chars().take(3).count() >= 3,
1176 "fuzzy search requires at least 3 characters; use literal search for shorter text"
1177 );
1178 ensure!(
1179 !query.trim().is_empty(),
1180 "fuzzy query must contain a non-whitespace character"
1181 );
1182 ensure!(
1183 self.limit > 0,
1184 "fuzzy search requires a finite non-zero limit; literal search supports unlimited results"
1185 );
1186 }
1187 ensure!(
1188 !query.is_empty() || self.match_mode == MessageSearchMode::Literal,
1189 "match_mode={} requires a non-empty query",
1190 self.match_mode.as_str()
1191 );
1192 let offset = i64::try_from(self.offset)
1193 .map_err(|_| anyhow::anyhow!("offset exceeds SQLite's signed 64-bit limit"))?;
1194 if self.limit > 0 {
1195 let limit = i64::try_from(self.limit)
1196 .map_err(|_| anyhow::anyhow!("limit exceeds SQLite's signed 64-bit limit"))?;
1197 offset.checked_add(limit).ok_or_else(|| {
1198 anyhow::anyhow!("offset + limit exceeds SQLite's signed 64-bit limit")
1199 })?;
1200 }
1201
1202 if self.seq_from.is_some() || self.seq_to.is_some() {
1203 if self.seq_from.is_some_and(|seq| seq < 0) || self.seq_to.is_some_and(|seq| seq < 0) {
1204 bail!("seq_from and seq_to must be non-negative");
1205 }
1206 if let (Some(from), Some(to)) = (self.seq_from, self.seq_to) {
1207 ensure!(from <= to, "seq_from must be <= seq_to");
1208 }
1209 }
1210
1211 let field = self.field.unwrap_or(SearchField::Content);
1212 if field == SearchField::ToolArgument {
1213 let pointer = self
1214 .argument_path
1215 .as_deref()
1216 .ok_or_else(|| anyhow::anyhow!("tool-argument search requires argument_path"))?;
1217 crate::message_search::JsonPointer::new(pointer.to_string()).map_err(|error| {
1218 anyhow::anyhow!("argument_path must be an RFC 6901 JSON pointer: {error}")
1219 })?;
1220 ensure!(
1223 self.kinds
1224 .as_ref()
1225 .is_none_or(|kinds| kinds.contains(&MessageKind::ToolCall)),
1226 "tool-argument search requires tool_call among the selected kinds"
1227 );
1228 } else if self.argument_path.is_some() {
1229 bail!("argument_path requires field=tool_argument");
1230 }
1231 Ok(())
1232 }
1233
1234 pub fn narrows_corpus(&self) -> bool {
1241 self.role.is_some()
1242 || self.kinds.is_some()
1243 || self.providers.is_some()
1244 || self.session_id.is_some()
1245 || self.path_prefix.is_some()
1246 || !self.exclude_path_prefixes.is_empty()
1247 || self.workspace_path_prefix.is_some()
1248 || self.transcript_path_prefix.is_some()
1249 || !self.exclude_workspace_path_prefixes.is_empty()
1250 || !self.exclude_transcript_path_prefixes.is_empty()
1251 || !self.exclude_session_ids.is_empty()
1252 || self.since.is_some()
1253 || self.until.is_some()
1254 || self.seq_from.is_some()
1255 || self.seq_to.is_some()
1256 || self.tool.is_some()
1257 || self.no_compaction
1258 || self.kinds.is_some()
1259 }
1260
1261 pub fn effective_kinds(&self) -> Vec<MessageKind> {
1267 let mut kinds = self
1268 .kinds
1269 .clone()
1270 .unwrap_or_else(MessageKind::default_search_set);
1271 if self.no_compaction {
1272 kinds.retain(|kind| *kind != MessageKind::Compaction);
1273 }
1274 kinds
1275 }
1276
1277 pub fn kind_predicate(&self) -> KindPredicate {
1286 if self.kinds.is_some() {
1287 return KindPredicate::Only(self.effective_kinds());
1288 }
1289 let mut excluded = vec![MessageKind::HarnessNotice];
1290 if self.no_compaction {
1291 excluded.push(MessageKind::Compaction);
1292 }
1293 KindPredicate::AllExcept(excluded)
1294 }
1295}
1296
1297#[derive(Debug, Clone, Serialize)]
1298pub struct MessageHit {
1299 pub session_id: String,
1300 pub provider: Provider,
1301 pub seq: i64,
1302 pub role: Role,
1303 pub kind: MessageKind,
1304 pub ts: Option<DateTime<Utc>>,
1305 pub tool_name: Option<String>,
1307 #[serde(skip_serializing_if = "Option::is_none")]
1308 pub tool_call_id: Option<String>,
1309 #[serde(skip_serializing_if = "Option::is_none")]
1310 pub fuzzy_score: Option<u32>,
1311 pub content: String,
1312}
1313
1314#[derive(Debug, Clone, Default, Serialize)]
1320pub struct SessionMeta {
1321 pub provider_session_id: Option<String>,
1322 pub cwd: Option<String>,
1323 pub repo_root: Option<String>,
1324 pub title: Option<String>,
1325 pub updated_at: Option<DateTime<Utc>>,
1326 pub last_message_at: Option<DateTime<Utc>>,
1327 pub message_count: Option<i64>,
1328 pub parse_warning: Option<String>,
1329 pub parent_session_id: Option<String>,
1334 pub agent_label: Option<String>,
1337}
1338
1339#[derive(Debug, Clone, Serialize)]
1340pub struct ProviderParserHealth {
1341 pub provider: Provider,
1342 pub expected_parse_version: String,
1343 pub indexed_sessions: i64,
1344 pub current_sessions: i64,
1345 pub stale_sessions: i64,
1346}
1347
1348#[derive(Debug, Clone, Serialize)]
1349pub struct ParserHealth {
1350 pub schema_version: i64,
1351 pub expected_schema_version: i64,
1352 pub schema_current: bool,
1353 pub indexed_sessions: i64,
1354 pub current_sessions: i64,
1355 pub stale_sessions: i64,
1356 pub parse_warnings: i64,
1357 pub providers: Vec<ProviderParserHealth>,
1358}
1359
1360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1361#[serde(rename_all = "snake_case")]
1362pub enum IndexSnapshotAvailability {
1363 Unavailable,
1364 Usable,
1365}
1366
1367impl IndexSnapshotAvailability {
1368 pub const fn as_str(self) -> &'static str {
1369 match self {
1370 Self::Unavailable => "unavailable",
1371 Self::Usable => "usable",
1372 }
1373 }
1374}
1375
1376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1377pub struct IndexSnapshotStatus {
1378 pub availability: IndexSnapshotAvailability,
1379 pub last_successful_refresh_at: Option<DateTime<Utc>>,
1380}
1381
1382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1383#[serde(rename_all = "snake_case")]
1384pub enum IndexRefreshState {
1385 NotStarted,
1386 Indexing,
1387 Fresh,
1388 Postponed,
1389 FailedWithRecovery,
1390}
1391
1392impl IndexRefreshState {
1393 pub const fn as_str(self) -> &'static str {
1394 match self {
1395 Self::NotStarted => "not_started",
1396 Self::Indexing => "indexing",
1397 Self::Fresh => "fresh",
1398 Self::Postponed => "postponed",
1399 Self::FailedWithRecovery => "failed_with_recovery",
1400 }
1401 }
1402}
1403
1404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1405#[serde(rename_all = "snake_case")]
1406pub enum IndexRefreshTrigger {
1407 IntegrationInstall,
1408 CommandLine,
1409 Mcp,
1410}
1411
1412impl IndexRefreshTrigger {
1413 pub const fn as_str(self) -> &'static str {
1414 match self {
1415 Self::IntegrationInstall => "integration_install",
1416 Self::CommandLine => "command_line",
1417 Self::Mcp => "mcp",
1418 }
1419 }
1420}
1421
1422#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1423pub struct IndexRefreshStatus {
1424 pub state: IndexRefreshState,
1425 pub started_by: Option<IndexRefreshTrigger>,
1426 pub started_at: Option<DateTime<Utc>>,
1427 pub finished_at: Option<DateTime<Utc>>,
1428 pub files_discovered: Option<usize>,
1429 pub files_processed: Option<usize>,
1430 pub sessions_updated: Option<usize>,
1431 pub retry_after_ms: Option<u64>,
1432 pub message: Option<String>,
1433 pub next_command: Option<String>,
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1437pub struct IndexReadinessStatus {
1438 pub snapshot: IndexSnapshotStatus,
1439 pub refresh: IndexRefreshStatus,
1440}
1441
1442impl IndexReadinessStatus {
1443 pub const fn not_started() -> Self {
1444 Self {
1445 snapshot: IndexSnapshotStatus {
1446 availability: IndexSnapshotAvailability::Unavailable,
1447 last_successful_refresh_at: None,
1448 },
1449 refresh: IndexRefreshStatus {
1450 state: IndexRefreshState::NotStarted,
1451 started_by: None,
1452 started_at: None,
1453 finished_at: None,
1454 files_discovered: None,
1455 files_processed: None,
1456 sessions_updated: None,
1457 retry_after_ms: None,
1458 message: None,
1459 next_command: None,
1460 },
1461 }
1462 }
1463}
1464
1465#[derive(Debug, Clone, Serialize)]
1466pub struct IndexStatus {
1467 pub parser_health: ParserHealth,
1468 pub repairable_stale_sessions: i64,
1470 pub unavailable_stale_sessions: i64,
1472 pub unindexed_files: i64,
1477 pub repair_commands: Vec<String>,
1478 pub readiness: IndexReadinessStatus,
1481}
1482
1483#[derive(Debug, Clone, Serialize)]
1484pub struct SessionTimeProfile {
1485 pub messages: i64,
1486 pub timestamped_messages: i64,
1487 pub undated_messages: i64,
1488 pub first_timestamp: Option<DateTime<Utc>>,
1489 pub last_timestamp: Option<DateTime<Utc>>,
1490 pub observed_span_seconds: Option<i64>,
1491 pub max_message_gap_seconds: Option<i64>,
1492 pub tool_calls: i64,
1493 pub tool_results: i64,
1494}
1495
1496#[derive(Debug, Clone, Serialize)]
1501pub struct SearchExplain {
1502 pub prefilter: Option<String>,
1505 pub candidates: Option<i64>,
1508 pub prefilter_skipped: Option<String>,
1510 pub corpus: i64,
1513}
1514
1515impl SearchExplain {
1516 pub fn summary(&self, has_content_query: bool) -> String {
1523 match (&self.prefilter, self.candidates) {
1524 (None, Some(candidates)) if has_content_query && self.prefilter_skipped.is_some() => {
1525 format!(
1526 "[explain] {} / {} corpus rows matched after {}",
1527 candidates,
1528 self.corpus,
1529 self.prefilter_skipped
1530 .as_deref()
1531 .unwrap_or("content scoring")
1532 )
1533 }
1534 (Some(prefilter), Some(candidates)) => {
1535 let pct = if self.corpus > 0 {
1536 100.0 * candidates as f64 / self.corpus as f64
1537 } else {
1538 0.0
1539 };
1540 let hint = if pct >= 50.0 {
1541 " — low selectivity; anchor the regex on a rarer literal substring"
1542 } else {
1543 ""
1544 };
1545 format!(
1546 "[explain] trigram prefilter: {prefilter}\n\
1547 [explain] candidates: {candidates} / {} corpus rows ({pct:.1}%) to verify{hint}",
1548 self.corpus
1549 )
1550 }
1551 (Some(prefilter), None) if has_content_query && self.prefilter_skipped.is_some() => {
1552 format!(
1553 "[explain] trigram prefilter available: {prefilter}\n\
1554 [explain] skipped trigram prefilter: {}; direct scan of {} corpus rows",
1555 self.prefilter_skipped.as_deref().unwrap_or("not used"),
1556 self.corpus
1557 )
1558 }
1559 (None, None) if has_content_query && self.prefilter_skipped.is_some() => format!(
1564 "[explain] {}; scanned {} corpus rows",
1565 self.prefilter_skipped.as_deref().unwrap_or("no prefilter"),
1566 self.corpus
1567 ),
1568 _ if has_content_query => format!(
1569 "[explain] query has no >=3-char literal anchor → full scan of {} corpus rows",
1570 self.corpus
1571 ),
1572 _ => format!(
1573 "[explain] {} corpus rows; no content query was provided",
1574 self.corpus
1575 ),
1576 }
1577 }
1578}
1579
1580#[derive(Debug, Clone, Serialize, Deserialize)]
1582pub struct MessageClassificationMatch {
1583 pub session_id: String,
1584 pub message_seq: i64,
1586 pub provider: Provider,
1587 pub ts: Option<DateTime<Utc>>,
1588 pub policy_name: String,
1592 pub category: String,
1593 pub matched_text: String,
1597 pub match_start_char: usize,
1599 pub match_end_char_exclusive: usize,
1600 pub content: String,
1601}
1602
1603#[derive(Debug, Clone, Serialize)]
1605pub struct PlanningCount {
1606 pub command: String,
1607 pub count: i64,
1608 pub unique_sessions: i64,
1609 pub unique_projects: i64,
1610}
1611
1612#[derive(Debug, Clone, Default)]
1617pub struct FileQuery {
1618 pub pattern: Option<String>,
1619 pub provider: Option<Provider>,
1621 pub session_id: Option<String>,
1623 pub path_prefix: Option<String>,
1625 pub exclude_path_prefixes: Vec<String>,
1628 pub exclude_session_ids: Vec<String>,
1630 pub since: Option<DateTime<Utc>>,
1631 pub until: Option<DateTime<Utc>>,
1632 pub min_edits: Option<i64>,
1633 pub max_edits: Option<i64>,
1634 pub limit: usize,
1637 pub offset: usize,
1639}
1640
1641#[derive(Debug, Clone, Serialize)]
1643pub struct FileEditSummary {
1644 pub file_path: String,
1645 pub file_name: String,
1646 pub edits: i64,
1647 pub sessions: i64,
1648 pub last_edited: Option<DateTime<Utc>>,
1649}
1650
1651#[derive(Debug, Clone, Serialize)]
1653pub struct FileVersion {
1654 pub session_id: String,
1655 pub provider: Provider,
1656 pub version: i64,
1657 pub tool: String,
1658 pub ts: Option<DateTime<Utc>>,
1659 pub lines: i64,
1660 pub file_path: String,
1661}
1662
1663#[derive(Debug, Clone, Serialize)]
1665pub struct FileCrossRef {
1666 pub file_path: String,
1667 pub session_id: String,
1668 pub provider: Provider,
1669 pub edits: i64,
1670}
1671
1672#[derive(Debug, Clone, Serialize)]
1673pub struct ProviderHealth {
1674 pub provider: Provider,
1675 pub enabled: bool,
1676 pub cli_available: bool,
1677 pub roots: Vec<String>,
1678 pub discovered_files: usize,
1679 pub indexed_sessions: i64,
1680 pub unindexed_files: i64,
1685 pub expected_parse_version: String,
1686 pub current_sessions: i64,
1687 pub stale_sessions: i64,
1688 pub repairable_stale_sessions: i64,
1689 pub unavailable_stale_sessions: i64,
1690 pub resume_command: Option<String>,
1691}
1692
1693#[derive(Debug, Clone, Serialize)]
1694pub struct DiagnosticStatus {
1695 pub db_path: String,
1696 #[serde(flatten)]
1697 pub index_status: IndexStatus,
1698 pub discovery_warnings: Vec<crate::source::ProviderDiscoveryWarning>,
1699 pub providers: Vec<ProviderHealth>,
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 use super::*;
1705
1706 #[test]
1707 fn message_provenance_accepts_complete_unicode_scalar_parts_and_scoped_correlation() {
1708 let provenance = MessageProvenance {
1709 authorship: MessageAuthorship::Mixed,
1710 record_relation: MessageRecordRelation::Mirror,
1711 correlation_identity: Some(MessageCorrelationIdentity {
1712 authority: MessageCorrelationAuthority::Google,
1713 scope: "gemini_conversation_event".to_string(),
1714 id: "event-7".to_string(),
1715 }),
1716 content_parts: vec![
1717 MessageContentPart {
1718 ordinal: 0,
1719 start_char: 0,
1720 end_char: 2,
1721 authorship: ContentPartAuthorship::Human,
1722 origin: ContentPartOrigin::DirectInput,
1723 },
1724 MessageContentPart {
1725 ordinal: 1,
1726 start_char: 2,
1727 end_char: 4,
1728 authorship: ContentPartAuthorship::Generated,
1729 origin: ContentPartOrigin::QuotedContent,
1730 },
1731 ],
1732 };
1733
1734 assert_eq!("aé日🙂".chars().count(), 4);
1736 assert!(provenance.validate("aé日🙂").is_ok());
1737 }
1738
1739 #[test]
1740 fn message_provenance_rejects_ambiguous_parts_and_correlation_identities() {
1741 let valid_parts = vec![
1742 MessageContentPart {
1743 ordinal: 0,
1744 start_char: 0,
1745 end_char: 2,
1746 authorship: ContentPartAuthorship::Human,
1747 origin: ContentPartOrigin::DirectInput,
1748 },
1749 MessageContentPart {
1750 ordinal: 1,
1751 start_char: 2,
1752 end_char: 4,
1753 authorship: ContentPartAuthorship::Generated,
1754 origin: ContentPartOrigin::QuotedContent,
1755 },
1756 ];
1757 for (provenance, expected) in [
1758 (
1759 MessageProvenance {
1760 authorship: MessageAuthorship::Human,
1761 content_parts: valid_parts.clone(),
1762 ..Default::default()
1763 },
1764 "only when message authorship is \"mixed\"",
1765 ),
1766 (
1767 MessageProvenance {
1768 authorship: MessageAuthorship::Mixed,
1769 ..Default::default()
1770 },
1771 "requires content_parts",
1772 ),
1773 (
1774 MessageProvenance {
1775 authorship: MessageAuthorship::Mixed,
1776 content_parts: vec![MessageContentPart {
1777 ordinal: 0,
1778 start_char: 0,
1779 end_char: 4,
1780 authorship: ContentPartAuthorship::Human,
1781 origin: ContentPartOrigin::DirectInput,
1782 }],
1783 ..Default::default()
1784 },
1785 "at least two distinct",
1786 ),
1787 (
1788 MessageProvenance {
1789 authorship: MessageAuthorship::Mixed,
1790 content_parts: vec![
1791 valid_parts[0].clone(),
1792 MessageContentPart {
1793 start_char: 3,
1794 ..valid_parts[1].clone()
1795 },
1796 ],
1797 ..Default::default()
1798 },
1799 "must start at character 2",
1800 ),
1801 (
1802 MessageProvenance {
1803 authorship: MessageAuthorship::Unknown,
1804 correlation_identity: Some(MessageCorrelationIdentity {
1805 authority: MessageCorrelationAuthority::Google,
1806 scope: " ".to_string(),
1807 id: "event".to_string(),
1808 }),
1809 content_parts: Vec::new(),
1810 ..Default::default()
1811 },
1812 "scope must not be empty",
1813 ),
1814 ] {
1815 let error = provenance.validate("aé日🙂").unwrap_err();
1816 assert!(error.contains(expected), "{error}");
1817 }
1818 }
1819
1820 #[test]
1821 fn correlation_authority_prevents_equal_native_ids_from_collapsing() {
1822 let first = MessageCorrelationIdentity {
1823 authority: MessageCorrelationAuthority::OpenAi,
1824 scope: "response_item".to_string(),
1825 id: "shared".to_string(),
1826 };
1827 let second = MessageCorrelationIdentity {
1828 authority: MessageCorrelationAuthority::Google,
1829 ..first.clone()
1830 };
1831 assert_ne!(first, second);
1832 }
1833
1834 #[test]
1835 fn original_and_mirror_rows_share_one_correlation_identity() {
1836 let identity = MessageCorrelationIdentity {
1837 authority: MessageCorrelationAuthority::Google,
1838 scope: "conversation_event".to_string(),
1839 id: "event-7".to_string(),
1840 };
1841 let original = MessageProvenance {
1842 record_relation: MessageRecordRelation::Original,
1843 correlation_identity: Some(identity.clone()),
1844 ..Default::default()
1845 };
1846 let mirror = MessageProvenance {
1847 record_relation: MessageRecordRelation::Mirror,
1848 correlation_identity: Some(identity.clone()),
1849 ..Default::default()
1850 };
1851
1852 assert_ne!(original, mirror);
1853 assert_eq!(original.correlation_identity, mirror.correlation_identity);
1854 }
1855
1856 #[test]
1857 fn known_mirror_relation_does_not_require_a_correlation_identity() {
1858 let provenance = MessageProvenance {
1859 record_relation: MessageRecordRelation::Mirror,
1860 ..Default::default()
1861 };
1862 assert!(provenance.validate("replayed transcript").is_ok());
1863 }
1864
1865 #[test]
1870 fn default_class_selection_excludes_rather_than_enumerates() {
1871 let default = MessageFilters::default();
1872 assert_eq!(
1873 default.kind_predicate(),
1874 KindPredicate::AllExcept(vec![MessageKind::HarnessNotice]),
1875 "the default must be an exclusion so unrecognized kinds survive"
1876 );
1877
1878 let no_compaction = MessageFilters {
1879 no_compaction: true,
1880 ..Default::default()
1881 };
1882 assert_eq!(
1883 no_compaction.kind_predicate(),
1884 KindPredicate::AllExcept(vec![MessageKind::HarnessNotice, MessageKind::Compaction]),
1885 "no_compaction narrows the same exclusion rather than switching to an inclusion"
1886 );
1887 }
1888
1889 #[test]
1893 fn every_default_exclusion_is_covered_by_the_excludable_set() {
1894 for no_compaction in [false, true] {
1895 let filters = MessageFilters {
1896 no_compaction,
1897 ..Default::default()
1898 };
1899 let KindPredicate::AllExcept(excluded) = filters.kind_predicate() else {
1900 panic!("naming no set must produce an exclusion");
1901 };
1902 for kind in excluded {
1903 assert!(
1904 MessageKind::EXCLUDABLE_BY_DEFAULT.contains(&kind),
1905 "{kind:?} is excluded by default but missing from EXCLUDABLE_BY_DEFAULT"
1906 );
1907 }
1908 }
1909 let mut names: Vec<&str> = MessageKind::EXCLUDABLE_BY_DEFAULT
1910 .iter()
1911 .map(MessageKind::as_str)
1912 .collect();
1913 let rendered = names.clone();
1914 names.sort_unstable();
1915 assert_eq!(rendered, names, "kept sorted so rendered SQL is stable");
1916 }
1917
1918 #[test]
1936 fn session_class_spellings_match_the_providers_and_exclude_the_ambiguous_ones() {
1937 use std::str::FromStr;
1938
1939 assert_eq!(SessionKind::User.as_str(), "user");
1940 assert_eq!(SessionKind::Subagent.as_str(), "subagent");
1941 for kind in SessionKind::all() {
1942 assert_eq!(
1943 SessionKind::from_str(kind.as_str()),
1944 Ok(kind),
1945 "every spelling this emits must be one it accepts"
1946 );
1947 }
1948
1949 for rejected in [
1950 "agent",
1951 "top_level",
1952 "top-level",
1953 "sidechain",
1954 "child",
1955 "spawned",
1956 ] {
1957 let error = SessionKind::from_str(rejected)
1958 .expect_err(&format!("{rejected} must not be an accepted spelling"));
1959 assert!(
1960 error.contains("user") && error.contains("subagent"),
1961 "a rejection must name both accepted values: {error}"
1962 );
1963 }
1964
1965 assert_eq!(SessionKind::from_str("SubAgent"), Ok(SessionKind::Subagent));
1967 }
1968
1969 #[test]
1980 fn the_default_session_filter_selects_every_session() {
1981 let SearchFilters {
1982 provider,
1983 session_kinds,
1984 path_prefix,
1985 exclude_path_prefixes,
1986 exclude_session_ids,
1987 parent_session_id,
1988 since,
1989 until,
1990 limit,
1991 warnings_only,
1992 } = SearchFilters::default();
1993
1994 assert_eq!(provider, None, "no provider named means every provider");
1995 assert_eq!(session_kinds, None, "no class named means every class");
1996 assert_eq!(path_prefix, None);
1997 assert!(exclude_path_prefixes.is_empty());
1998 assert!(exclude_session_ids.is_empty());
1999 assert_eq!(parent_session_id, None, "not restricted to one spawner");
2000 assert_eq!(since, None);
2001 assert_eq!(until, None);
2002 assert_eq!(limit, 0, "0 is the unbounded page; callers state their own");
2003 assert!(!warnings_only);
2004 }
2005
2006 #[test]
2011 fn the_default_session_class_set_is_every_class() {
2012 assert_eq!(
2013 SearchFilters::default().effective_session_kinds(),
2014 vec![SessionKind::User, SessionKind::Subagent]
2015 );
2016 assert_eq!(SessionKind::default_search_set(), SessionKind::all());
2017
2018 let only_runs = SearchFilters {
2020 session_kinds: Some(vec![SessionKind::Subagent]),
2021 ..SearchFilters::default()
2022 };
2023 assert_eq!(
2024 only_runs.effective_session_kinds(),
2025 vec![SessionKind::Subagent]
2026 );
2027 let none = SearchFilters {
2028 session_kinds: Some(Vec::new()),
2029 ..SearchFilters::default()
2030 };
2031 assert!(
2032 none.effective_session_kinds().is_empty(),
2033 "an empty set is a caller who deselected every class, not an absent one"
2034 );
2035 }
2036
2037 #[test]
2039 fn a_named_class_set_selects_exactly_those_classes() {
2040 let notices = MessageFilters {
2041 kinds: Some(vec![MessageKind::HarnessNotice]),
2042 ..Default::default()
2043 };
2044 assert_eq!(
2045 notices.kind_predicate(),
2046 KindPredicate::Only(vec![MessageKind::HarnessNotice]),
2047 "asking for harness notices must return them, not self-cancel against the default"
2048 );
2049
2050 let conflict = MessageFilters {
2052 kinds: Some(vec![MessageKind::Compaction]),
2053 no_compaction: true,
2054 ..Default::default()
2055 };
2056 assert_eq!(
2057 conflict.kind_predicate(),
2058 KindPredicate::Only(Vec::new()),
2059 "an emptied set is reported as empty so the caller gets an error, not silence"
2060 );
2061 }
2062
2063 #[test]
2066 fn the_default_search_set_is_every_class_except_harness_notices() {
2067 let set = MessageKind::default_search_set();
2068 assert!(!set.contains(&MessageKind::HarnessNotice));
2069 for kind in [
2070 MessageKind::Conversation,
2071 MessageKind::Compaction,
2072 MessageKind::ToolCall,
2073 MessageKind::ToolResult,
2074 MessageKind::Unknown,
2075 ] {
2076 assert!(
2077 set.contains(&kind),
2078 "{kind:?} must be searchable by default"
2079 );
2080 }
2081 }
2082
2083 #[test]
2091 fn every_typed_value_parses_back_from_the_string_it_renders() {
2092 use std::str::FromStr;
2093
2094 macro_rules! assert_round_trips {
2095 ($($type:ty => [$($variant:expr),+ $(,)?]),+ $(,)?) => {
2096 $($(
2097 let rendered = $variant.as_str();
2098 assert_eq!(
2099 <$type>::from_str(rendered).ok(),
2100 Some($variant),
2101 "{} renders as {rendered:?}, which parses back as something else",
2102 stringify!($variant),
2103 );
2104 )+)+
2105 };
2106 }
2107
2108 assert_round_trips! {
2109 Provider => [
2110 Provider::Claude, Provider::ClaudeDesktop, Provider::Codex, Provider::Cursor,
2111 Provider::Antigravity, Provider::Pi, Provider::PrimeAgent, Provider::AiStudio,
2112 Provider::GeminiCli,
2113 ],
2114 Role => [Role::User, Role::Assistant, Role::Tool, Role::Slash, Role::Compaction],
2115 MessageSearchMode => [
2116 MessageSearchMode::Literal, MessageSearchMode::Regex, MessageSearchMode::Fuzzy,
2117 ],
2118 SearchField => [
2119 SearchField::Content, SearchField::ToolName, SearchField::ToolArgument,
2120 ],
2121 MessageKind => [
2122 MessageKind::Conversation, MessageKind::Compaction, MessageKind::ToolCall,
2123 MessageKind::ToolResult, MessageKind::HarnessNotice, MessageKind::Unknown,
2124 ],
2125 MessageAuthorship => [
2126 MessageAuthorship::Human, MessageAuthorship::Agent, MessageAuthorship::Harness,
2127 MessageAuthorship::Generated, MessageAuthorship::Mixed, MessageAuthorship::Unknown,
2128 ],
2129 ContentPartAuthorship => [
2130 ContentPartAuthorship::Human, ContentPartAuthorship::Agent,
2131 ContentPartAuthorship::Harness, ContentPartAuthorship::Generated,
2132 ContentPartAuthorship::Unknown,
2133 ],
2134 ContentPartOrigin => [
2135 ContentPartOrigin::DirectInput, ContentPartOrigin::QuotedContent,
2136 ContentPartOrigin::HarnessContext, ContentPartOrigin::GeneratedSummary,
2137 ContentPartOrigin::ToolPayload, ContentPartOrigin::Unknown,
2138 ],
2139 MessageCorrelationAuthority => [
2140 MessageCorrelationAuthority::Anthropic, MessageCorrelationAuthority::OpenAi,
2141 MessageCorrelationAuthority::Cursor, MessageCorrelationAuthority::Google,
2142 MessageCorrelationAuthority::Pi, MessageCorrelationAuthority::PrimeAgent,
2143 ],
2144 MessageRecordRelation => [
2145 MessageRecordRelation::Original, MessageRecordRelation::Mirror,
2146 MessageRecordRelation::Unknown,
2147 ],
2148 SessionKind => [SessionKind::User, SessionKind::Subagent],
2149 }
2150 }
2151
2152 #[test]
2153 fn every_message_kind_round_trips_through_its_database_spelling() {
2154 use clap::ValueEnum;
2155
2156 for kind in MessageKind::value_variants() {
2157 assert_eq!(
2158 MessageKind::from_db_str(kind.as_str()),
2159 *kind,
2160 "{} must not decode as a different kind after a database read",
2161 kind.as_str()
2162 );
2163 }
2164 }
2165
2166 #[test]
2167 fn unrecognized_provider_spelling_is_rejected_in_every_build() {
2168 let error = Provider::from_db_str("not_a_provider").unwrap_err();
2169 assert!(
2170 error.contains("not_a_provider"),
2171 "the error must identify the corrupt stored value: {error}"
2172 );
2173 }
2174
2175 #[test]
2176 fn unrecognized_role_spelling_is_rejected_in_every_build() {
2177 let error = Role::from_db_str("not_a_role").unwrap_err();
2178 assert!(
2179 error.contains("not_a_role"),
2180 "the error must identify the corrupt stored value: {error}"
2181 );
2182 }
2183
2184 #[test]
2192 fn unrecognized_message_kind_decodes_quietly_because_older_indexes_hold_other_spellings() {
2193 assert_eq!(
2194 MessageKind::from_db_str("message"),
2195 MessageKind::Unknown,
2196 "a legacy on-disk spelling must decode, not panic"
2197 );
2198 assert_eq!(
2199 MessageKind::from_db_str(MessageKind::Unknown.as_str()),
2200 MessageKind::Unknown,
2201 "\"unknown\" is itself a legitimate stored spelling"
2202 );
2203 }
2204
2205 #[test]
2206 fn programmatic_search_field_accepts_cli_spelling_but_serializes_canonically() {
2207 assert_eq!(
2208 "tool-argument".parse::<SearchField>().unwrap(),
2209 SearchField::ToolArgument
2210 );
2211 assert_eq!(
2212 "tool_argument".parse::<SearchField>().unwrap(),
2213 SearchField::ToolArgument
2214 );
2215 assert_eq!(SearchField::ToolArgument.as_str(), "tool_argument");
2216 assert_eq!(
2217 serde_json::to_string(&SearchField::ToolArgument).unwrap(),
2218 "\"tool_argument\""
2219 );
2220 }
2221
2222 #[test]
2223 fn every_parsed_vocabulary_names_its_accepted_values_when_rejecting_input() {
2224 for (rejected, accepted) in [
2229 (
2230 "usr".parse::<Role>().unwrap_err(),
2231 vec!["user", "assistant", "tool", "slash", "compaction"],
2232 ),
2233 (
2234 "contnet".parse::<SearchField>().unwrap_err(),
2235 vec!["content", "tool_name", "tool_argument"],
2236 ),
2237 (
2238 "chatgpt".parse::<Provider>().unwrap_err(),
2239 vec![
2240 "claude",
2241 "claude-desktop",
2242 "codex",
2243 "cursor",
2244 "antigravity",
2245 "pi",
2246 "aistudio",
2247 "gemini-cli",
2248 ],
2249 ),
2250 (
2251 "converstaion".parse::<MessageKind>().unwrap_err(),
2252 vec![
2253 "conversation",
2254 "compaction",
2255 "tool_call",
2256 "tool_result",
2257 "unknown",
2258 ],
2259 ),
2260 ] {
2261 assert!(
2262 rejected.contains("must be one of"),
2263 "no accepted-value list in: {rejected}"
2264 );
2265 for value in accepted {
2266 assert!(
2267 rejected.contains(&format!("{value:?}")),
2268 "{value} missing from: {rejected}"
2269 );
2270 }
2271 }
2272 }
2273
2274 #[test]
2275 fn fuzzy_message_validation_rejects_short_queries_before_database_work() {
2276 let filters = MessageFilters {
2277 match_mode: MessageSearchMode::Fuzzy,
2278 limit: 5,
2279 ..Default::default()
2280 };
2281
2282 for query in ["", "a", "éx"] {
2283 let error = filters.validate(query).unwrap_err().to_string();
2284 assert!(
2285 error.contains("at least 3 characters"),
2286 "unexpected error for {query:?}: {error}"
2287 );
2288 assert!(error.contains("literal"), "{error}");
2289 }
2290 assert!(filters.validate("éx!").is_ok());
2291 }
2292
2293 #[test]
2294 fn fuzzy_message_validation_requires_a_finite_page_without_an_arbitrary_window_cap() {
2295 let unlimited = MessageFilters {
2296 match_mode: MessageSearchMode::Fuzzy,
2297 ..Default::default()
2298 };
2299 assert!(unlimited
2300 .validate("query")
2301 .unwrap_err()
2302 .to_string()
2303 .contains("finite non-zero limit"));
2304
2305 let large_valid_page = MessageFilters {
2306 match_mode: MessageSearchMode::Fuzzy,
2307 offset: 10_000,
2308 limit: 1,
2309 ..Default::default()
2310 };
2311 assert!(large_valid_page.validate("query").is_ok());
2312
2313 let overflowing = MessageFilters {
2314 match_mode: MessageSearchMode::Fuzzy,
2315 offset: usize::MAX,
2316 limit: 1,
2317 ..Default::default()
2318 };
2319 assert!(overflowing
2320 .validate("query")
2321 .unwrap_err()
2322 .to_string()
2323 .contains("signed 64-bit"));
2324
2325 let whitespace = MessageFilters {
2326 match_mode: MessageSearchMode::Fuzzy,
2327 limit: 5,
2328 ..Default::default()
2329 };
2330 assert!(whitespace
2331 .validate(" \t\n")
2332 .unwrap_err()
2333 .to_string()
2334 .contains("non-whitespace"));
2335 }
2336
2337 #[test]
2338 fn legacy_tool_argument_filter_rejects_malformed_json_pointer_escapes() {
2339 let filters = MessageFilters {
2340 field: Some(SearchField::ToolArgument),
2341 argument_path: Some("/~bad".into()),
2342 ..Default::default()
2343 };
2344 let error = filters.validate("needle").unwrap_err().to_string();
2345 assert!(error.contains("RFC 6901"), "{error}");
2346 assert!(error.contains("'~' must be followed"), "{error}");
2347 }
2348
2349 #[test]
2350 fn message_page_validation_rejects_values_sqlite_cannot_bind() {
2351 let filters = MessageFilters {
2352 offset: i64::MAX as usize,
2353 limit: 1,
2354 ..Default::default()
2355 };
2356 assert!(filters
2357 .validate("")
2358 .unwrap_err()
2359 .to_string()
2360 .contains("offset + limit"));
2361
2362 if let Some(too_large) = usize::try_from(i64::MAX)
2363 .ok()
2364 .and_then(|maximum| maximum.checked_add(1))
2365 {
2366 let filters = MessageFilters {
2367 offset: too_large,
2368 ..Default::default()
2369 };
2370 assert!(filters
2371 .validate("")
2372 .unwrap_err()
2373 .to_string()
2374 .contains("offset exceeds"));
2375 }
2376 }
2377
2378 #[test]
2379 fn every_provider_has_a_concrete_display_name_and_resume_contract() {
2380 let providers = crate::source::PROVIDERS;
2381 assert!(providers
2382 .iter()
2383 .all(|provider| !provider.display_name().trim().is_empty()));
2384 assert_eq!(
2385 providers
2386 .into_iter()
2387 .filter(|provider| provider.supports_native_resume())
2388 .map(Provider::as_str)
2389 .collect::<Vec<_>>(),
2390 ["claude", "codex", "pi", "prime-agent"]
2391 );
2392 }
2393
2394 #[test]
2395 fn explain_summary_reports_prefilter_and_selectivity_pct() {
2396 let ex = SearchExplain {
2397 prefilter: Some("\"abc\"".to_string()),
2398 candidates: Some(80),
2399 prefilter_skipped: None,
2400 corpus: 100,
2401 };
2402 let s = ex.summary(true);
2403 assert!(s.contains("trigram prefilter: \"abc\""), "{s}");
2404 assert!(s.contains("80 / 100 corpus rows (80.0%)"), "{s}");
2405 assert!(s.contains("low selectivity"), "{s}");
2407 }
2408
2409 #[test]
2410 fn explain_summary_omits_hint_when_prefilter_is_selective() {
2411 let ex = SearchExplain {
2412 prefilter: Some("\"rareword\"".to_string()),
2413 candidates: Some(2),
2414 prefilter_skipped: None,
2415 corpus: 1000,
2416 };
2417 let s = ex.summary(true);
2418 assert!(s.contains("2 / 1000 corpus rows (0.2%)"), "{s}");
2419 assert!(
2420 !s.contains("low selectivity"),
2421 "selective query gets no hint: {s}"
2422 );
2423 }
2424
2425 #[test]
2426 fn explain_summary_flags_regex_without_literal_anchor() {
2427 let ex = SearchExplain {
2428 prefilter: None,
2429 candidates: None,
2430 prefilter_skipped: None,
2431 corpus: 500,
2432 };
2433 let s = ex.summary(true);
2434 assert!(s.contains("no >=3-char literal anchor"), "{s}");
2435 assert!(s.contains("full scan of 500 corpus rows"), "{s}");
2436 }
2437
2438 #[test]
2439 fn explain_summary_notes_no_content_query_for_empty_searches() {
2440 let ex = SearchExplain {
2441 prefilter: None,
2442 candidates: None,
2443 prefilter_skipped: None,
2444 corpus: 42,
2445 };
2446 let s = ex.summary(false);
2447 assert!(s.contains("42 corpus rows"), "{s}");
2448 assert!(s.contains("no content query was provided"), "{s}");
2449 }
2450
2451 #[test]
2452 fn explain_summary_handles_empty_corpus_without_dividing_by_zero() {
2453 let ex = SearchExplain {
2454 prefilter: Some("\"x\"".to_string()),
2455 candidates: Some(0),
2456 prefilter_skipped: None,
2457 corpus: 0,
2458 };
2459 let s = ex.summary(true);
2460 assert!(s.contains("0 / 0 corpus rows (0.0%)"), "{s}");
2461 }
2462
2463 #[test]
2464 fn explain_summary_reports_intentional_prefilter_skip() {
2465 let ex = SearchExplain {
2466 prefilter: Some("\"rare\"".to_string()),
2467 candidates: None,
2468 prefilter_skipped: Some("corpus below configured threshold".to_string()),
2469 corpus: 25,
2470 };
2471 let s = ex.summary(true);
2472 assert!(s.contains("trigram prefilter available"), "{s}");
2473 assert!(s.contains("skipped trigram prefilter"), "{s}");
2474 assert!(s.contains("direct scan of 25 corpus rows"), "{s}");
2475 }
2476
2477 #[test]
2481 fn explain_summary_names_a_verified_scan_without_inventing_a_candidate_count() {
2482 let ex = SearchExplain {
2483 prefilter: None,
2484 candidates: None,
2485 prefilter_skipped: Some("tool name verified in SQLite without a prefilter".to_string()),
2486 corpus: 3,
2487 };
2488 let s = ex.summary(true);
2489 assert!(
2490 s.contains("tool name verified in SQLite without a prefilter"),
2491 "{s}"
2492 );
2493 assert!(s.contains("scanned 3 corpus rows"), "{s}");
2494 assert!(!s.contains("anchor"), "{s}");
2495 assert!(!s.contains("%"), "{s}");
2496 }
2497}