use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::num::NonZeroUsize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Claude,
#[serde(rename = "claude-desktop")]
#[clap(name = "claude-desktop", alias = "claude_desktop")]
ClaudeDesktop,
Codex,
Cursor,
Antigravity,
Pi,
#[serde(rename = "prime-agent")]
#[clap(name = "prime-agent", alias = "prime_agent")]
PrimeAgent,
#[serde(rename = "aistudio")]
#[clap(name = "aistudio", alias = "ai-studio")]
AiStudio,
#[serde(rename = "gemini-cli")]
#[clap(name = "gemini-cli", alias = "gemini_cli")]
GeminiCli,
}
impl Provider {
pub fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::ClaudeDesktop => "claude-desktop",
Self::Codex => "codex",
Self::Cursor => "cursor",
Self::Antigravity => "antigravity",
Self::Pi => "pi",
Self::PrimeAgent => "prime-agent",
Self::AiStudio => "aistudio",
Self::GeminiCli => "gemini-cli",
}
}
pub const fn display_name(self) -> &'static str {
match self {
Self::Claude => "Claude Code",
Self::ClaudeDesktop => "Claude Desktop local agent",
Self::Codex => "Codex",
Self::Cursor => "Cursor",
Self::Antigravity => "Antigravity",
Self::Pi => "Pi coding agent",
Self::PrimeAgent => "Prime Agent",
Self::AiStudio => "Google AI Studio",
Self::GeminiCli => "Gemini CLI",
}
}
pub const fn supports_native_resume(self) -> bool {
matches!(
self,
Self::Claude | Self::Codex | Self::Pi | Self::PrimeAgent
)
}
pub fn from_db_str(value: &str) -> Result<Self, String> {
value.parse().map_err(|_| {
format!(
"unrecognized provider in index: {value:?}; this aise {} does not recognize it, \
so a newer aise wrote the index: upgrade aise (`aise package update`) and retry",
env!("CARGO_PKG_VERSION")
)
})
}
}
impl std::fmt::Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Provider {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"claude" => Ok(Self::Claude),
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(Self::ClaudeDesktop),
"codex" => Ok(Self::Codex),
"cursor" => Ok(Self::Cursor),
"antigravity" => Ok(Self::Antigravity),
"pi" => Ok(Self::Pi),
"prime-agent" | "prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
"aistudio" | "ai-studio" | "ai_studio" => Ok(Self::AiStudio),
"gemini-cli" | "gemini_cli" | "geminicli" => Ok(Self::GeminiCli),
other => Err(format!(
"unsupported provider: {other} — must be one of \"claude\", \"claude-desktop\", \"codex\", \"cursor\", \"antigravity\", \"pi\", \"prime-agent\", \"aistudio\", \"gemini-cli\""
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[clap(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
Tool,
Slash,
Compaction,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool => "tool",
Self::Slash => "slash",
Self::Compaction => "compaction",
}
}
pub fn from_db_str(value: &str) -> Result<Self, String> {
value.parse().map_err(|_| {
format!(
"unrecognized role in index: {value:?}; stop AISE processes, then run \
`aise reindex --full` and retry"
)
})
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"user" => Ok(Self::User),
"assistant" => Ok(Self::Assistant),
"tool" => Ok(Self::Tool),
"slash" => Ok(Self::Slash),
"compaction" => Ok(Self::Compaction),
other => Err(format!(
"unknown role: {other} — must be one of \"user\", \"assistant\", \"tool\", \"slash\", \"compaction\""
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KindPredicate {
AllExcept(Vec<MessageKind>),
Only(Vec<MessageKind>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "kebab-case")]
pub enum MessageKind {
Conversation,
Compaction,
ToolCall,
ToolResult,
HarnessNotice,
Unknown,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[clap(rename_all = "lowercase")]
pub enum MessageSearchMode {
#[default]
Literal,
Regex,
Fuzzy,
}
impl MessageSearchMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Literal => "literal",
Self::Regex => "regex",
Self::Fuzzy => "fuzzy",
}
}
}
impl std::str::FromStr for MessageSearchMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"literal" => Ok(Self::Literal),
"regex" => Ok(Self::Regex),
"fuzzy" => Ok(Self::Fuzzy),
other => Err(format!(
"unknown message search mode: {other}; expected literal, regex, or fuzzy"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "kebab-case")]
pub enum SearchField {
Content,
ToolName,
ToolArgument,
}
impl SearchField {
pub const fn as_str(self) -> &'static str {
match self {
Self::Content => "content",
Self::ToolName => "tool_name",
Self::ToolArgument => "tool_argument",
}
}
}
impl std::str::FromStr for SearchField {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"content" => Ok(Self::Content),
"tool_name" => Ok(Self::ToolName),
"tool_argument" => Ok(Self::ToolArgument),
other => Err(format!(
"unknown message search field: {other} — must be one of \"content\", \"tool_name\", \"tool_argument\""
)),
}
}
}
impl MessageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Conversation => "conversation",
Self::Compaction => "compaction",
Self::ToolCall => "tool_call",
Self::ToolResult => "tool_result",
Self::HarnessNotice => "harness_notice",
Self::Unknown => "unknown",
}
}
pub fn default_search_set() -> Vec<Self> {
use clap::ValueEnum;
Self::value_variants()
.iter()
.copied()
.filter(|kind| *kind != Self::HarnessNotice)
.collect()
}
pub const EXCLUDABLE_BY_DEFAULT: &'static [Self] = &[Self::Compaction, Self::HarnessNotice];
pub fn from_db_str(value: &str) -> Self {
value.parse().unwrap_or(Self::Unknown)
}
}
impl std::fmt::Display for MessageKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for MessageKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"conversation" => Ok(Self::Conversation),
"compaction" => Ok(Self::Compaction),
"tool_call" => Ok(Self::ToolCall),
"tool_result" => Ok(Self::ToolResult),
"harness_notice" => Ok(Self::HarnessNotice),
"unknown" => Ok(Self::Unknown),
other => Err(format!(
"unknown message kind: {other} — must be one of \"conversation\", \"compaction\", \"tool_call\", \"tool_result\", \"harness_notice\", \"unknown\""
)),
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum,
)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "kebab-case")]
pub enum MessageAuthorship {
Human,
Agent,
Harness,
Generated,
Mixed,
#[default]
Unknown,
}
impl MessageAuthorship {
pub const fn as_str(self) -> &'static str {
match self {
Self::Human => "human",
Self::Agent => "agent",
Self::Harness => "harness",
Self::Generated => "generated",
Self::Mixed => "mixed",
Self::Unknown => "unknown",
}
}
pub fn from_db_str(value: &str) -> Self {
value.parse().unwrap_or(Self::Unknown)
}
}
impl std::str::FromStr for MessageAuthorship {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"human" => Ok(Self::Human),
"agent" => Ok(Self::Agent),
"harness" => Ok(Self::Harness),
"generated" => Ok(Self::Generated),
"mixed" => Ok(Self::Mixed),
"unknown" => Ok(Self::Unknown),
other => Err(format!(
"unknown message authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"mixed\", \"unknown\""
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentPartAuthorship {
Human,
Agent,
Harness,
Generated,
Unknown,
}
impl ContentPartAuthorship {
pub const fn as_str(self) -> &'static str {
match self {
Self::Human => "human",
Self::Agent => "agent",
Self::Harness => "harness",
Self::Generated => "generated",
Self::Unknown => "unknown",
}
}
pub fn from_db_str(value: &str) -> Self {
value.parse().unwrap_or(Self::Unknown)
}
}
impl std::str::FromStr for ContentPartAuthorship {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"human" => Ok(Self::Human),
"agent" => Ok(Self::Agent),
"harness" => Ok(Self::Harness),
"generated" => Ok(Self::Generated),
"unknown" => Ok(Self::Unknown),
other => Err(format!(
"unknown content-part authorship: {other} — must be one of \"human\", \"agent\", \"harness\", \"generated\", \"unknown\""
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentPartOrigin {
DirectInput,
QuotedContent,
HarnessContext,
GeneratedSummary,
ToolPayload,
Unknown,
}
impl ContentPartOrigin {
pub const fn as_str(self) -> &'static str {
match self {
Self::DirectInput => "direct_input",
Self::QuotedContent => "quoted_content",
Self::HarnessContext => "harness_context",
Self::GeneratedSummary => "generated_summary",
Self::ToolPayload => "tool_payload",
Self::Unknown => "unknown",
}
}
pub fn from_db_str(value: &str) -> Self {
value.parse().unwrap_or(Self::Unknown)
}
}
impl std::str::FromStr for ContentPartOrigin {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"direct_input" => Ok(Self::DirectInput),
"quoted_content" => Ok(Self::QuotedContent),
"harness_context" => Ok(Self::HarnessContext),
"generated_summary" => Ok(Self::GeneratedSummary),
"tool_payload" => Ok(Self::ToolPayload),
"unknown" => Ok(Self::Unknown),
other => Err(format!(
"unknown content-part origin: {other} — must be one of \"direct_input\", \"quoted_content\", \"harness_context\", \"generated_summary\", \"tool_payload\", \"unknown\""
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageContentPart {
pub ordinal: u32,
pub start_char: usize,
pub end_char: usize,
pub authorship: ContentPartAuthorship,
pub origin: ContentPartOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageCorrelationAuthority {
Anthropic,
OpenAi,
Cursor,
Google,
Pi,
PrimeAgent,
}
impl MessageCorrelationAuthority {
pub const fn as_str(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAi => "open_ai",
Self::Cursor => "cursor",
Self::Google => "google",
Self::Pi => "pi",
Self::PrimeAgent => "prime_agent",
}
}
pub fn from_db_str(value: &str) -> Option<Self> {
value.parse().ok()
}
}
impl std::str::FromStr for MessageCorrelationAuthority {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"anthropic" => Ok(Self::Anthropic),
"open_ai" | "openai" => Ok(Self::OpenAi),
"cursor" => Ok(Self::Cursor),
"google" => Ok(Self::Google),
"pi" => Ok(Self::Pi),
"prime_agent" | "primeagent" => Ok(Self::PrimeAgent),
other => Err(format!(
"unknown message correlation authority: {other} — must be one of \"anthropic\", \"open_ai\", \"cursor\", \"google\", \"pi\", \"prime_agent\""
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageCorrelationIdentity {
pub authority: MessageCorrelationAuthority,
pub scope: String,
pub id: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageRecordRelation {
Original,
Mirror,
#[default]
Unknown,
}
impl MessageRecordRelation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Original => "original",
Self::Mirror => "mirror",
Self::Unknown => "unknown",
}
}
pub fn from_db_str(value: &str) -> Option<Self> {
value.parse().ok()
}
}
impl std::str::FromStr for MessageRecordRelation {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"original" => Ok(Self::Original),
"mirror" => Ok(Self::Mirror),
"unknown" => Ok(Self::Unknown),
other => Err(format!(
"unknown correlated record relation: {other} — must be one of \"original\", \"mirror\", \"unknown\""
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageProvenance {
pub authorship: MessageAuthorship,
pub record_relation: MessageRecordRelation,
pub correlation_identity: Option<MessageCorrelationIdentity>,
pub content_parts: Vec<MessageContentPart>,
}
impl MessageProvenance {
pub fn validate(&self, content: &str) -> Result<(), String> {
if let Some(identity) = &self.correlation_identity {
if identity.scope.trim().is_empty() {
return Err("message correlation scope must not be empty".to_string());
}
if identity.id.trim().is_empty() {
return Err("message correlation id must not be empty".to_string());
}
}
if self.authorship != MessageAuthorship::Mixed {
if !self.content_parts.is_empty() {
return Err(
"content_parts are allowed only when message authorship is \"mixed\""
.to_string(),
);
}
return Ok(());
}
if self.content_parts.is_empty() {
return Err("mixed message authorship requires content_parts".to_string());
}
let content_chars = content.chars().count();
let mut expected_start = 0;
let mut first_authorship = None;
let mut has_distinct_authorship = false;
for (index, part) in self.content_parts.iter().enumerate() {
if part.ordinal as usize != index {
return Err(format!(
"content part ordinal {} must equal its zero-based position {index}",
part.ordinal
));
}
if part.start_char != expected_start {
return Err(format!(
"content part {index} must start at character {expected_start}, not {}",
part.start_char
));
}
if part.end_char <= part.start_char {
return Err(format!(
"content part {index} must have a non-empty half-open character range"
));
}
if part.end_char > content_chars {
return Err(format!(
"content part {index} ends at character {}, beyond message length {content_chars}",
part.end_char
));
}
if let Some(first) = first_authorship {
has_distinct_authorship |= first != part.authorship;
} else {
first_authorship = Some(part.authorship);
}
expected_start = part.end_char;
}
if expected_start != content_chars {
return Err(format!(
"content parts end at character {expected_start}, before message length {content_chars}; represent gaps as explicit \"unknown\" parts"
));
}
if !has_distinct_authorship {
return Err(
"mixed message authorship requires at least two distinct part authorships"
.to_string(),
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub seq: i64,
pub role: Role,
pub ts: Option<DateTime<Utc>>,
pub tool_name: Option<String>,
pub kind: MessageKind,
pub tool_call_id: Option<String>,
pub is_compaction: bool,
pub content: String,
#[serde(default)]
pub provenance: MessageProvenance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub id: String,
pub provider: Provider,
pub provider_session_id: String,
pub title: Option<String>,
pub summary: Option<String>,
pub cwd: Option<String>,
pub repo_root: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub last_message_at: Option<DateTime<Utc>>,
pub preview_text: String,
pub source_path: String,
pub message_count: Option<i64>,
pub parse_version: String,
pub raw_metadata_json: Option<String>,
pub parse_warning: Option<String>,
pub discovery_source: String,
pub parent_session_id: Option<String>,
pub agent_label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AnalysisCursor(String);
impl AnalysisCursor {
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn after(session_id: String) -> Self {
Self(session_id)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisDocument {
pub session: SessionRecord,
pub user_text: String,
pub first_user_text: Option<String>,
pub message_count: i64,
pub user_message_count: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisDocumentPage {
pub documents: Vec<AnalysisDocument>,
pub next_cursor: Option<AnalysisCursor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEdit {
pub seq: i64,
pub ts: Option<DateTime<Utc>>,
pub tool: String,
pub file_path: String,
pub file_name: String,
pub new_content: Option<String>,
pub edits: Vec<EditOp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditOp {
pub old: String,
pub new: String,
#[serde(default)]
pub replace_all: bool,
}
impl EditOp {
pub fn new(old: impl Into<String>, new: impl Into<String>) -> Self {
Self {
old: old.into(),
new: new.into(),
replace_all: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedSession {
pub session: SessionRecord,
pub transcript_text: String,
pub messages: Vec<Message>,
pub file_edits: Vec<FileEdit>,
}
#[derive(Debug, Clone)]
pub struct SourceFile {
pub provider: Provider,
pub path: std::path::PathBuf,
pub mtime_ns: i64,
pub size_bytes: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
#[clap(rename_all = "kebab-case")]
pub enum SessionKind {
User,
Subagent,
}
impl SessionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Subagent => "subagent",
}
}
pub fn all() -> Vec<Self> {
use clap::ValueEnum;
Self::value_variants().to_vec()
}
pub fn default_search_set() -> Vec<Self> {
Self::all()
}
pub fn sql_predicate(self, alias: &str) -> String {
match self {
Self::User => format!("{alias}.parent_session_id is null"),
Self::Subagent => format!("{alias}.parent_session_id is not null"),
}
}
}
impl std::fmt::Display for SessionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for SessionKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().replace('-', "_").as_str() {
"user" => Ok(Self::User),
"subagent" => Ok(Self::Subagent),
other => Err(format!(
"unknown session kind: {other} — must be one of \"user\" (a session you started) or \"subagent\" (a run one of those spawned)"
)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchFilters {
pub provider: Option<Provider>,
pub session_kinds: Option<Vec<SessionKind>>,
pub path_prefix: Option<String>,
pub exclude_path_prefixes: Vec<String>,
pub exclude_session_ids: Vec<String>,
pub parent_session_id: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
pub warnings_only: bool,
}
impl SearchFilters {
pub fn effective_session_kinds(&self) -> Vec<SessionKind> {
self.session_kinds
.clone()
.unwrap_or_else(SessionKind::default_search_set)
}
pub fn validate(&self) -> anyhow::Result<()> {
let only_user_sessions = self.session_kinds.as_ref().is_some_and(|kinds| {
!kinds.is_empty() && kinds.iter().all(|kind| *kind == SessionKind::User)
});
if self.parent_session_id.is_some() && only_user_sessions {
anyhow::bail!(
"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"
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AnalysisSessionSelection {
#[default]
AllEligible,
FirstCanonicalSessions { max_sessions: NonZeroUsize },
}
impl AnalysisSessionSelection {
pub(crate) const fn max_sessions(self) -> Option<NonZeroUsize> {
match self {
Self::AllEligible => None,
Self::FirstCanonicalSessions { max_sessions } => Some(max_sessions),
}
}
}
#[derive(Debug, Clone)]
pub struct AnalysisRequest {
scope: SearchFilters,
selection: AnalysisSessionSelection,
}
impl AnalysisRequest {
pub fn new(scope: SearchFilters, selection: AnalysisSessionSelection) -> anyhow::Result<Self> {
scope.validate()?;
if scope.limit != 0 {
anyhow::bail!(
"analysis does not accept SearchFilters.limit={}; set it to 0 and choose \
AnalysisSessionSelection::FirstCanonicalSessions {{ max_sessions }} for the \
explicitly labeled canonical-order prefix, or AllEligible for every session in \
scope",
scope.limit
);
}
Ok(Self { scope, selection })
}
pub const fn scope(&self) -> &SearchFilters {
&self.scope
}
pub const fn selection(&self) -> AnalysisSessionSelection {
self.selection
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionWithTranscript {
#[serde(flatten)]
pub session: SessionRecord,
pub transcript_text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchHit {
#[serde(flatten)]
pub session: SessionRecord,
pub score: i64,
pub match_source: String,
pub match_snippet: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MessageFilters {
pub role: Option<Role>,
pub kinds: Option<Vec<MessageKind>>,
pub field: Option<SearchField>,
pub argument_path: Option<String>,
pub providers: Option<Vec<Provider>>,
pub session_id: Option<String>,
pub path_prefix: Option<String>,
pub exclude_path_prefixes: Vec<String>,
#[doc(hidden)]
pub workspace_path_prefix: Option<String>,
#[doc(hidden)]
pub transcript_path_prefix: Option<String>,
#[doc(hidden)]
pub exclude_workspace_path_prefixes: Vec<String>,
#[doc(hidden)]
pub exclude_transcript_path_prefixes: Vec<String>,
pub exclude_session_ids: Vec<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub seq_from: Option<i64>,
pub seq_to: Option<i64>,
pub match_mode: MessageSearchMode,
pub tool: Option<String>,
pub no_compaction: bool,
pub session_kinds: Option<Vec<SessionKind>>,
pub limit: usize,
pub offset: usize,
}
impl MessageFilters {
pub fn validate(&self, query: &str) -> anyhow::Result<()> {
use anyhow::{bail, ensure};
if self.match_mode == MessageSearchMode::Fuzzy {
ensure!(
query.chars().take(3).count() >= 3,
"fuzzy search requires at least 3 characters; use literal search for shorter text"
);
ensure!(
!query.trim().is_empty(),
"fuzzy query must contain a non-whitespace character"
);
ensure!(
self.limit > 0,
"fuzzy search requires a finite non-zero limit; literal search supports unlimited results"
);
}
ensure!(
!query.is_empty() || self.match_mode == MessageSearchMode::Literal,
"match_mode={} requires a non-empty query",
self.match_mode.as_str()
);
let offset = i64::try_from(self.offset)
.map_err(|_| anyhow::anyhow!("offset exceeds SQLite's signed 64-bit limit"))?;
if self.limit > 0 {
let limit = i64::try_from(self.limit)
.map_err(|_| anyhow::anyhow!("limit exceeds SQLite's signed 64-bit limit"))?;
offset.checked_add(limit).ok_or_else(|| {
anyhow::anyhow!("offset + limit exceeds SQLite's signed 64-bit limit")
})?;
}
if self.seq_from.is_some() || self.seq_to.is_some() {
if self.seq_from.is_some_and(|seq| seq < 0) || self.seq_to.is_some_and(|seq| seq < 0) {
bail!("seq_from and seq_to must be non-negative");
}
if let (Some(from), Some(to)) = (self.seq_from, self.seq_to) {
ensure!(from <= to, "seq_from must be <= seq_to");
}
}
let field = self.field.unwrap_or(SearchField::Content);
if field == SearchField::ToolArgument {
let pointer = self
.argument_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("tool-argument search requires argument_path"))?;
crate::message_search::JsonPointer::new(pointer.to_string()).map_err(|error| {
anyhow::anyhow!("argument_path must be an RFC 6901 JSON pointer: {error}")
})?;
ensure!(
self.kinds
.as_ref()
.is_none_or(|kinds| kinds.contains(&MessageKind::ToolCall)),
"tool-argument search requires tool_call among the selected kinds"
);
} else if self.argument_path.is_some() {
bail!("argument_path requires field=tool_argument");
}
Ok(())
}
pub fn narrows_corpus(&self) -> bool {
self.role.is_some()
|| self.kinds.is_some()
|| self.providers.is_some()
|| self.session_id.is_some()
|| self.path_prefix.is_some()
|| !self.exclude_path_prefixes.is_empty()
|| self.workspace_path_prefix.is_some()
|| self.transcript_path_prefix.is_some()
|| !self.exclude_workspace_path_prefixes.is_empty()
|| !self.exclude_transcript_path_prefixes.is_empty()
|| !self.exclude_session_ids.is_empty()
|| self.since.is_some()
|| self.until.is_some()
|| self.seq_from.is_some()
|| self.seq_to.is_some()
|| self.tool.is_some()
|| self.no_compaction
|| self.kinds.is_some()
}
pub fn effective_kinds(&self) -> Vec<MessageKind> {
let mut kinds = self
.kinds
.clone()
.unwrap_or_else(MessageKind::default_search_set);
if self.no_compaction {
kinds.retain(|kind| *kind != MessageKind::Compaction);
}
kinds
}
pub fn kind_predicate(&self) -> KindPredicate {
if self.kinds.is_some() {
return KindPredicate::Only(self.effective_kinds());
}
let mut excluded = vec![MessageKind::HarnessNotice];
if self.no_compaction {
excluded.push(MessageKind::Compaction);
}
KindPredicate::AllExcept(excluded)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MessageHit {
pub session_id: String,
pub provider: Provider,
pub seq: i64,
pub role: Role,
pub kind: MessageKind,
pub ts: Option<DateTime<Utc>>,
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy_score: Option<u32>,
pub content: String,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SessionMeta {
pub provider_session_id: Option<String>,
pub cwd: Option<String>,
pub repo_root: Option<String>,
pub title: Option<String>,
pub updated_at: Option<DateTime<Utc>>,
pub last_message_at: Option<DateTime<Utc>>,
pub message_count: Option<i64>,
pub parse_warning: Option<String>,
pub parent_session_id: Option<String>,
pub agent_label: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProviderParserHealth {
pub provider: Provider,
pub expected_parse_version: String,
pub indexed_sessions: i64,
pub current_sessions: i64,
pub stale_sessions: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ParserHealth {
pub schema_version: i64,
pub expected_schema_version: i64,
pub schema_current: bool,
pub indexed_sessions: i64,
pub current_sessions: i64,
pub stale_sessions: i64,
pub parse_warnings: i64,
pub providers: Vec<ProviderParserHealth>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexSnapshotAvailability {
Unavailable,
Usable,
}
impl IndexSnapshotAvailability {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unavailable => "unavailable",
Self::Usable => "usable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexSnapshotStatus {
pub availability: IndexSnapshotAvailability,
pub last_successful_refresh_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexRefreshState {
NotStarted,
Indexing,
Fresh,
Postponed,
FailedWithRecovery,
}
impl IndexRefreshState {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotStarted => "not_started",
Self::Indexing => "indexing",
Self::Fresh => "fresh",
Self::Postponed => "postponed",
Self::FailedWithRecovery => "failed_with_recovery",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexRefreshTrigger {
IntegrationInstall,
CommandLine,
Mcp,
}
impl IndexRefreshTrigger {
pub const fn as_str(self) -> &'static str {
match self {
Self::IntegrationInstall => "integration_install",
Self::CommandLine => "command_line",
Self::Mcp => "mcp",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexRefreshStatus {
pub state: IndexRefreshState,
pub started_by: Option<IndexRefreshTrigger>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub files_discovered: Option<usize>,
pub files_processed: Option<usize>,
pub sessions_updated: Option<usize>,
pub retry_after_ms: Option<u64>,
pub message: Option<String>,
pub next_command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexReadinessStatus {
pub snapshot: IndexSnapshotStatus,
pub refresh: IndexRefreshStatus,
}
impl IndexReadinessStatus {
pub const fn not_started() -> Self {
Self {
snapshot: IndexSnapshotStatus {
availability: IndexSnapshotAvailability::Unavailable,
last_successful_refresh_at: None,
},
refresh: IndexRefreshStatus {
state: IndexRefreshState::NotStarted,
started_by: None,
started_at: None,
finished_at: None,
files_discovered: None,
files_processed: None,
sessions_updated: None,
retry_after_ms: None,
message: None,
next_command: None,
},
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct IndexStatus {
pub parser_health: ParserHealth,
pub repairable_stale_sessions: i64,
pub unavailable_stale_sessions: i64,
pub unindexed_files: i64,
pub repair_commands: Vec<String>,
pub readiness: IndexReadinessStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionTimeProfile {
pub messages: i64,
pub timestamped_messages: i64,
pub undated_messages: i64,
pub first_timestamp: Option<DateTime<Utc>>,
pub last_timestamp: Option<DateTime<Utc>>,
pub observed_span_seconds: Option<i64>,
pub max_message_gap_seconds: Option<i64>,
pub tool_calls: i64,
pub tool_results: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchExplain {
pub prefilter: Option<String>,
pub candidates: Option<i64>,
pub prefilter_skipped: Option<String>,
pub corpus: i64,
}
impl SearchExplain {
pub fn summary(&self, has_content_query: bool) -> String {
match (&self.prefilter, self.candidates) {
(None, Some(candidates)) if has_content_query && self.prefilter_skipped.is_some() => {
format!(
"[explain] {} / {} corpus rows matched after {}",
candidates,
self.corpus,
self.prefilter_skipped
.as_deref()
.unwrap_or("content scoring")
)
}
(Some(prefilter), Some(candidates)) => {
let pct = if self.corpus > 0 {
100.0 * candidates as f64 / self.corpus as f64
} else {
0.0
};
let hint = if pct >= 50.0 {
" — low selectivity; anchor the regex on a rarer literal substring"
} else {
""
};
format!(
"[explain] trigram prefilter: {prefilter}\n\
[explain] candidates: {candidates} / {} corpus rows ({pct:.1}%) to verify{hint}",
self.corpus
)
}
(Some(prefilter), None) if has_content_query && self.prefilter_skipped.is_some() => {
format!(
"[explain] trigram prefilter available: {prefilter}\n\
[explain] skipped trigram prefilter: {}; direct scan of {} corpus rows",
self.prefilter_skipped.as_deref().unwrap_or("not used"),
self.corpus
)
}
(None, None) if has_content_query && self.prefilter_skipped.is_some() => format!(
"[explain] {}; scanned {} corpus rows",
self.prefilter_skipped.as_deref().unwrap_or("no prefilter"),
self.corpus
),
_ if has_content_query => format!(
"[explain] query has no >=3-char literal anchor → full scan of {} corpus rows",
self.corpus
),
_ => format!(
"[explain] {} corpus rows; no content query was provided",
self.corpus
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageClassificationMatch {
pub session_id: String,
pub message_seq: i64,
pub provider: Provider,
pub ts: Option<DateTime<Utc>>,
pub policy_name: String,
pub category: String,
pub matched_text: String,
pub match_start_char: usize,
pub match_end_char_exclusive: usize,
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlanningCount {
pub command: String,
pub count: i64,
pub unique_sessions: i64,
pub unique_projects: i64,
}
#[derive(Debug, Clone, Default)]
pub struct FileQuery {
pub pattern: Option<String>,
pub provider: Option<Provider>,
pub session_id: Option<String>,
pub path_prefix: Option<String>,
pub exclude_path_prefixes: Vec<String>,
pub exclude_session_ids: Vec<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub min_edits: Option<i64>,
pub max_edits: Option<i64>,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileEditSummary {
pub file_path: String,
pub file_name: String,
pub edits: i64,
pub sessions: i64,
pub last_edited: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileVersion {
pub session_id: String,
pub provider: Provider,
pub version: i64,
pub tool: String,
pub ts: Option<DateTime<Utc>>,
pub lines: i64,
pub file_path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileCrossRef {
pub file_path: String,
pub session_id: String,
pub provider: Provider,
pub edits: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProviderHealth {
pub provider: Provider,
pub enabled: bool,
pub cli_available: bool,
pub roots: Vec<String>,
pub discovered_files: usize,
pub indexed_sessions: i64,
pub unindexed_files: i64,
pub expected_parse_version: String,
pub current_sessions: i64,
pub stale_sessions: i64,
pub repairable_stale_sessions: i64,
pub unavailable_stale_sessions: i64,
pub resume_command: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticStatus {
pub db_path: String,
#[serde(flatten)]
pub index_status: IndexStatus,
pub discovery_warnings: Vec<crate::source::ProviderDiscoveryWarning>,
pub providers: Vec<ProviderHealth>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_provenance_accepts_complete_unicode_scalar_parts_and_scoped_correlation() {
let provenance = MessageProvenance {
authorship: MessageAuthorship::Mixed,
record_relation: MessageRecordRelation::Mirror,
correlation_identity: Some(MessageCorrelationIdentity {
authority: MessageCorrelationAuthority::Google,
scope: "gemini_conversation_event".to_string(),
id: "event-7".to_string(),
}),
content_parts: vec![
MessageContentPart {
ordinal: 0,
start_char: 0,
end_char: 2,
authorship: ContentPartAuthorship::Human,
origin: ContentPartOrigin::DirectInput,
},
MessageContentPart {
ordinal: 1,
start_char: 2,
end_char: 4,
authorship: ContentPartAuthorship::Generated,
origin: ContentPartOrigin::QuotedContent,
},
],
};
assert_eq!("aé日🙂".chars().count(), 4);
assert!(provenance.validate("aé日🙂").is_ok());
}
#[test]
fn message_provenance_rejects_ambiguous_parts_and_correlation_identities() {
let valid_parts = vec![
MessageContentPart {
ordinal: 0,
start_char: 0,
end_char: 2,
authorship: ContentPartAuthorship::Human,
origin: ContentPartOrigin::DirectInput,
},
MessageContentPart {
ordinal: 1,
start_char: 2,
end_char: 4,
authorship: ContentPartAuthorship::Generated,
origin: ContentPartOrigin::QuotedContent,
},
];
for (provenance, expected) in [
(
MessageProvenance {
authorship: MessageAuthorship::Human,
content_parts: valid_parts.clone(),
..Default::default()
},
"only when message authorship is \"mixed\"",
),
(
MessageProvenance {
authorship: MessageAuthorship::Mixed,
..Default::default()
},
"requires content_parts",
),
(
MessageProvenance {
authorship: MessageAuthorship::Mixed,
content_parts: vec![MessageContentPart {
ordinal: 0,
start_char: 0,
end_char: 4,
authorship: ContentPartAuthorship::Human,
origin: ContentPartOrigin::DirectInput,
}],
..Default::default()
},
"at least two distinct",
),
(
MessageProvenance {
authorship: MessageAuthorship::Mixed,
content_parts: vec![
valid_parts[0].clone(),
MessageContentPart {
start_char: 3,
..valid_parts[1].clone()
},
],
..Default::default()
},
"must start at character 2",
),
(
MessageProvenance {
authorship: MessageAuthorship::Unknown,
correlation_identity: Some(MessageCorrelationIdentity {
authority: MessageCorrelationAuthority::Google,
scope: " ".to_string(),
id: "event".to_string(),
}),
content_parts: Vec::new(),
..Default::default()
},
"scope must not be empty",
),
] {
let error = provenance.validate("aé日🙂").unwrap_err();
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn correlation_authority_prevents_equal_native_ids_from_collapsing() {
let first = MessageCorrelationIdentity {
authority: MessageCorrelationAuthority::OpenAi,
scope: "response_item".to_string(),
id: "shared".to_string(),
};
let second = MessageCorrelationIdentity {
authority: MessageCorrelationAuthority::Google,
..first.clone()
};
assert_ne!(first, second);
}
#[test]
fn original_and_mirror_rows_share_one_correlation_identity() {
let identity = MessageCorrelationIdentity {
authority: MessageCorrelationAuthority::Google,
scope: "conversation_event".to_string(),
id: "event-7".to_string(),
};
let original = MessageProvenance {
record_relation: MessageRecordRelation::Original,
correlation_identity: Some(identity.clone()),
..Default::default()
};
let mirror = MessageProvenance {
record_relation: MessageRecordRelation::Mirror,
correlation_identity: Some(identity.clone()),
..Default::default()
};
assert_ne!(original, mirror);
assert_eq!(original.correlation_identity, mirror.correlation_identity);
}
#[test]
fn known_mirror_relation_does_not_require_a_correlation_identity() {
let provenance = MessageProvenance {
record_relation: MessageRecordRelation::Mirror,
..Default::default()
};
assert!(provenance.validate("replayed transcript").is_ok());
}
#[test]
fn default_class_selection_excludes_rather_than_enumerates() {
let default = MessageFilters::default();
assert_eq!(
default.kind_predicate(),
KindPredicate::AllExcept(vec![MessageKind::HarnessNotice]),
"the default must be an exclusion so unrecognized kinds survive"
);
let no_compaction = MessageFilters {
no_compaction: true,
..Default::default()
};
assert_eq!(
no_compaction.kind_predicate(),
KindPredicate::AllExcept(vec![MessageKind::HarnessNotice, MessageKind::Compaction]),
"no_compaction narrows the same exclusion rather than switching to an inclusion"
);
}
#[test]
fn every_default_exclusion_is_covered_by_the_excludable_set() {
for no_compaction in [false, true] {
let filters = MessageFilters {
no_compaction,
..Default::default()
};
let KindPredicate::AllExcept(excluded) = filters.kind_predicate() else {
panic!("naming no set must produce an exclusion");
};
for kind in excluded {
assert!(
MessageKind::EXCLUDABLE_BY_DEFAULT.contains(&kind),
"{kind:?} is excluded by default but missing from EXCLUDABLE_BY_DEFAULT"
);
}
}
let mut names: Vec<&str> = MessageKind::EXCLUDABLE_BY_DEFAULT
.iter()
.map(MessageKind::as_str)
.collect();
let rendered = names.clone();
names.sort_unstable();
assert_eq!(rendered, names, "kept sorted so rendered SQL is stable");
}
#[test]
fn session_class_spellings_match_the_providers_and_exclude_the_ambiguous_ones() {
use std::str::FromStr;
assert_eq!(SessionKind::User.as_str(), "user");
assert_eq!(SessionKind::Subagent.as_str(), "subagent");
for kind in SessionKind::all() {
assert_eq!(
SessionKind::from_str(kind.as_str()),
Ok(kind),
"every spelling this emits must be one it accepts"
);
}
for rejected in [
"agent",
"top_level",
"top-level",
"sidechain",
"child",
"spawned",
] {
let error = SessionKind::from_str(rejected)
.expect_err(&format!("{rejected} must not be an accepted spelling"));
assert!(
error.contains("user") && error.contains("subagent"),
"a rejection must name both accepted values: {error}"
);
}
assert_eq!(SessionKind::from_str("SubAgent"), Ok(SessionKind::Subagent));
}
#[test]
fn the_default_session_filter_selects_every_session() {
let SearchFilters {
provider,
session_kinds,
path_prefix,
exclude_path_prefixes,
exclude_session_ids,
parent_session_id,
since,
until,
limit,
warnings_only,
} = SearchFilters::default();
assert_eq!(provider, None, "no provider named means every provider");
assert_eq!(session_kinds, None, "no class named means every class");
assert_eq!(path_prefix, None);
assert!(exclude_path_prefixes.is_empty());
assert!(exclude_session_ids.is_empty());
assert_eq!(parent_session_id, None, "not restricted to one spawner");
assert_eq!(since, None);
assert_eq!(until, None);
assert_eq!(limit, 0, "0 is the unbounded page; callers state their own");
assert!(!warnings_only);
}
#[test]
fn the_default_session_class_set_is_every_class() {
assert_eq!(
SearchFilters::default().effective_session_kinds(),
vec![SessionKind::User, SessionKind::Subagent]
);
assert_eq!(SessionKind::default_search_set(), SessionKind::all());
let only_runs = SearchFilters {
session_kinds: Some(vec![SessionKind::Subagent]),
..SearchFilters::default()
};
assert_eq!(
only_runs.effective_session_kinds(),
vec![SessionKind::Subagent]
);
let none = SearchFilters {
session_kinds: Some(Vec::new()),
..SearchFilters::default()
};
assert!(
none.effective_session_kinds().is_empty(),
"an empty set is a caller who deselected every class, not an absent one"
);
}
#[test]
fn a_named_class_set_selects_exactly_those_classes() {
let notices = MessageFilters {
kinds: Some(vec![MessageKind::HarnessNotice]),
..Default::default()
};
assert_eq!(
notices.kind_predicate(),
KindPredicate::Only(vec![MessageKind::HarnessNotice]),
"asking for harness notices must return them, not self-cancel against the default"
);
let conflict = MessageFilters {
kinds: Some(vec![MessageKind::Compaction]),
no_compaction: true,
..Default::default()
};
assert_eq!(
conflict.kind_predicate(),
KindPredicate::Only(Vec::new()),
"an emptied set is reported as empty so the caller gets an error, not silence"
);
}
#[test]
fn the_default_search_set_is_every_class_except_harness_notices() {
let set = MessageKind::default_search_set();
assert!(!set.contains(&MessageKind::HarnessNotice));
for kind in [
MessageKind::Conversation,
MessageKind::Compaction,
MessageKind::ToolCall,
MessageKind::ToolResult,
MessageKind::Unknown,
] {
assert!(
set.contains(&kind),
"{kind:?} must be searchable by default"
);
}
}
#[test]
fn every_typed_value_parses_back_from_the_string_it_renders() {
use std::str::FromStr;
macro_rules! assert_round_trips {
($($type:ty => [$($variant:expr),+ $(,)?]),+ $(,)?) => {
$($(
let rendered = $variant.as_str();
assert_eq!(
<$type>::from_str(rendered).ok(),
Some($variant),
"{} renders as {rendered:?}, which parses back as something else",
stringify!($variant),
);
)+)+
};
}
assert_round_trips! {
Provider => [
Provider::Claude, Provider::ClaudeDesktop, Provider::Codex, Provider::Cursor,
Provider::Antigravity, Provider::Pi, Provider::PrimeAgent, Provider::AiStudio,
Provider::GeminiCli,
],
Role => [Role::User, Role::Assistant, Role::Tool, Role::Slash, Role::Compaction],
MessageSearchMode => [
MessageSearchMode::Literal, MessageSearchMode::Regex, MessageSearchMode::Fuzzy,
],
SearchField => [
SearchField::Content, SearchField::ToolName, SearchField::ToolArgument,
],
MessageKind => [
MessageKind::Conversation, MessageKind::Compaction, MessageKind::ToolCall,
MessageKind::ToolResult, MessageKind::HarnessNotice, MessageKind::Unknown,
],
MessageAuthorship => [
MessageAuthorship::Human, MessageAuthorship::Agent, MessageAuthorship::Harness,
MessageAuthorship::Generated, MessageAuthorship::Mixed, MessageAuthorship::Unknown,
],
ContentPartAuthorship => [
ContentPartAuthorship::Human, ContentPartAuthorship::Agent,
ContentPartAuthorship::Harness, ContentPartAuthorship::Generated,
ContentPartAuthorship::Unknown,
],
ContentPartOrigin => [
ContentPartOrigin::DirectInput, ContentPartOrigin::QuotedContent,
ContentPartOrigin::HarnessContext, ContentPartOrigin::GeneratedSummary,
ContentPartOrigin::ToolPayload, ContentPartOrigin::Unknown,
],
MessageCorrelationAuthority => [
MessageCorrelationAuthority::Anthropic, MessageCorrelationAuthority::OpenAi,
MessageCorrelationAuthority::Cursor, MessageCorrelationAuthority::Google,
MessageCorrelationAuthority::Pi, MessageCorrelationAuthority::PrimeAgent,
],
MessageRecordRelation => [
MessageRecordRelation::Original, MessageRecordRelation::Mirror,
MessageRecordRelation::Unknown,
],
SessionKind => [SessionKind::User, SessionKind::Subagent],
}
}
#[test]
fn every_message_kind_round_trips_through_its_database_spelling() {
use clap::ValueEnum;
for kind in MessageKind::value_variants() {
assert_eq!(
MessageKind::from_db_str(kind.as_str()),
*kind,
"{} must not decode as a different kind after a database read",
kind.as_str()
);
}
}
#[test]
fn unrecognized_provider_spelling_is_rejected_in_every_build() {
let error = Provider::from_db_str("not_a_provider").unwrap_err();
assert!(
error.contains("not_a_provider"),
"the error must identify the corrupt stored value: {error}"
);
}
#[test]
fn unrecognized_role_spelling_is_rejected_in_every_build() {
let error = Role::from_db_str("not_a_role").unwrap_err();
assert!(
error.contains("not_a_role"),
"the error must identify the corrupt stored value: {error}"
);
}
// `MessageKind` is the deliberate exception to the rule above, and this test pins the
// exception so nobody "fixes" it into consistency. An earlier attempt to give it the same
// `debug_assert!` broke
// `cli_search_self_heals_v4_hybrid_missing_trigram_from_intact_messages`, which reads rows
// storing `kind = 'message'`: older indexes legitimately hold spellings this build does not
// know, and absorbing them is what `Unknown` is FOR. Panicking would turn "opened an older
#[test]
fn unrecognized_message_kind_decodes_quietly_because_older_indexes_hold_other_spellings() {
assert_eq!(
MessageKind::from_db_str("message"),
MessageKind::Unknown,
"a legacy on-disk spelling must decode, not panic"
);
assert_eq!(
MessageKind::from_db_str(MessageKind::Unknown.as_str()),
MessageKind::Unknown,
"\"unknown\" is itself a legitimate stored spelling"
);
}
#[test]
fn programmatic_search_field_accepts_cli_spelling_but_serializes_canonically() {
assert_eq!(
"tool-argument".parse::<SearchField>().unwrap(),
SearchField::ToolArgument
);
assert_eq!(
"tool_argument".parse::<SearchField>().unwrap(),
SearchField::ToolArgument
);
assert_eq!(SearchField::ToolArgument.as_str(), "tool_argument");
assert_eq!(
serde_json::to_string(&SearchField::ToolArgument).unwrap(),
"\"tool_argument\""
);
}
#[test]
fn every_parsed_vocabulary_names_its_accepted_values_when_rejecting_input() {
for (rejected, accepted) in [
(
"usr".parse::<Role>().unwrap_err(),
vec!["user", "assistant", "tool", "slash", "compaction"],
),
(
"contnet".parse::<SearchField>().unwrap_err(),
vec!["content", "tool_name", "tool_argument"],
),
(
"chatgpt".parse::<Provider>().unwrap_err(),
vec![
"claude",
"claude-desktop",
"codex",
"cursor",
"antigravity",
"pi",
"aistudio",
"gemini-cli",
],
),
(
"converstaion".parse::<MessageKind>().unwrap_err(),
vec![
"conversation",
"compaction",
"tool_call",
"tool_result",
"unknown",
],
),
] {
assert!(
rejected.contains("must be one of"),
"no accepted-value list in: {rejected}"
);
for value in accepted {
assert!(
rejected.contains(&format!("{value:?}")),
"{value} missing from: {rejected}"
);
}
}
}
#[test]
fn fuzzy_message_validation_rejects_short_queries_before_database_work() {
let filters = MessageFilters {
match_mode: MessageSearchMode::Fuzzy,
limit: 5,
..Default::default()
};
for query in ["", "a", "éx"] {
let error = filters.validate(query).unwrap_err().to_string();
assert!(
error.contains("at least 3 characters"),
"unexpected error for {query:?}: {error}"
);
assert!(error.contains("literal"), "{error}");
}
assert!(filters.validate("éx!").is_ok());
}
#[test]
fn fuzzy_message_validation_requires_a_finite_page_without_an_arbitrary_window_cap() {
let unlimited = MessageFilters {
match_mode: MessageSearchMode::Fuzzy,
..Default::default()
};
assert!(unlimited
.validate("query")
.unwrap_err()
.to_string()
.contains("finite non-zero limit"));
let large_valid_page = MessageFilters {
match_mode: MessageSearchMode::Fuzzy,
offset: 10_000,
limit: 1,
..Default::default()
};
assert!(large_valid_page.validate("query").is_ok());
let overflowing = MessageFilters {
match_mode: MessageSearchMode::Fuzzy,
offset: usize::MAX,
limit: 1,
..Default::default()
};
assert!(overflowing
.validate("query")
.unwrap_err()
.to_string()
.contains("signed 64-bit"));
let whitespace = MessageFilters {
match_mode: MessageSearchMode::Fuzzy,
limit: 5,
..Default::default()
};
assert!(whitespace
.validate(" \t\n")
.unwrap_err()
.to_string()
.contains("non-whitespace"));
}
#[test]
fn legacy_tool_argument_filter_rejects_malformed_json_pointer_escapes() {
let filters = MessageFilters {
field: Some(SearchField::ToolArgument),
argument_path: Some("/~bad".into()),
..Default::default()
};
let error = filters.validate("needle").unwrap_err().to_string();
assert!(error.contains("RFC 6901"), "{error}");
assert!(error.contains("'~' must be followed"), "{error}");
}
#[test]
fn message_page_validation_rejects_values_sqlite_cannot_bind() {
let filters = MessageFilters {
offset: i64::MAX as usize,
limit: 1,
..Default::default()
};
assert!(filters
.validate("")
.unwrap_err()
.to_string()
.contains("offset + limit"));
if let Some(too_large) = usize::try_from(i64::MAX)
.ok()
.and_then(|maximum| maximum.checked_add(1))
{
let filters = MessageFilters {
offset: too_large,
..Default::default()
};
assert!(filters
.validate("")
.unwrap_err()
.to_string()
.contains("offset exceeds"));
}
}
#[test]
fn every_provider_has_a_concrete_display_name_and_resume_contract() {
let providers = crate::source::PROVIDERS;
assert!(providers
.iter()
.all(|provider| !provider.display_name().trim().is_empty()));
assert_eq!(
providers
.into_iter()
.filter(|provider| provider.supports_native_resume())
.map(Provider::as_str)
.collect::<Vec<_>>(),
["claude", "codex", "pi", "prime-agent"]
);
}
#[test]
fn explain_summary_reports_prefilter_and_selectivity_pct() {
let ex = SearchExplain {
prefilter: Some("\"abc\"".to_string()),
candidates: Some(80),
prefilter_skipped: None,
corpus: 100,
};
let s = ex.summary(true);
assert!(s.contains("trigram prefilter: \"abc\""), "{s}");
assert!(s.contains("80 / 100 corpus rows (80.0%)"), "{s}");
assert!(s.contains("low selectivity"), "{s}");
}
#[test]
fn explain_summary_omits_hint_when_prefilter_is_selective() {
let ex = SearchExplain {
prefilter: Some("\"rareword\"".to_string()),
candidates: Some(2),
prefilter_skipped: None,
corpus: 1000,
};
let s = ex.summary(true);
assert!(s.contains("2 / 1000 corpus rows (0.2%)"), "{s}");
assert!(
!s.contains("low selectivity"),
"selective query gets no hint: {s}"
);
}
#[test]
fn explain_summary_flags_regex_without_literal_anchor() {
let ex = SearchExplain {
prefilter: None,
candidates: None,
prefilter_skipped: None,
corpus: 500,
};
let s = ex.summary(true);
assert!(s.contains("no >=3-char literal anchor"), "{s}");
assert!(s.contains("full scan of 500 corpus rows"), "{s}");
}
#[test]
fn explain_summary_notes_no_content_query_for_empty_searches() {
let ex = SearchExplain {
prefilter: None,
candidates: None,
prefilter_skipped: None,
corpus: 42,
};
let s = ex.summary(false);
assert!(s.contains("42 corpus rows"), "{s}");
assert!(s.contains("no content query was provided"), "{s}");
}
#[test]
fn explain_summary_handles_empty_corpus_without_dividing_by_zero() {
let ex = SearchExplain {
prefilter: Some("\"x\"".to_string()),
candidates: Some(0),
prefilter_skipped: None,
corpus: 0,
};
let s = ex.summary(true);
assert!(s.contains("0 / 0 corpus rows (0.0%)"), "{s}");
}
#[test]
fn explain_summary_reports_intentional_prefilter_skip() {
let ex = SearchExplain {
prefilter: Some("\"rare\"".to_string()),
candidates: None,
prefilter_skipped: Some("corpus below configured threshold".to_string()),
corpus: 25,
};
let s = ex.summary(true);
assert!(s.contains("trigram prefilter available"), "{s}");
assert!(s.contains("skipped trigram prefilter"), "{s}");
assert!(s.contains("direct scan of 25 corpus rows"), "{s}");
}
#[test]
fn explain_summary_names_a_verified_scan_without_inventing_a_candidate_count() {
let ex = SearchExplain {
prefilter: None,
candidates: None,
prefilter_skipped: Some("tool name verified in SQLite without a prefilter".to_string()),
corpus: 3,
};
let s = ex.summary(true);
assert!(
s.contains("tool name verified in SQLite without a prefilter"),
"{s}"
);
assert!(s.contains("scanned 3 corpus rows"), "{s}");
assert!(!s.contains("anchor"), "{s}");
assert!(!s.contains("%"), "{s}");
}
}