Skip to main content

ai_session_search/
sql_query.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4use std::cmp::Ordering;
5use std::collections::{BTreeMap, HashMap};
6use std::io::{self, Write};
7use std::num::NonZeroU64;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::{bail, Context, Result};
12use clap::{Args, Subcommand};
13use rusqlite::hooks::{AuthAction, Authorization};
14use rusqlite::types::ValueRef;
15use rusqlite::{Connection, OpenFlags};
16use serde_json::{json, Number, Value};
17
18use crate::render::{csv_escape, OutputFormat};
19
20pub const DEFAULT_LIMIT: usize = 100;
21/// Native CLI/Rust raw-SQL default. Zero means valid read-only work is not interrupted merely
22/// because the caller omitted a timer; MCP owns a separate, initially-zero availability guard.
23pub const DEFAULT_TIMEOUT_MS: u64 = 0;
24pub const DEFAULT_MCP_MAX_CELL_CHARS: usize = 1_000;
25const SESSION_INDEX_NOUN: &str = "local AI session-history tables";
26
27/// What to do about a raw-SQL query that ran out of time, printed at the point of failure.
28///
29/// Narrowing the SQL and raising the bound both keep the reader scanning, so the indexed surface
30/// leads. `aise db query --help` already names it, but a reader only reaches this text after
31/// passing that help, and the query that times out is usually the one FTS already answers. One
32/// string serves the CLI and the MCP tool, so both spellings appear, matching how the sentence
33/// below carries the canonical `timeout_ms` beside its CLI flag.
34const QUERY_TIMEOUT_RECOVERY: &str = "message content and tool names are indexed, so `aise messages search` (MCP: search_messages) answers those without scanning, with --field tool-name for tool calls. Otherwise narrow the SQL or increase timeout_ms (CLI: --timeout-ms). Set timeout_ms to 0 only when an unbounded query is intentional";
35
36/// Per-column semantics for values that a correct-looking predicate silently misreads.
37///
38/// `aise db query --help` sends a SQL writer to `aise db schema`, which otherwise reports storage
39/// types only. Every entry below yields a wrong answer with no error, which is worse than a
40/// failure, so the disclosure belongs beside the column being read rather than in prose the writer
41/// passed earlier. Entries are `(table, column, note)`; a test checks each against the live schema
42/// so a rename cannot leave a trap silently undocumented. Lookup is a linear scan of this
43/// fixed-size table, so schema inspection stays `O(columns)`.
44pub(crate) const SCHEMA_COLUMN_NOTES: &[(&str, &str, &str)] = &[
45    (
46        "messages",
47        "tool_name",
48        "Provider spelling, not a cross-provider identifier: Claude stores an MCP tool namespaced (mcp__codebase-memory-mcp__search_graph) where Codex stores the leaf name (search_graph). Matching one spelling drops the other provider silently. `aise messages search --field tool-name` (MCP: search_messages with tool_name_contains) matches both.",
49    ),
50    (
51        "messages",
52        "tool_call_id",
53        "Not unique, in either direction: the same id appears on more than one row of a session, and a resumed session replays the earlier calls under its new session_id keeping their original ids. One row is therefore not one call; use count(distinct tool_call_id).",
54    ),
55    (
56        "messages",
57        "kind",
58        "Stored with underscores: conversation, compaction, tool_call, tool_result, harness_notice, unknown. MCP's kinds and this SQL surface use those spellings; the CLI hyphenates three of them, as `--kind tool-call`, `tool-result`, and `harness-notice`. No hyphenated spelling appears in storage, so a hyphen in SQL matches no row and reports no error.",
59    ),
60    (
61        "sessions",
62        "raw_metadata_json",
63        "Provider-shaped, with no key common to all: Codex records $.model where Claude records none, so grouping on json_extract(raw_metadata_json, '$.model') reports a Claude session as having no model rather than as unmeasured. A Claude session can also switch model partway through, so no session-level value would be correct for it.",
64    ),
65];
66
67/// What each queryable table holds, one row per table, in the order a reader meets them.
68///
69/// Names the row, not the table, because "the messages table" tells a reader nothing about what
70/// counting its rows would mean. A test checks each name against the live schema.
71const SCHEMA_TABLE_ROWS: &[(&str, &str)] = &[
72    ("sessions", "one indexed session"),
73    (
74        "messages",
75        "one conversation turn, tool call, tool result, compaction, or harness notice",
76    ),
77    (
78        "file_edits",
79        "one file-editing tool call, with its path, tool, and resulting content",
80    ),
81    ("transcripts", "one session's full text, needing no join"),
82];
83
84/// The indexed command that answers each question a reader would otherwise write SQL for.
85///
86/// These lead the long help because reaching for SQL is usually the first mistake, not the
87/// predicate: each of these returns ranking, context, and cross-provider matching that a
88/// hand-written predicate over one column does not.
89const INDEXED_COMMAND_ALTERNATIVES: &[(&str, &str)] = &[
90    (
91        "aise messages search",
92        "message text, tool names, and tool arguments, with surrounding turns",
93    ),
94    (
95        "aise files search",
96        "files an edit tool wrote, by path or name, with edit and session counts",
97    ),
98    ("aise search", "sessions by keyword, ranked by relevance"),
99    ("aise list", "sessions by recency, provider, or path"),
100    ("aise stats", "message counts by role"),
101    (
102        // The fts5vocab views this reads are internal, so this line is the whole route to term
103        // frequency for a reader who arrived here to write SQL for it.
104        "aise vocab",
105        "how often a term appears and in how many messages (--prefix looks one up)",
106    ),
107];
108
109/// Long help for `aise db query`, built from the same constants the schema surface reports.
110///
111/// A new reader runs `--help` before an index exists, so this reads no database: the table layout
112/// comes from the crate's migrations rather than from user data, and tests check both the table
113/// names and the notes against a live schema. Restating the notes here rather than referring to
114/// `aise db schema` is deliberate: the reader writing the predicate is holding this text and not
115/// that output, and the string is shared, so the two cannot disagree.
116fn db_query_long_help() -> String {
117    let width = INDEXED_COMMAND_ALTERNATIVES
118        .iter()
119        .map(|(command, _)| command.len())
120        .max()
121        .unwrap_or_default();
122    let mut help = String::from(
123        "Run one read-only SQL statement over the local AI session-history index.\n\nMost questions do not need SQL. These read the same index and return ranking, context, and cross-provider matching that a predicate over one column does not:\n\n",
124    );
125    for (command, answers) in INDEXED_COMMAND_ALTERNATIVES {
126        help.push_str(&format!("  {command:width$}  {answers}\n"));
127    }
128    help.push_str("\nTables, one row per:\n\n");
129    let width = SCHEMA_TABLE_ROWS
130        .iter()
131        .map(|(table, _)| table.len())
132        .max()
133        .unwrap_or_default();
134    for (table, row) in SCHEMA_TABLE_ROWS {
135        help.push_str(&format!("  {table:width$}  {row}\n"));
136    }
137    help.push_str(
138        "\nRun `aise db schema` for every table and `aise db schema --table NAME` for one table's columns, types, and notes.\n\nThese values a correct-looking predicate misreads, returning a wrong answer and no error:\n",
139    );
140    for (table, column, note) in SCHEMA_COLUMN_NOTES {
141        help.push_str(&format!("\n  {table}.{column}\n      {note}\n"));
142    }
143    help
144}
145
146#[derive(Debug, Subcommand)]
147pub enum DbCmd {
148    /// Print the AI session-history SQLite schema, or columns for one table.
149    ///
150    /// Use `aise db query` to read these tables, or `aise messages search` for indexed search instead of SQL.
151    Schema(DbSchemaArgs),
152    /// Run one read-only SQL query against the AI session-history index.
153    #[command(long_about = db_query_long_help())]
154    Query(DbQueryArgs),
155}
156
157#[derive(Debug, Args, Clone)]
158pub struct DbSchemaArgs {
159    /// Show columns for one table or virtual table, using SQLite table_xinfo. The `note` column
160    /// carries the reading traps: values a correct-looking predicate misreads without erroring.
161    /// Omit to list every table without their columns.
162    #[arg(long)]
163    pub table: Option<String>,
164    /// Include SQLite/FTS shadow tables and internal indexes. Omit for the declared tables only.
165    #[arg(long)]
166    pub include_internal: bool,
167    /// Output format.
168    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
169    pub format: OutputFormat,
170}
171
172#[derive(Debug, Args, Clone)]
173pub struct DbQueryArgs {
174    /// One read-only SQL statement. Use `aise db schema` first to inspect tables and
175    /// columns. For indexed content or regex search, prefer `aise messages search`.
176    /// Use --limit 0 only when you really want all rows.
177    pub sql: String,
178    /// Maximum rows to return. Omit to use `[db].query_limit` from config. 0 = unlimited.
179    #[arg(long)]
180    pub limit: Option<usize>,
181    /// Skip this many rows after the SQL statement runs. Prefer SQL LIMIT/OFFSET for expensive
182    /// queries; this is a CLI pagination convenience.
183    #[arg(long, default_value_t = 0)]
184    pub offset: usize,
185    /// Interrupt the query after this many milliseconds. Omit to use the native
186    /// `[db].query_timeout_ms` default, which is 0 (no timeout). MCP resolves its separate
187    /// `mcp.query_timeout_ms` before reaching this typed query.
188    #[arg(long)]
189    pub timeout_ms: Option<u64>,
190    /// Output format.
191    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
192    pub format: OutputFormat,
193}
194
195#[derive(Debug, Clone, PartialEq)]
196pub struct QueryResult {
197    pub columns: Vec<String>,
198    pub rows: Vec<BTreeMap<String, Value>>,
199    pub truncated: bool,
200}
201
202#[derive(Debug, Clone, PartialEq)]
203pub struct QueryResultPayload {
204    pub value: Value,
205}
206
207#[derive(Debug, Clone)]
208pub struct ResolvedDbQueryArgs {
209    pub sql: String,
210    pub limit: usize,
211    pub offset: usize,
212    pub timeout_ms: u64,
213    pub format: OutputFormat,
214}
215
216impl DbQueryArgs {
217    pub fn resolve(&self, defaults: &crate::config::DbConfig) -> ResolvedDbQueryArgs {
218        ResolvedDbQueryArgs {
219            sql: self.sql.clone(),
220            limit: self.limit.unwrap_or(defaults.query_limit),
221            offset: self.offset,
222            timeout_ms: self.timeout_ms.unwrap_or(defaults.query_timeout_ms),
223            format: self.format,
224        }
225    }
226}
227
228pub fn run(
229    path: &Path,
230    busy_timeout_ms: u64,
231    defaults: &crate::config::DbConfig,
232    cmd: DbCmd,
233) -> Result<()> {
234    match cmd {
235        DbCmd::Schema(args) => {
236            let result = schema_path(path, busy_timeout_ms, &args)?;
237            let stdout = io::stdout();
238            let mut out = stdout.lock();
239            render_query_result(&result, args.format, &mut out)?;
240            out.flush()?;
241        }
242        DbCmd::Query(args) => {
243            let resolved = args.resolve(defaults);
244            let result =
245                query_path(path, busy_timeout_ms, &resolved).map_err(format_cli_query_error)?;
246            let stdout = io::stdout();
247            let mut out = stdout.lock();
248            render_query_result(&result, resolved.format, &mut out)?;
249            out.flush()?;
250        }
251    }
252    Ok(())
253}
254
255pub fn format_cli_query_error(err: anyhow::Error) -> anyhow::Error {
256    anyhow::anyhow!(format_query_error(
257        err,
258        "aise db query",
259        "run `aise db schema` to list tables, then `aise db schema --table NAME` to inspect columns",
260    ))
261}
262
263pub fn format_query_error(err: anyhow::Error, caller: &str, schema_help: &str) -> String {
264    let detail = err.to_string();
265    let chain = format!("{err:#}");
266    if chain.contains("Authorization denied") || chain.contains("not authorized") {
267        format!(
268            "{caller} rejected this SQL because it is not read-only or uses a blocked SQLite operation. Use exactly one SELECT-style statement over the {SESSION_INDEX_NOUN}, or {schema_help}. Details: {detail}"
269        )
270    } else if detail.contains("provide exactly one SQL statement") {
271        format!(
272            "{caller} accepts exactly one SQL statement. Remove extra semicolon-separated statements, or run one query per call."
273        )
274    } else if detail.contains("query must return rows") {
275        format!(
276            "{caller} only returns row-producing read-only queries. Use SELECT, WITH ... SELECT, or {schema_help}."
277        )
278    } else if detail.contains("no table or view named") {
279        format!("{detail}. {schema_help}, then retry with one listed table or view name.")
280    } else {
281        format!("{caller} failed: {chain}")
282    }
283}
284
285pub fn schema_path(path: &Path, busy_timeout_ms: u64, args: &DbSchemaArgs) -> Result<QueryResult> {
286    let conn = open_read_only(path, busy_timeout_ms)?;
287    schema_connection(&conn, args)
288}
289
290pub(crate) fn schema_path_cancellable(
291    path: &Path,
292    busy_timeout_ms: u64,
293    args: &DbSchemaArgs,
294    cancellation: &crate::db::QueryCancellation,
295) -> Result<QueryResult> {
296    let conn = open_read_only(path, busy_timeout_ms)?;
297    cancellation.register(&conn);
298    crate::db::with_sqlite_query_control(
299        &conn,
300        None,
301        Some(cancellation.flag_arc()),
302        "schema inspection",
303        "retry the request after indexing or database contention subsides",
304        || schema_connection(&conn, args),
305    )
306}
307
308pub fn schema_summary_path(
309    path: &Path,
310    busy_timeout_ms: u64,
311    max_tables: usize,
312    max_columns: usize,
313) -> Result<String> {
314    let conn = open_read_only(path, busy_timeout_ms)?;
315    schema_summary_connection(&conn, max_tables, max_columns)
316}
317
318pub fn query_path(
319    path: &Path,
320    busy_timeout_ms: u64,
321    args: &ResolvedDbQueryArgs,
322) -> Result<QueryResult> {
323    validate_sql(&args.sql)?;
324    let conn = open_read_only(path, busy_timeout_ms)?;
325    query_connection(&conn, args)
326}
327
328pub(crate) fn query_path_cancellable(
329    path: &Path,
330    busy_timeout_ms: u64,
331    args: &ResolvedDbQueryArgs,
332    cancellation: &crate::db::QueryCancellation,
333) -> Result<QueryResult> {
334    validate_sql(&args.sql)?;
335    let conn = open_read_only(path, busy_timeout_ms)?;
336    cancellation.register(&conn);
337    query_connection_control(&conn, args, Some(cancellation.flag_arc()))
338}
339
340fn open_read_only(path: &Path, busy_timeout_ms: u64) -> Result<Connection> {
341    let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI;
342    let conn = Connection::open_with_flags(path, flags)
343        .with_context(|| format!("failed to open {} read-only", path.display()))?;
344    conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
345    Ok(conn)
346}
347
348fn schema_connection(conn: &Connection, args: &DbSchemaArgs) -> Result<QueryResult> {
349    with_read_only_authorizer(conn, || {
350        if let Some(table) = args.table.as_deref() {
351            table_columns(conn, table)
352        } else {
353            schema_objects(conn, args.include_internal)
354        }
355    })
356}
357
358fn schema_summary_connection(
359    conn: &Connection,
360    max_tables: usize,
361    max_columns: usize,
362) -> Result<String> {
363    with_read_only_authorizer(conn, || {
364        let schema = load_schema_objects(conn, false)?;
365        let mut parts = Vec::new();
366        for name in prioritized_schema_table_names(&schema)
367            .into_iter()
368            .take(max_tables)
369        {
370            let columns = table_column_names(conn, &name, max_columns)?;
371            let suffix = if columns.truncated { ", ..." } else { "" };
372            parts.push(format!("{name}({}{suffix})", columns.names.join(", ")));
373        }
374        if parts.is_empty() {
375            Ok(
376                "No queryable tables found; call query_session_index with no sql to inspect schema objects."
377                    .to_string(),
378            )
379        } else {
380            Ok(parts.join("; "))
381        }
382    })
383}
384
385fn with_read_only_authorizer<T>(conn: &Connection, f: impl FnOnce() -> Result<T>) -> Result<T> {
386    conn.execute_batch("pragma query_only = on")?;
387    // Fail closed. rusqlite 0.40 reports whether the authorizer installed, and running the
388    // caller's SQL after a failed install would drop the read-only restriction this function
389    // exists to impose.
390    conn.authorizer(Some(read_only_authorizer))
391        .context("could not install the read-only SQL authorizer")?;
392    let result = f();
393    let cleared = conn
394        .authorizer(None::<fn(rusqlite::hooks::AuthContext<'_>) -> Authorization>)
395        .context("could not clear the read-only SQL authorizer");
396    // The caller asked about their query, so its own error wins. A failed clear leaves the
397    // authorizer installed, which restricts later statements rather than loosening them.
398    result.and_then(|value| cleared.map(|()| value))
399}
400
401struct ColumnNames {
402    names: Vec<String>,
403    truncated: bool,
404}
405
406fn table_column_names(conn: &Connection, table: &str, max_columns: usize) -> Result<ColumnNames> {
407    let columns = table_columns(conn, table)?;
408    let mut names = columns
409        .rows
410        .iter()
411        .filter_map(|row| row.get("name").map(value_to_cell))
412        .collect::<Vec<_>>();
413    let truncated = max_columns > 0 && names.len() > max_columns;
414    if truncated {
415        names.truncate(max_columns);
416    }
417    Ok(ColumnNames { names, truncated })
418}
419
420fn query_connection(conn: &Connection, args: &ResolvedDbQueryArgs) -> Result<QueryResult> {
421    query_connection_control(conn, args, None)
422}
423
424fn query_connection_control(
425    conn: &Connection,
426    args: &ResolvedDbQueryArgs,
427    cancellation: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
428) -> Result<QueryResult> {
429    with_read_only_authorizer(conn, || {
430        crate::db::with_sqlite_query_control(
431            conn,
432            NonZeroU64::new(args.timeout_ms),
433            cancellation,
434            "query",
435            QUERY_TIMEOUT_RECOVERY,
436            || collect_query_rows(conn, args),
437        )
438    })
439}
440
441const PRIMARY_SCHEMA_TABLES: &[&str] = &["sessions", "messages", "file_edits", "transcripts"];
442
443#[derive(Debug, Clone, Eq, PartialEq)]
444struct SchemaObject {
445    object_type: String,
446    name: String,
447    table_name: String,
448    sql: String,
449}
450
451impl SchemaObject {
452    fn to_query_row(&self) -> BTreeMap<String, Value> {
453        BTreeMap::from([
454            ("type".to_string(), Value::String(self.object_type.clone())),
455            ("name".to_string(), Value::String(self.name.clone())),
456            (
457                "table_name".to_string(),
458                Value::String(self.table_name.clone()),
459            ),
460            ("sql".to_string(), Value::String(self.sql.clone())),
461        ])
462    }
463}
464
465fn prioritized_schema_table_names(schema: &[SchemaObject]) -> Vec<String> {
466    let mut names = Vec::new();
467    for priority_name in PRIMARY_SCHEMA_TABLES {
468        if schema
469            .iter()
470            .any(|object| object.object_type == "table" && object.name == *priority_name)
471        {
472            names.push((*priority_name).to_string());
473        }
474    }
475    names.extend(
476        schema
477            .iter()
478            .filter(|object| {
479                object.object_type == "table"
480                    && !PRIMARY_SCHEMA_TABLES.contains(&object.name.as_str())
481            })
482            .map(|object| object.name.clone()),
483    );
484    names
485}
486
487fn schema_objects(conn: &Connection, include_internal: bool) -> Result<QueryResult> {
488    let mut objects = load_schema_objects(conn, include_internal)?;
489    if !include_internal {
490        objects.sort_by(compare_schema_objects_for_users);
491    }
492    let rows = objects
493        .into_iter()
494        .map(|object| object.to_query_row())
495        .collect();
496    Ok(QueryResult {
497        columns: vec![
498            "type".to_string(),
499            "name".to_string(),
500            "table_name".to_string(),
501            "sql".to_string(),
502        ],
503        rows,
504        truncated: false,
505    })
506}
507
508fn load_schema_objects(conn: &Connection, include_internal: bool) -> Result<Vec<SchemaObject>> {
509    let mut stmt = conn.prepare(
510        "select type, name, tbl_name as table_name, sql
511         from sqlite_schema
512         where sql is not null
513           and (?1 or (
514             type in ('table', 'view')
515             and
516             name not like 'sqlite_%'
517             and not exists (
518               select 1
519               from sqlite_schema as fts
520               where fts.type = 'table'
521                 and lower(ltrim(fts.sql)) like 'create virtual table%using fts5%'
522                 and sqlite_schema.name in (
523                   fts.name || '_content',
524                   fts.name || '_data',
525                   fts.name || '_idx',
526                   fts.name || '_docsize',
527                   fts.name || '_config'
528                 )
529             )
530             -- A full-text index and the fts5vocab views over it are machinery behind the
531             -- documented tables, so they are internal whatever they are named. Reading the
532             -- declaration rather than the name keeps `messages_vocab` and
533             -- `messages_trigram_terms` on the same side of the line.
534             and lower(ltrim(sql)) not like 'create virtual table%using fts5%'
535             and name not glob '*_fts_content'
536             and name not glob '*_fts_data'
537             and name not glob '*_fts_idx'
538             and name not glob '*_fts_docsize'
539             and name not glob '*_fts_config'
540             and name not glob 'trigram_*'
541             and name not in ('files_seen', 'index_metadata')
542           ))
543         order by
544           case type when 'table' then 0 when 'view' then 1 when 'index' then 2 when 'trigger' then 3 else 4 end,
545           name",
546    )?;
547    let mut rows = Vec::new();
548    let mapped = stmt.query_map([include_internal], |row| {
549        Ok(SchemaObject {
550            object_type: row.get(0)?,
551            name: row.get(1)?,
552            table_name: row.get(2)?,
553            sql: row.get(3)?,
554        })
555    })?;
556    for row in mapped {
557        rows.push(row?);
558    }
559    Ok(rows)
560}
561
562fn compare_schema_objects_for_users(left: &SchemaObject, right: &SchemaObject) -> Ordering {
563    schema_object_priority(left)
564        .cmp(&schema_object_priority(right))
565        .then_with(|| left.name.cmp(&right.name))
566}
567
568fn schema_object_priority(object: &SchemaObject) -> usize {
569    PRIMARY_SCHEMA_TABLES
570        .iter()
571        .position(|name| object.object_type == "table" && object.name == *name)
572        .unwrap_or_else(|| match object.object_type.as_str() {
573            "table" => PRIMARY_SCHEMA_TABLES.len(),
574            "view" => PRIMARY_SCHEMA_TABLES.len() + 1,
575            _ => PRIMARY_SCHEMA_TABLES.len() + 2,
576        })
577}
578
579fn table_columns(conn: &Connection, table: &str) -> Result<QueryResult> {
580    let exists: bool = conn.query_row(
581        "select exists(
582            select 1 from sqlite_schema
583            where name = ?1 and type in ('table', 'view')
584        )",
585        [table],
586        |row| row.get(0),
587    )?;
588    if !exists {
589        bail!("no table or view named {table:?}; inspect schema objects to list valid names");
590    }
591
592    let mut stmt = conn.prepare(
593        "select name, type, \"notnull\", dflt_value, pk, hidden
594         from pragma_table_xinfo(?1)
595         order by cid",
596    )?;
597    let mapped = stmt.query_map([table], |row| {
598        let mut out = BTreeMap::new();
599        let name = row.get::<_, String>(0)?;
600        out.insert(
601            "note".to_string(),
602            schema_column_note(table, &name).map_or(Value::Null, |note| Value::String(note.into())),
603        );
604        out.insert("name".to_string(), Value::String(name));
605        out.insert("type".to_string(), Value::String(row.get::<_, String>(1)?));
606        out.insert(
607            "not_null".to_string(),
608            Value::Bool(row.get::<_, i64>(2)? != 0),
609        );
610        out.insert("default".to_string(), value_ref_to_json(row.get_ref(3)?));
611        out.insert(
612            "primary_key".to_string(),
613            Number::from(row.get::<_, i64>(4)?).into(),
614        );
615        out.insert(
616            "hidden".to_string(),
617            Number::from(row.get::<_, i64>(5)?).into(),
618        );
619        Ok(out)
620    })?;
621    let mut rows = Vec::new();
622    for row in mapped {
623        rows.push(row?);
624    }
625    Ok(QueryResult {
626        columns: vec![
627            "name".to_string(),
628            "type".to_string(),
629            "not_null".to_string(),
630            "default".to_string(),
631            "primary_key".to_string(),
632            "hidden".to_string(),
633            "note".to_string(),
634        ],
635        rows,
636        truncated: false,
637    })
638}
639
640/// The [`SCHEMA_COLUMN_NOTES`] entry for one column, or `None` when reading it holds no trap.
641fn schema_column_note(table: &str, column: &str) -> Option<&'static str> {
642    SCHEMA_COLUMN_NOTES
643        .iter()
644        .find(|(note_table, note_column, _)| *note_table == table && *note_column == column)
645        .map(|(_, _, note)| *note)
646}
647
648fn collect_query_rows(conn: &Connection, args: &ResolvedDbQueryArgs) -> Result<QueryResult> {
649    let mut stmt = conn.prepare(&args.sql)?;
650    let column_count = stmt.column_count();
651    if column_count == 0 {
652        bail!("query must return rows; writes and maintenance commands are not supported");
653    }
654    let columns = unique_column_names(stmt.column_names());
655    let mut query = stmt.query([])?;
656    let mut skipped = 0usize;
657    let mut rows = Vec::new();
658    let mut truncated = false;
659    while let Some(row) = query.next()? {
660        if skipped < args.offset {
661            skipped += 1;
662            continue;
663        }
664        if args.limit > 0 && rows.len() >= args.limit {
665            truncated = true;
666            break;
667        }
668        let mut out = BTreeMap::new();
669        for (idx, name) in columns.iter().enumerate().take(column_count) {
670            out.insert(name.clone(), value_ref_to_json(row.get_ref(idx)?));
671        }
672        rows.push(out);
673    }
674
675    Ok(QueryResult {
676        columns,
677        rows,
678        truncated,
679    })
680}
681
682fn read_only_authorizer(ctx: rusqlite::hooks::AuthContext<'_>) -> Authorization {
683    match ctx.action {
684        AuthAction::Select | AuthAction::Read { .. } => Authorization::Allow,
685        AuthAction::Function { function_name } if allowed_read_only_function(function_name) => {
686            Authorization::Allow
687        }
688        AuthAction::Pragma {
689            pragma_name,
690            pragma_value,
691        } if allowed_read_only_pragma(pragma_name, pragma_value) => Authorization::Allow,
692        _ => Authorization::Deny,
693    }
694}
695
696fn allowed_read_only_function(name: &str) -> bool {
697    !name.eq_ignore_ascii_case("load_extension")
698}
699
700fn allowed_read_only_pragma(name: &str, value: Option<&str>) -> bool {
701    let name = name.to_ascii_lowercase();
702    matches!(
703        (name.as_str(), value),
704        ("table_info", Some(_))
705            | ("table_xinfo", Some(_))
706            | ("index_info", Some(_))
707            | ("index_xinfo", Some(_))
708            | ("database_list", None)
709            | ("user_version", None)
710            | ("application_id", None)
711            | ("data_version", None)
712    )
713}
714
715/// Every check a statement must pass before it reaches SQLite.
716///
717/// One entry point so both the CLI and the MCP path apply the same set; adding a check to one
718/// caller and not the other is how a surface quietly keeps a defect the other one fixed.
719fn validate_sql(sql: &str) -> Result<()> {
720    ensure_single_statement(sql)?;
721    ensure_vocabulary_predicate_can_match(sql)
722}
723
724/// Every column whose stored values come from a vocabulary the CLI and MCP also spell, paired with
725/// the flag those surfaces take.
726///
727/// Each entry maps a typed spelling to the stored one and yields `None` when the two agree, so a
728/// vocabulary that matches costs nothing and a vocabulary that diverges is caught the day it does.
729/// `kind` is the one that diverges today; `role` and `provider` are listed because being listed is
730/// what makes a future rename safe rather than silent. A stored vocabulary added later belongs
731/// here, and a test checks each named column against the live schema.
732const STORED_VOCABULARIES: &[StoredVocabulary] = &[
733    StoredVocabulary {
734        column: "kind",
735        flag: "--kind",
736        stored_for_typed: stored_kind_for_typed_spelling,
737    },
738    StoredVocabulary {
739        column: "role",
740        flag: "--role",
741        stored_for_typed: stored_role_for_typed_spelling,
742    },
743    StoredVocabulary {
744        column: "provider",
745        flag: "--provider",
746        stored_for_typed: stored_provider_for_typed_spelling,
747    },
748];
749
750struct StoredVocabulary {
751    /// The index column holding the stored spelling.
752    column: &'static str,
753    /// The `aise messages search` flag that takes the typed spelling of the same value.
754    flag: &'static str,
755    /// The stored spelling for a typed one, or `None` when the two agree for that value.
756    stored_for_typed: fn(&str) -> Option<&'static str>,
757}
758
759/// Reject `kind = 'harness-notice'`, which returns zero rows because the index stores
760/// `harness_notice`.
761///
762/// This is the trap that cannot be disclosed away: an empty table is indistinguishable from "no
763/// such data", so a reader who learned the spelling from `aise messages search --kind` has no
764/// signal that the vocabulary changed at the SQL boundary. Rewriting their statement would be
765/// worse, because a read-only SQL surface has to run what was written, so the statement is refused
766/// and the stored spelling is named instead.
767///
768/// A literal is only refused where it is compared against one of the [`STORED_VOCABULARIES`]
769/// columns, so searching content that happens to contain the same text stays valid. Scanning is
770/// one pass over the statement's tokens against a fixed-size table.
771fn ensure_vocabulary_predicate_can_match(sql: &str) -> Result<()> {
772    let mut comparing: Option<&StoredVocabulary> = None;
773    for (token, text) in SqlTokens::new(sql) {
774        match token {
775            SqlToken::WhitespaceOrComment => {}
776            SqlToken::Semicolon => comparing = None,
777            SqlToken::StringLiteral => {
778                let value = unquote_sql_string(text);
779                let Some(vocabulary) = comparing else {
780                    continue;
781                };
782                let Some(stored) = (vocabulary.stored_for_typed)(&value) else {
783                    continue;
784                };
785                let (column, flag) = (vocabulary.column, vocabulary.flag);
786                bail!(
787                    "{column} = '{value}' matches no row: this index stores {column} as \
788                     '{stored}'. The spelling you used is the CLI's; MCP and this SQL surface use \
789                     the stored one. Retry with '{stored}', or run \
790                     `aise messages search {flag} {value}`, which takes the spelling you wrote and \
791                     searches the index directly."
792                );
793            }
794            SqlToken::Other => {
795                let lowered = text.to_ascii_lowercase();
796                comparing = compared_vocabulary_column(&lowered)
797                    .or_else(|| comparing.filter(|_| continues_a_comparison(&lowered)));
798            }
799        }
800    }
801    Ok(())
802}
803
804/// The [`STORED_VOCABULARIES`] entry this token references, allowing for a table alias and for a
805/// comparison operator that the tokenizer ran together with the name, as in `m.kind=`.
806fn compared_vocabulary_column(token: &str) -> Option<&'static StoredVocabulary> {
807    let name = token.trim_end_matches(['=', '!', '<', '>', '(', ',']);
808    let name = name.rsplit('.').next().unwrap_or(name);
809    STORED_VOCABULARIES
810        .iter()
811        .find(|vocabulary| vocabulary.column == name)
812}
813
814/// Whether this token keeps an already-open `kind` comparison open, covering the operators and
815/// punctuation that separate `kind` from its values in `kind in ('a', 'b')`.
816fn continues_a_comparison(token: &str) -> bool {
817    matches!(
818        token.trim_matches(['(', ',']),
819        "" | "in" | "like" | "=" | "==" | "!=" | "<>"
820    )
821}
822
823/// The stored spelling for a `kind` value written the way the CLI and MCP spell it, when the two
824/// differ.
825///
826/// Derived from [`crate::models::MessageKind`] so a variant added later is covered without an edit
827/// here, and so a variant whose spellings converge stops being reported. Its two siblings below
828/// read their own enums the same way.
829pub(crate) fn stored_kind_for_typed_spelling(value: &str) -> Option<&'static str> {
830    use clap::ValueEnum;
831    crate::models::MessageKind::value_variants()
832        .iter()
833        .find_map(|kind| diverging_stored_spelling(kind, kind.as_str(), value))
834}
835
836/// The stored spelling for a `role` value written the way the CLI and MCP spell it, when the two
837/// differ. They agree today, so this reports nothing until a variant is renamed.
838fn stored_role_for_typed_spelling(value: &str) -> Option<&'static str> {
839    use clap::ValueEnum;
840    crate::models::Role::value_variants()
841        .iter()
842        .find_map(|role| diverging_stored_spelling(role, role.as_str(), value))
843}
844
845/// The stored spelling for a `provider` value written the way the CLI and MCP spell it, when the
846/// two differ. They agree today, so this reports nothing until a variant is renamed.
847fn stored_provider_for_typed_spelling(value: &str) -> Option<&'static str> {
848    use clap::ValueEnum;
849    crate::models::Provider::value_variants()
850        .iter()
851        .find_map(|provider| diverging_stored_spelling(provider, provider.as_str(), value))
852}
853
854/// `stored` when this variant's typed spelling differs from it and `value` is that typed spelling.
855fn diverging_stored_spelling<T: clap::ValueEnum>(
856    variant: &T,
857    stored: &'static str,
858    value: &str,
859) -> Option<&'static str> {
860    let typed = variant.to_possible_value()?;
861    (typed.get_name() != stored && typed.get_name().eq_ignore_ascii_case(value)).then_some(stored)
862}
863
864/// The text of a single-quoted SQL string, with the doubled quotes that escape one collapsed.
865fn unquote_sql_string(token: &str) -> String {
866    token
867        .strip_prefix('\'')
868        .map_or(token, |rest| rest.strip_suffix('\'').unwrap_or(rest))
869        .replace("''", "'")
870}
871
872fn ensure_single_statement(sql: &str) -> Result<()> {
873    if sql.trim().is_empty() {
874        bail!("SQL query cannot be empty");
875    }
876    let mut semicolon_seen = false;
877    for (token, _) in SqlTokens::new(sql) {
878        if semicolon_seen && !matches!(token, SqlToken::WhitespaceOrComment) {
879            bail!("provide exactly one SQL statement");
880        }
881        if matches!(token, SqlToken::Semicolon) {
882            semicolon_seen = true;
883        }
884    }
885    Ok(())
886}
887
888#[derive(Clone, Copy, Debug, Eq, PartialEq)]
889enum SqlToken {
890    Semicolon,
891    WhitespaceOrComment,
892    /// A single-quoted SQL string, the only token whose text is a value rather than a name.
893    StringLiteral,
894    Other,
895}
896
897struct SqlTokens<'a> {
898    input: &'a str,
899    pos: usize,
900}
901
902impl<'a> SqlTokens<'a> {
903    fn new(input: &'a str) -> Self {
904        Self { input, pos: 0 }
905    }
906}
907
908impl<'a> Iterator for SqlTokens<'a> {
909    type Item = (SqlToken, &'a str);
910
911    fn next(&mut self) -> Option<Self::Item> {
912        let bytes = self.input.as_bytes();
913        if self.pos >= bytes.len() {
914            return None;
915        }
916        let start = self.pos;
917        let kind = self.next_kind(bytes, start)?;
918        Some((kind, &self.input[start..self.pos]))
919    }
920}
921
922impl SqlTokens<'_> {
923    fn next_kind(&mut self, bytes: &[u8], start: usize) -> Option<SqlToken> {
924        match bytes[self.pos] {
925            b';' => {
926                self.pos += 1;
927                Some(SqlToken::Semicolon)
928            }
929            b'\'' => {
930                skip_quoted(bytes, &mut self.pos, b'\'');
931                Some(SqlToken::StringLiteral)
932            }
933            b'"' | b'`' => {
934                let quote = bytes[self.pos];
935                skip_quoted(bytes, &mut self.pos, quote);
936                Some(SqlToken::Other)
937            }
938            b'[' => {
939                skip_bracket_quoted(bytes, &mut self.pos);
940                Some(SqlToken::Other)
941            }
942            b'-' if bytes.get(self.pos + 1) == Some(&b'-') => {
943                self.pos += 2;
944                while self.pos < bytes.len() && !matches!(bytes[self.pos], b'\n' | b'\r') {
945                    self.pos += 1;
946                }
947                Some(SqlToken::WhitespaceOrComment)
948            }
949            b'/' if bytes.get(self.pos + 1) == Some(&b'*') => {
950                self.pos += 2;
951                while self.pos + 1 < bytes.len()
952                    && !(bytes[self.pos] == b'*' && bytes[self.pos + 1] == b'/')
953                {
954                    self.pos += 1;
955                }
956                self.pos = (self.pos + 2).min(bytes.len());
957                Some(SqlToken::WhitespaceOrComment)
958            }
959            b if b.is_ascii_whitespace() => {
960                while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
961                    self.pos += 1;
962                }
963                Some(SqlToken::WhitespaceOrComment)
964            }
965            _ => {
966                while !is_sql_token_boundary(bytes, self.pos) {
967                    self.pos += 1;
968                }
969                if self.pos == start {
970                    self.pos += 1;
971                }
972                Some(SqlToken::Other)
973            }
974        }
975    }
976}
977
978fn is_sql_token_boundary(bytes: &[u8], pos: usize) -> bool {
979    pos >= bytes.len()
980        || matches!(bytes[pos], b';' | b'\'' | b'"' | b'`' | b'[')
981        || bytes[pos].is_ascii_whitespace()
982        || (bytes[pos] == b'-' && bytes.get(pos + 1) == Some(&b'-'))
983        || (bytes[pos] == b'/' && bytes.get(pos + 1) == Some(&b'*'))
984}
985
986fn skip_quoted(bytes: &[u8], pos: &mut usize, quote: u8) {
987    *pos += 1;
988    while *pos < bytes.len() {
989        if bytes[*pos] == quote {
990            *pos += 1;
991            if bytes.get(*pos) == Some(&quote) {
992                *pos += 1;
993                continue;
994            }
995            break;
996        }
997        *pos += 1;
998    }
999}
1000
1001fn skip_bracket_quoted(bytes: &[u8], pos: &mut usize) {
1002    *pos += 1;
1003    while *pos < bytes.len() {
1004        if bytes[*pos] == b']' {
1005            *pos += 1;
1006            break;
1007        }
1008        *pos += 1;
1009    }
1010}
1011
1012fn unique_column_names(names: Vec<&str>) -> Vec<String> {
1013    let mut seen = HashMap::<String, usize>::new();
1014    names
1015        .into_iter()
1016        .enumerate()
1017        .map(|(idx, raw)| {
1018            let base = if raw.is_empty() {
1019                format!("column_{}", idx + 1)
1020            } else {
1021                raw.to_string()
1022            };
1023            let count = seen.entry(base.clone()).or_insert(0);
1024            *count += 1;
1025            if *count == 1 {
1026                base
1027            } else {
1028                format!("{base}_{count}")
1029            }
1030        })
1031        .collect()
1032}
1033
1034fn value_ref_to_json(value: ValueRef<'_>) -> Value {
1035    match value {
1036        ValueRef::Null => Value::Null,
1037        ValueRef::Integer(value) => Value::Number(Number::from(value)),
1038        ValueRef::Real(value) => Number::from_f64(value).map_or(Value::Null, Value::Number),
1039        ValueRef::Text(value) => Value::String(String::from_utf8_lossy(value).into_owned()),
1040        ValueRef::Blob(value) => Value::String(format!("<blob {} bytes>", value.len())),
1041    }
1042}
1043
1044pub fn render_query_result<W: Write>(
1045    result: &QueryResult,
1046    format: OutputFormat,
1047    out: &mut W,
1048) -> Result<()> {
1049    match format {
1050        OutputFormat::Json => writeln!(out, "{}", serde_json::to_string_pretty(&result.rows)?)?,
1051        OutputFormat::Jsonl => {
1052            for row in &result.rows {
1053                writeln!(out, "{}", serde_json::to_string(row)?)?;
1054            }
1055        }
1056        OutputFormat::Csv => {
1057            writeln!(
1058                out,
1059                "{}",
1060                result
1061                    .columns
1062                    .iter()
1063                    .map(|h| csv_escape(h))
1064                    .collect::<Vec<_>>()
1065                    .join(",")
1066            )?;
1067            for row in &result.rows {
1068                writeln!(out, "{}", csv_cells(result, row).join(","))?;
1069            }
1070        }
1071        OutputFormat::Plain => {
1072            for row in &result.rows {
1073                writeln!(out, "{}", plain_cells(result, row).join("\t"))?;
1074            }
1075        }
1076        OutputFormat::Table => render_table(result, out)?,
1077    }
1078    if result.truncated {
1079        writeln!(
1080            out,
1081            "# truncated at {} rows; rerun with --limit 0 for all rows",
1082            result.rows.len()
1083        )?;
1084    }
1085    Ok(())
1086}
1087
1088pub fn query_result_payload(
1089    result: &QueryResult,
1090    offset: usize,
1091    max_cell_chars: usize,
1092) -> QueryResultPayload {
1093    let mut cells_truncated = false;
1094    let rows: Vec<Value> = result
1095        .rows
1096        .iter()
1097        .map(|row| {
1098            let mut out = serde_json::Map::new();
1099            for column in &result.columns {
1100                let value = row
1101                    .get(column)
1102                    .cloned()
1103                    .map(|value| truncate_json_value(value, max_cell_chars, &mut cells_truncated))
1104                    .unwrap_or(Value::Null);
1105                out.insert(column.clone(), value);
1106            }
1107            Value::Object(out)
1108        })
1109        .collect();
1110    QueryResultPayload {
1111        value: json!({
1112            "columns": result.columns,
1113            "rows": rows,
1114            "next_offset": result
1115                .truncated
1116                .then(|| offset.saturating_add(result.rows.len())),
1117            "truncated_cell_char_limit": cells_truncated.then_some(max_cell_chars),
1118        }),
1119    }
1120}
1121
1122fn truncate_json_value(value: Value, max_chars: usize, truncated: &mut bool) -> Value {
1123    if max_chars == 0 {
1124        return value;
1125    }
1126    match value {
1127        Value::String(value) if value.chars().count() > max_chars => {
1128            *truncated = true;
1129            Value::String(format!(
1130                "{}... [truncated]",
1131                value.chars().take(max_chars).collect::<String>()
1132            ))
1133        }
1134        other => other,
1135    }
1136}
1137
1138fn csv_cells(result: &QueryResult, row: &BTreeMap<String, Value>) -> Vec<String> {
1139    plain_cells(result, row)
1140        .iter()
1141        .map(|cell| csv_escape(cell))
1142        .collect()
1143}
1144
1145fn plain_cells(result: &QueryResult, row: &BTreeMap<String, Value>) -> Vec<String> {
1146    result
1147        .columns
1148        .iter()
1149        .map(|column| row.get(column).map(value_to_cell).unwrap_or_default())
1150        .collect()
1151}
1152
1153fn value_to_cell(value: &Value) -> String {
1154    match value {
1155        Value::Null => String::new(),
1156        Value::String(value) => value.clone(),
1157        other => other.to_string(),
1158    }
1159}
1160
1161fn render_table<W: Write>(result: &QueryResult, out: &mut W) -> Result<()> {
1162    let mut widths: Vec<usize> = result.columns.iter().map(|h| h.chars().count()).collect();
1163    let body: Vec<Vec<String>> = result
1164        .rows
1165        .iter()
1166        .map(|row| {
1167            let cells = plain_cells(result, row);
1168            for (idx, cell) in cells.iter().enumerate() {
1169                if idx < widths.len() {
1170                    widths[idx] = widths[idx].max(cell.chars().count());
1171                }
1172            }
1173            cells
1174        })
1175        .collect();
1176    let fmt_row = |cells: &[String]| -> String {
1177        cells
1178            .iter()
1179            .enumerate()
1180            .map(|(idx, cell)| format!("{:width$}", cell, width = widths[idx]))
1181            .collect::<Vec<_>>()
1182            .join("  ")
1183            .trim_end()
1184            .to_string()
1185    };
1186    writeln!(out, "{}", fmt_row(&result.columns))?;
1187    for row in body {
1188        writeln!(out, "{}", fmt_row(&row))?;
1189    }
1190    Ok(())
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196    use crate::db::Db;
1197
1198    fn fixture() -> (tempfile::TempDir, std::path::PathBuf) {
1199        let dir = tempfile::tempdir().unwrap();
1200        let path = dir.path().join("index.db");
1201        let conn = Connection::open(&path).unwrap();
1202        conn.execute_batch(
1203            "create table demo(id integer primary key, name text, note text);
1204             create index demo_name_idx on demo(name);
1205             create virtual table demo_fts using fts5(name, note);
1206             insert into demo(name, note) values ('alpha', '=formula');
1207             insert into demo(name, note) values ('beta', 'plain');",
1208        )
1209        .unwrap();
1210        (dir, path)
1211    }
1212
1213    fn args(sql: &str) -> ResolvedDbQueryArgs {
1214        ResolvedDbQueryArgs {
1215            sql: sql.to_string(),
1216            limit: 100,
1217            offset: 0,
1218            timeout_ms: DEFAULT_TIMEOUT_MS,
1219            format: OutputFormat::Json,
1220        }
1221    }
1222
1223    fn schema_args() -> DbSchemaArgs {
1224        DbSchemaArgs {
1225            table: None,
1226            include_internal: false,
1227            format: OutputFormat::Json,
1228        }
1229    }
1230
1231    fn schema_object(name: &str) -> SchemaObject {
1232        SchemaObject {
1233            object_type: "table".to_string(),
1234            name: name.to_string(),
1235            table_name: name.to_string(),
1236            sql: format!("create table {name}(id integer)"),
1237        }
1238    }
1239
1240    #[test]
1241    fn db_query_args_resolve_config_defaults_and_explicit_zero_overrides() {
1242        let defaults = crate::config::DbConfig {
1243            query_limit: 17,
1244            query_timeout_ms: 2500,
1245        };
1246        let args = DbQueryArgs {
1247            sql: "select 1".to_string(),
1248            limit: None,
1249            offset: 2,
1250            timeout_ms: None,
1251            format: OutputFormat::Json,
1252        };
1253        let resolved = args.resolve(&defaults);
1254        assert_eq!(resolved.limit, 17);
1255        assert_eq!(resolved.timeout_ms, 2500);
1256        assert_eq!(resolved.offset, 2);
1257
1258        let args = DbQueryArgs {
1259            sql: "select 1".to_string(),
1260            limit: Some(0),
1261            offset: 0,
1262            timeout_ms: Some(0),
1263            format: OutputFormat::Json,
1264        };
1265        let resolved = args.resolve(&defaults);
1266        assert_eq!(resolved.limit, 0, "explicit 0 keeps unlimited rows");
1267        assert_eq!(resolved.timeout_ms, 0, "explicit 0 keeps no timeout");
1268    }
1269
1270    #[test]
1271    fn native_default_does_not_interrupt_valid_read_only_sql() {
1272        assert_eq!(
1273            DEFAULT_TIMEOUT_MS, 0,
1274            "native CLI/Rust raw SQL stays unlimited unless the caller configures a timeout"
1275        );
1276    }
1277
1278    #[test]
1279    fn read_only_query_returns_typed_values() {
1280        let (_dir, path) = fixture();
1281        let result =
1282            query_path(&path, 100, &args("select id, name from demo order by id")).unwrap();
1283        assert_eq!(result.columns, vec!["id", "name"]);
1284        assert_eq!(result.rows.len(), 2);
1285        assert_eq!(result.rows[0]["id"], Value::Number(Number::from(1)));
1286        assert_eq!(result.rows[0]["name"], Value::String("alpha".into()));
1287    }
1288
1289    #[test]
1290    fn query_timeout_reports_effective_bound_and_recovery_options() {
1291        let (_dir, path) = fixture();
1292        let conn = Connection::open(&path).unwrap();
1293        conn.execute_batch(
1294            "create table workload(value integer);
1295             with recursive n(value) as (values(1) union all select value + 1 from n where value < 10000)
1296             insert into workload select value from n;",
1297        )
1298        .unwrap();
1299        drop(conn);
1300        let mut query = args("select sum(a.value * b.value) from workload a cross join workload b");
1301        query.timeout_ms = 1;
1302
1303        let error = query_path(&path, 100, &query).unwrap_err().to_string();
1304
1305        assert!(error.contains("timed out after 1 ms"), "{error}");
1306        assert!(error.contains("--timeout-ms"), "{error}");
1307        assert!(error.contains("timeout_ms to 0"), "{error}");
1308        // Narrowing the SQL and raising the timeout both keep the reader scanning. A reader at a
1309        // timeout has already passed `aise db query --help`, where the indexed alternative is
1310        // named, so naming it only there leaves it undiscovered exactly when it is needed. One
1311        // recovery string serves the CLI and MCP paths, so both spellings must appear.
1312        assert!(error.contains("aise messages search"), "{error}");
1313        assert!(error.contains("search_messages"), "{error}");
1314        assert!(error.contains("tool-name"), "{error}");
1315    }
1316
1317    #[test]
1318    fn a_kind_predicate_that_can_never_match_is_rejected_with_the_stored_spelling() {
1319        let dir = tempfile::tempdir().unwrap();
1320        let path = dir.path().join("index.db");
1321        let _db = Db::open(&path).unwrap();
1322
1323        for sql in [
1324            "select * from messages where kind = 'harness-notice'",
1325            "select * from messages where kind='harness-notice'",
1326            "select * from messages m where m.kind in ('tool-call', 'conversation')",
1327        ] {
1328            let error = query_path(&path, 100, &args(sql)).unwrap_err().to_string();
1329            // Zero rows reads as "no such data", which is the one answer a reader cannot debug,
1330            // so the corrected spelling has to arrive instead of an empty table.
1331            assert!(
1332                error.contains("harness_notice") || error.contains("tool_call"),
1333                "{sql}: {error}"
1334            );
1335            assert!(error.contains("aise messages search"), "{sql}: {error}");
1336        }
1337
1338        // The hyphenated text is legitimate content, and naming the kind column in the select
1339        // list is not comparing against it, so neither of these may be rejected.
1340        for sql in [
1341            "select kind from messages where content like '%tool-call%'",
1342            "select * from messages where content = 'harness-notice'",
1343            "select * from messages where kind = 'harness_notice'",
1344        ] {
1345            query_path(&path, 100, &args(sql))
1346                .unwrap_or_else(|error| panic!("rejected a valid query {sql}: {error}"));
1347        }
1348    }
1349
1350    #[test]
1351    fn schema_table_discloses_columns_a_correct_looking_predicate_misreads() {
1352        let dir = tempfile::tempdir().unwrap();
1353        let path = dir.path().join("index.db");
1354        let _db = Db::open(&path).unwrap();
1355        let mut args = schema_args();
1356        args.table = Some("messages".to_string());
1357
1358        let result = schema_path(&path, 100, &args).unwrap();
1359
1360        assert!(result.columns.contains(&"note".to_string()), "{result:?}");
1361        let note = |column: &str| {
1362            result
1363                .rows
1364                .iter()
1365                .find(|row| value_to_cell(&row["name"]) == column)
1366                .unwrap_or_else(|| panic!("no {column} column in {result:?}"))["note"]
1367                .clone()
1368        };
1369        // A biased tool_name predicate returns a per-provider answer and no error at all, so the
1370        // disclosure has to reach the writer while they are reading the column, not afterwards.
1371        let tool_name = note("tool_name");
1372        let tool_name = tool_name.as_str().unwrap_or_default();
1373        assert!(tool_name.contains("mcp__"), "{tool_name}");
1374        assert!(tool_name.contains("aise messages search"), "{tool_name}");
1375        let tool_call_id = note("tool_call_id");
1376        let tool_call_id = tool_call_id.as_str().unwrap_or_default();
1377        assert!(tool_call_id.contains("distinct"), "{tool_call_id}");
1378        // The typed surfaces teach `--kind harness-notice`, which is the one spelling that never
1379        // appears in storage, so a reader who learned the vocabulary from help gets zero rows.
1380        let kind = note("kind");
1381        let kind = kind.as_str().unwrap_or_default();
1382        assert!(kind.contains("harness_notice"), "{kind}");
1383        assert!(kind.contains("harness-notice"), "{kind}");
1384        // A note on every column would be noise that hides the few that matter.
1385        assert_eq!(note("seq"), Value::Null);
1386
1387        args.table = Some("sessions".to_string());
1388        let sessions = schema_path(&path, 100, &args).unwrap();
1389        let metadata = sessions
1390            .rows
1391            .iter()
1392            .find(|row| value_to_cell(&row["name"]) == "raw_metadata_json")
1393            .expect("raw_metadata_json column")["note"]
1394            .as_str()
1395            .unwrap_or_default()
1396            .to_string();
1397        assert!(metadata.contains("$.model"), "{metadata}");
1398    }
1399
1400    #[test]
1401    fn every_stored_vocabulary_either_matches_its_typed_spelling_or_is_refused_and_disclosed() {
1402        let dir = tempfile::tempdir().unwrap();
1403        let path = dir.path().join("index.db");
1404        let _db = Db::open(&path).unwrap();
1405        let messages = schema_path(
1406            &path,
1407            100,
1408            &DbSchemaArgs {
1409                table: Some("messages".to_string()),
1410                ..schema_args()
1411            },
1412        )
1413        .unwrap();
1414
1415        for vocabulary in STORED_VOCABULARIES {
1416            let column = vocabulary.column;
1417            let row = messages
1418                .rows
1419                .iter()
1420                .find(|row| value_to_cell(&row["name"]) == column)
1421                .unwrap_or_else(|| panic!("declared {column} is absent from messages"));
1422            let note = row["note"].as_str().unwrap_or_default();
1423
1424            for typed in typed_spellings_of(column) {
1425                let Some(stored) = (vocabulary.stored_for_typed)(&typed) else {
1426                    // The two spellings agree, so SQL written from the typed vocabulary works and
1427                    // there is nothing to disclose. This is the state every column should be in.
1428                    continue;
1429                };
1430                // They diverge, so SQL written from the typed vocabulary silently matches nothing.
1431                // Both halves of the treatment are required: the schema note has to name each
1432                // spelling, and the query has to be refused rather than answered with zero rows.
1433                assert!(
1434                    note.contains(stored),
1435                    "{column}: {stored} missing from {note}"
1436                );
1437                assert!(
1438                    note.contains(&typed),
1439                    "{column}: {typed} missing from {note}"
1440                );
1441                let sql = format!("select * from messages where {column} = '{typed}'");
1442                let error = query_path(&path, 100, &args(&sql))
1443                    .expect_err(&format!("{sql} was answered instead of refused"))
1444                    .to_string();
1445                assert!(error.contains(stored), "{error}");
1446                assert!(error.contains(vocabulary.flag), "{error}");
1447            }
1448        }
1449    }
1450
1451    /// Every spelling the CLI and MCP accept for one stored vocabulary column.
1452    fn typed_spellings_of(column: &str) -> Vec<String> {
1453        use clap::ValueEnum;
1454        fn names<T: ValueEnum>() -> Vec<String> {
1455            T::value_variants()
1456                .iter()
1457                .filter_map(|variant| Some(variant.to_possible_value()?.get_name().to_string()))
1458                .collect()
1459        }
1460        match column {
1461            "kind" => names::<crate::models::MessageKind>(),
1462            "role" => names::<crate::models::Role>(),
1463            "provider" => names::<crate::models::Provider>(),
1464            other => panic!("no typed vocabulary is declared for {other}"),
1465        }
1466    }
1467
1468    #[test]
1469    fn the_kind_note_lists_every_stored_spelling_a_predicate_can_match() {
1470        use clap::ValueEnum;
1471        let note = schema_column_note("messages", "kind").expect("kind note");
1472
1473        for kind in crate::models::MessageKind::value_variants() {
1474            // The enum is the authority for what a SQL writer can match; a variant added later
1475            // must reach this note rather than leaving one class silently unmatchable. The typed
1476            // spellings are covered by the stored-vocabulary contract, which also refuses them.
1477            let stored = kind.as_str();
1478            assert!(note.contains(stored), "{stored} is missing from: {note}");
1479        }
1480    }
1481
1482    #[test]
1483    fn every_documented_schema_column_exists_in_the_live_index() {
1484        let dir = tempfile::tempdir().unwrap();
1485        let path = dir.path().join("index.db");
1486        let _db = Db::open(&path).unwrap();
1487
1488        let listed = schema_path(&path, 100, &schema_args()).unwrap();
1489        let names = listed
1490            .rows
1491            .iter()
1492            .map(|row| value_to_cell(&row["name"]))
1493            .collect::<Vec<_>>();
1494        for (table, _) in SCHEMA_TABLE_ROWS {
1495            // `aise db query --help` describes what one row of each table means. A table renamed
1496            // or dropped by a migration would leave that description describing nothing.
1497            assert!(
1498                names.contains(&(*table).to_string()),
1499                "documented {table} is absent from the live schema: {names:?}"
1500            );
1501        }
1502
1503        for (table, column, _) in SCHEMA_COLUMN_NOTES {
1504            let mut args = schema_args();
1505            args.table = Some((*table).to_string());
1506            let result = schema_path(&path, 100, &args).unwrap();
1507            // A renamed column silently orphans its note, which puts the trap back undocumented
1508            // while the constant still reads as if it were covered.
1509            assert!(
1510                result
1511                    .rows
1512                    .iter()
1513                    .any(|row| value_to_cell(&row["name"]) == *column),
1514                "documented {table}.{column} is absent from the live schema: {result:?}"
1515            );
1516        }
1517    }
1518
1519    #[test]
1520    fn schema_lists_queryable_objects_without_shadow_tables_by_default() {
1521        let (_dir, path) = fixture();
1522        let result = schema_path(&path, 100, &schema_args()).unwrap();
1523        let names = result
1524            .rows
1525            .iter()
1526            .map(|row| value_to_cell(&row["name"]))
1527            .collect::<Vec<_>>();
1528
1529        assert!(names.contains(&"demo".to_string()));
1530        assert!(!names.contains(&"demo_fts".to_string()));
1531        assert!(!names.contains(&"demo_fts_data".to_string()));
1532        assert!(!names.contains(&"demo_name_idx".to_string()));
1533    }
1534
1535    #[test]
1536    fn schema_hides_production_named_trigram_shadow_tables_by_default() {
1537        let dir = tempfile::tempdir().unwrap();
1538        let path = dir.path().join("index.db");
1539        let conn = Connection::open(&path).unwrap();
1540        conn.execute_batch(
1541            "create table messages(id integer primary key, content text);
1542             create virtual table messages_trigram using fts5(
1543                 content,
1544                 content='messages',
1545                 content_rowid='id',
1546                 tokenize='trigram',
1547                 detail=none,
1548                 columnsize=0
1549             );
1550             create virtual table messages_trigram_terms
1551                 using fts5vocab(messages_trigram, row);",
1552        )
1553        .unwrap();
1554
1555        let result = schema_path(&path, 100, &schema_args()).unwrap();
1556        let names = result
1557            .rows
1558            .iter()
1559            .map(|row| value_to_cell(&row["name"]))
1560            .collect::<Vec<_>>();
1561
1562        assert!(names.contains(&"messages".to_string()));
1563        // The index and its vocabulary are internal alongside their shadow tables. They were once
1564        // listed only because the name globs that hid `messages_vocab` did not match these two.
1565        assert!(!names.contains(&"messages_trigram".to_string()));
1566        assert!(!names.contains(&"messages_trigram_terms".to_string()));
1567        assert!(!names.contains(&"messages_trigram_config".to_string()));
1568        assert!(!names.contains(&"messages_trigram_data".to_string()));
1569        assert!(!names.contains(&"messages_trigram_idx".to_string()));
1570    }
1571
1572    #[test]
1573    fn schema_summary_prioritizes_core_session_tables() {
1574        let names = prioritized_schema_table_names(&[
1575            schema_object("z_extra"),
1576            schema_object("messages"),
1577            schema_object("sessions"),
1578            schema_object("file_edits"),
1579            schema_object("transcripts"),
1580        ]);
1581        assert_eq!(
1582            names,
1583            vec![
1584                "sessions",
1585                "messages",
1586                "file_edits",
1587                "transcripts",
1588                "z_extra"
1589            ]
1590        );
1591    }
1592
1593    #[test]
1594    fn schema_listing_prioritizes_core_session_tables() {
1595        let dir = tempfile::tempdir().unwrap();
1596        let path = dir.path().join("index.db");
1597        let conn = Connection::open(&path).unwrap();
1598        conn.execute_batch(
1599            "create table z_extra(id integer);
1600             create table messages(id integer);
1601             create table sessions(id integer);
1602             create table file_edits(id integer);
1603             create table transcripts(id integer);",
1604        )
1605        .unwrap();
1606
1607        let result = schema_path(&path, 100, &schema_args()).unwrap();
1608        let names = result
1609            .rows
1610            .iter()
1611            .map(|row| value_to_cell(&row["name"]))
1612            .collect::<Vec<_>>();
1613        assert_eq!(
1614            &names[..5],
1615            [
1616                "sessions",
1617                "messages",
1618                "file_edits",
1619                "transcripts",
1620                "z_extra"
1621            ]
1622        );
1623    }
1624
1625    #[test]
1626    fn schema_summary_uses_actual_index_schema_columns() {
1627        let dir = tempfile::tempdir().unwrap();
1628        let path = dir.path().join("index.db");
1629        let _db = Db::open(&path).unwrap();
1630
1631        let summary = schema_summary_path(&path, 100, 4, 20).unwrap();
1632        assert!(summary.contains(
1633            "sessions(id, provider, provider_session_id, title, summary, cwd, repo_root, created_at, updated_at, last_message_at, preview_text, source_path, message_count, parse_version, raw_metadata_json, parse_warning, discovery_source, parent_session_id, agent_label)"
1634        ));
1635        assert!(summary.contains(
1636            "messages(id, session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, is_compaction, content, authorship, correlation_authority, correlation_scope, correlation_id, record_relation)"
1637        ));
1638        assert!(summary.contains(
1639            "file_edits(id, session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json)"
1640        ));
1641        assert!(summary.contains("transcripts(session_id, transcript_text)"));
1642        assert!(!summary.contains("messages_fts("));
1643        assert!(!summary.contains("files_seen("));
1644        assert!(!summary.contains("index_metadata("));
1645    }
1646
1647    #[test]
1648    fn schema_can_include_internal_shadow_tables() {
1649        let (_dir, path) = fixture();
1650        let mut args = schema_args();
1651        args.include_internal = true;
1652        let result = schema_path(&path, 100, &args).unwrap();
1653        let names = result
1654            .rows
1655            .iter()
1656            .map(|row| value_to_cell(&row["name"]))
1657            .collect::<Vec<_>>();
1658
1659        assert!(names.contains(&"demo_fts_data".to_string()));
1660        assert!(names.contains(&"demo_fts".to_string()));
1661        assert!(names.contains(&"demo_name_idx".to_string()));
1662    }
1663
1664    #[test]
1665    fn full_text_indexes_and_their_vocabularies_are_internal_whatever_they_are_named() {
1666        // The live index names its two fts5vocab views `messages_vocab` and
1667        // `messages_trigram_terms`. A name-shaped rule hid the first and listed the second, so one
1668        // term-frequency view read as part of the documented schema and the other did not exist.
1669        let dir = tempfile::tempdir().unwrap();
1670        let path = dir.path().join("index.db");
1671        let conn = Connection::open(&path).unwrap();
1672        conn.execute_batch(
1673            "create table demo(id integer primary key, name text);
1674             create virtual table demo_fts using fts5(name);
1675             create virtual table demo_vocab using fts5vocab('demo_fts', 'row');
1676             create virtual table demo_terms using fts5vocab('demo_fts', 'row');",
1677        )
1678        .unwrap();
1679        drop(conn);
1680
1681        let names = |include_internal: bool| -> Vec<String> {
1682            schema_path(
1683                &path,
1684                100,
1685                &DbSchemaArgs {
1686                    include_internal,
1687                    ..schema_args()
1688                },
1689            )
1690            .unwrap()
1691            .rows
1692            .iter()
1693            .map(|row| value_to_cell(&row["name"]))
1694            .collect()
1695        };
1696
1697        let listed = names(false);
1698        assert!(listed.contains(&"demo".to_string()), "{listed:?}");
1699        for internal in ["demo_fts", "demo_vocab", "demo_terms"] {
1700            assert!(
1701                !listed.contains(&internal.to_string()),
1702                "{internal} is a full-text index or its vocabulary, not part of the queryable \
1703                 schema: {listed:?}"
1704            );
1705        }
1706
1707        // Hidden by default, still reachable: the escape hatch is what makes hiding them honest.
1708        let internal = names(true);
1709        for name in ["demo_fts", "demo_vocab", "demo_terms"] {
1710            assert!(
1711                internal.contains(&name.to_string()),
1712                "--include-internal must still reach {name}: {internal:?}"
1713            );
1714        }
1715    }
1716
1717    #[test]
1718    fn schema_table_prints_columns_using_table_xinfo() {
1719        let (_dir, path) = fixture();
1720        let mut args = schema_args();
1721        args.table = Some("demo".to_string());
1722        let result = schema_path(&path, 100, &args).unwrap();
1723        assert_eq!(
1724            result
1725                .rows
1726                .iter()
1727                .map(|row| value_to_cell(&row["name"]))
1728                .collect::<Vec<_>>(),
1729            vec!["id", "name", "note"]
1730        );
1731        assert!(schema_path(
1732            &path,
1733            100,
1734            &DbSchemaArgs {
1735                table: Some("missing".to_string()),
1736                ..schema_args()
1737            }
1738        )
1739        .is_err());
1740    }
1741
1742    #[test]
1743    fn query_limit_truncates_without_cap() {
1744        let (_dir, path) = fixture();
1745        let mut query = args("select id from demo order by id");
1746        query.limit = 1;
1747        let result = query_path(&path, 100, &query).unwrap();
1748        assert!(result.truncated);
1749        assert_eq!(result.rows.len(), 1);
1750
1751        query.limit = 0;
1752        let result = query_path(&path, 100, &query).unwrap();
1753        assert!(!result.truncated);
1754        assert_eq!(result.rows.len(), 2);
1755    }
1756
1757    #[test]
1758    fn query_offset_paginates_after_sql_results() {
1759        let (_dir, path) = fixture();
1760        let mut query = args("select id from demo order by id");
1761        query.limit = 1;
1762        query.offset = 1;
1763        let result = query_path(&path, 100, &query).unwrap();
1764        assert_eq!(result.rows.len(), 1);
1765        assert_eq!(result.rows[0]["id"], Value::Number(Number::from(2)));
1766        assert!(!result.truncated);
1767    }
1768
1769    #[test]
1770    fn rejects_write_and_multi_statement_sql() {
1771        let (_dir, path) = fixture();
1772        assert!(query_path(&path, 100, &args("delete from demo")).is_err());
1773        assert!(query_path(&path, 100, &args("select 1; select 2")).is_err());
1774        assert!(query_path(&path, 100, &args("pragma wal_checkpoint")).is_err());
1775        assert!(query_path(&path, 100, &args("attach database '/tmp/x.db' as x")).is_err());
1776        assert!(query_path(
1777            &path,
1778            100,
1779            &args("select load_extension('/tmp/not-real-extension')")
1780        )
1781        .is_err());
1782        assert!(query_path(
1783            &path,
1784            100,
1785            &args("select * from pragma_table_xinfo('demo')")
1786        )
1787        .is_ok());
1788    }
1789
1790    #[test]
1791    fn single_statement_validation_ignores_semicolons_in_strings_and_comments() {
1792        ensure_single_statement("select ';' as semi -- ;\n").unwrap();
1793        ensure_single_statement("select 'x'; /* trailing ; comment */").unwrap();
1794        assert!(ensure_single_statement("select 1; select 2").is_err());
1795    }
1796
1797    #[test]
1798    fn dynamic_csv_uses_existing_formula_guard() {
1799        let (_dir, path) = fixture();
1800        let result = query_path(&path, 100, &args("select note from demo where id = 1")).unwrap();
1801        let mut out = Vec::new();
1802        render_query_result(&result, OutputFormat::Csv, &mut out).unwrap();
1803        assert_eq!(String::from_utf8(out).unwrap(), "note\n'=formula\n");
1804    }
1805
1806    #[test]
1807    fn duplicate_column_names_are_disambiguated_for_json() {
1808        let (_dir, path) = fixture();
1809        let result = query_path(&path, 100, &args("select id, id from demo limit 1")).unwrap();
1810        assert_eq!(result.columns, vec!["id", "id_2"]);
1811        assert!(result.rows[0].contains_key("id"));
1812        assert!(result.rows[0].contains_key("id_2"));
1813    }
1814
1815    #[test]
1816    fn mcp_payload_shape_includes_columns_and_cell_truncation() {
1817        let (_dir, path) = fixture();
1818        let result = query_path(
1819            &path,
1820            100,
1821            &args("select id, 'abcdef' as long_text from demo limit 1"),
1822        )
1823        .unwrap();
1824        let payload = query_result_payload(&result, 0, 3);
1825
1826        assert_eq!(payload.value["columns"], json!(["id", "long_text"]));
1827        assert_eq!(payload.value["next_offset"], Value::Null);
1828        assert_eq!(payload.value["truncated_cell_char_limit"], 3);
1829        assert_eq!(payload.value["rows"][0]["long_text"], "abc... [truncated]");
1830
1831        let mut paged = result.clone();
1832        paged.truncated = true;
1833        let payload = query_result_payload(&paged, 7, 0);
1834        assert_eq!(payload.value["next_offset"], 7 + paged.rows.len());
1835        assert_eq!(payload.value["truncated_cell_char_limit"], Value::Null);
1836    }
1837}