Skip to main content

inillucent_cli/command/
verbs.rs

1//! What each command in the table actually does.
2//!
3//! Invariant: **every one of these drives the shell.** Some run SQL through
4//! `Shell::collect`, some run a dot command through `Shell` and collect what it
5//! printed, and none of them touches the engine directly. That is the same
6//! constraint the shell itself carries, one level up, and it is what makes the
7//! 416-case differential probe cover this surface too.
8//!
9//! Where a verb is a thin wrapper over a dot command, it is a *deliberate* thin
10//! wrapper: `.dump` and `.import` are the reference's own, they have been
11//! compared against `sqlite3` byte for byte, and a second implementation here
12//! would be a second set of quoting rules. Where a verb produces a table
13//! instead - `tables`, `describe`, `capabilities` - it is because a caller that
14//! is a program wants rows rather than a paragraph.
15
16use inillucent_driver::{Status, Support};
17use inillucent_value::Value;
18
19use super::outcome::{columns_from, table, Column, Failed, Outcome};
20use super::{Arguments, Context};
21use crate::json::{self, Json};
22
23/// Turns one engine value into the JSON a result carries.
24///
25/// **A blob is `{"blob": "<hex>"}`, which is the grammar it goes in as**
26/// (task-2066 §4.1.15). It used to be the string `"x'00ff'"`, typed `text` in
27/// the column list while `typeof` said `blob` - so nothing distinguished it
28/// from a TEXT column that literally holds that text, and bytes went in through
29/// `--params` and could not come back. That affects Node, Go, PHP and the
30/// subprocess half of Python: four of the six bindings advertised.
31///
32/// The envelope matches `literal_of`'s input grammar exactly, so a value read
33/// out of one result can be bound into the next statement with no conversion -
34/// which is what "read back" has to mean for a wire format. `class_of` reports
35/// an object as `blob`, so the column type follows without a second rule.
36///
37/// @param value - the cell the engine produced
38pub fn value_to_json(value: &Value<'static>) -> Json {
39    match value {
40        Value::Null => Json::Null,
41        Value::Integer(number) => Json::Int(*number),
42        Value::Real(number) => Json::Real(*number),
43        Value::Text(text) => json::text(String::from_utf8_lossy(text.raw()).into_owned()),
44        Value::Blob(bytes) => {
45            let mut hex = String::with_capacity(bytes.raw().len().saturating_mul(2));
46            for byte in bytes.raw() {
47                hex.push_str(&format!("{byte:02x}"));
48            }
49            json::object(vec![("blob", json::text(hex))])
50        }
51    }
52}
53
54/// Turns one JSON value into the SQL literal that reproduces it.
55///
56/// **Scalars only.** An array or an object is not a SQL value, and accepting one
57/// by rendering it as its JSON text would bind a string where the caller meant a
58/// structure - which is wrong quietly, in the database, rather than loudly, here.
59///
60/// @param value - what the caller passed
61fn literal_of(value: &Json) -> Result<String, Failed> {
62    match value {
63        Json::Null => Ok("NULL".to_string()),
64        Json::Bool(true) => Ok("1".to_string()),
65        Json::Bool(false) => Ok("0".to_string()),
66        Json::Int(number) => Ok(number.to_string()),
67        Json::Real(number) if number.is_finite() => Ok(format!("{number:?}")),
68        Json::Real(_) => Err(Failed::misuse(
69            "a parameter cannot be NaN or infinity: SQL has no spelling for either.",
70        )),
71        // An `x'..'` string round-trips as the blob it names, which is how a
72        // blob leaves in `value_to_json` and so how it must be allowed back in.
73        Json::Text(text) if is_blob_literal(text) => Ok(text.clone()),
74        Json::Text(text) => Ok(format!("'{}'", text.replace('\'', "''"))),
75        // **An array of numbers is a vector (task-1979, section 8.2, gap 2).**
76        // It was refused, and the only working spelling of a vector parameter
77        // was a hex blob the caller had to assemble itself - so `--params` was
78        // unusable for the first write into a `VECTOR(N)` column, which is the
79        // first thing an application does with one.
80        Json::Array(values) => vector_literal(values),
81        // **A blob is `{"blob": "<hex>"}`.** JSON has no byte string, and the
82        // `x'..'` text form above only covers a value this command printed; a
83        // caller with bytes of its own had no spelling at all.
84        Json::Object(fields) => blob_literal(fields),
85    }
86}
87
88/// Returns the blob literal a JSON array of numbers names.
89///
90/// The bytes are little-endian 32-bit floats, which is what a `VECTOR(N)`
91/// column holds and what `vector_distance_cos` reads.
92///
93/// @param values - the array's elements
94fn vector_literal(values: &[Json]) -> Result<String, Failed> {
95    if values.is_empty() {
96        return Err(Failed::misuse(
97            "a parameter that is an array is a vector, so it needs at least one number.",
98        ));
99    }
100    let mut hex = String::from("x'");
101    for value in values {
102        let number = match value {
103            Json::Int(whole) => *whole as f64,
104            Json::Real(real) if real.is_finite() => *real,
105            _ => {
106                return Err(Failed::misuse(
107                    "a parameter that is an array is a vector, so every element has to be a \
108                     finite number.",
109                ))
110            }
111        };
112        for byte in (number as f32).to_le_bytes() {
113            hex.push_str(&format!("{byte:02x}"));
114        }
115    }
116    hex.push('\'');
117    Ok(hex)
118}
119
120/// Returns the blob literal a `{"blob": "<hex>"}` parameter names.
121///
122/// @param fields - the object's members
123fn blob_literal(fields: &[(String, Json)]) -> Result<String, Failed> {
124    let held = fields
125        .iter()
126        .find(|(name, _)| name == "blob")
127        .map(|(_, value)| value);
128    let Some(Json::Text(hex)) = held else {
129        return Err(Failed::misuse(
130            "a parameter has to be a string, a number, a boolean, null, an array of numbers \
131             for a vector, or {\"blob\": \"<hex>\"} for bytes.",
132        ));
133    };
134    if hex.is_empty() || hex.len() % 2 != 0 || !hex.chars().all(|digit| digit.is_ascii_hexdigit()) {
135        return Err(Failed::misuse(
136            "the value of \"blob\" has to be an even number of hexadecimal digits.",
137        ));
138    }
139    Ok(format!("x'{hex}'"))
140}
141
142/// Returns whether a string is the `x'..'` spelling of a blob.
143///
144/// @param text - the candidate
145fn is_blob_literal(text: &str) -> bool {
146    let Some(inner) = text
147        .strip_prefix("x'")
148        .and_then(|rest| rest.strip_suffix('\''))
149    else {
150        return false;
151    };
152    !inner.is_empty()
153        && inner.len() % 2 == 0
154        && inner.chars().all(|digit| digit.is_ascii_hexdigit())
155}
156
157/// Quotes an identifier the way the engine reads it back.
158///
159/// @param name - the object name
160fn quoted(name: &str) -> String {
161    format!("\"{}\"", name.replace('"', "\"\""))
162}
163
164/// Quotes a string as a SQL text literal.
165///
166/// @param text - the value
167fn quoted_text(text: &str) -> String {
168    format!("'{}'", text.replace('\'', "''"))
169}
170
171/// Binds the caller's parameters, runs the statement, and builds the outcome.
172///
173/// Parameters are bound **by position** - `?1`, `?2`, ... in the order the
174/// array gives them - through `Shell::collect_bound`. A value becomes an engine
175/// value by being selected: `SELECT <literal>` puts the engine's own literal
176/// reader in the path rather than a second one here, which is the same thing
177/// `.parameter set` does and for the same reason.
178///
179/// @param context - where to run
180/// @param command - the verb, for the outcome
181/// @param sql - the statement
182/// @param params - the values for `?1`, `?2`, ...
183/// @param limit - how many rows to hand back
184fn produce(
185    context: &mut Context,
186    command: &str,
187    sql: &str,
188    params: &[Json],
189    limit: usize,
190) -> Result<Outcome, Failed> {
191    context.refuse_if_it_writes(sql)?;
192    refuse_a_script(context, command, sql)?;
193    let mut bound = Vec::with_capacity(params.len());
194    for value in params {
195        let literal = literal_of(value)?;
196        let held = context
197            .shell()
198            .collect(&format!("SELECT {literal}"))
199            .map_err(|failure| Failed::from_shell(&failure))?
200            .1
201            .first()
202            .and_then(|row| row.first())
203            .cloned()
204            .unwrap_or(Value::Null);
205        bound.push(inillucent_tree::datum::OwnedDatum::from(&held));
206    }
207    let started = std::time::Instant::now();
208    let collected = context.shell().collect_bound(sql, &bound);
209    let elapsed = started.elapsed().as_secs_f64() * 1000.0;
210    let (names, rows) = collected.map_err(|failure| Failed::from_shell(&failure))?;
211    Ok(rows_to_outcome(
212        context, command, names, rows, limit, elapsed,
213    ))
214}
215
216/// Turns collected rows into an outcome, cut to the caller's limit.
217///
218/// @param context - for the null placeholder and the change counters
219/// @param command - the verb
220/// @param names - the column names
221/// @param rows - every row the statement produced
222/// @param limit - how many to hand back, zero meaning all of them
223/// @param elapsed - how long the statement took, in milliseconds
224fn rows_to_outcome(
225    context: &mut Context,
226    command: &str,
227    names: Vec<String>,
228    rows: Vec<Vec<Value<'static>>>,
229    limit: usize,
230    elapsed: f64,
231) -> Outcome {
232    let total = rows.len();
233    let kept = if limit == 0 { total } else { limit.min(total) };
234    let cells: Vec<Vec<Json>> = rows
235        .iter()
236        .take(kept)
237        .map(|row| row.iter().map(value_to_json).collect())
238        .collect();
239    let columns = columns_from(&names, &cells);
240    let connection = context.shell().connection();
241    let changes = connection.total_changes().unwrap_or_default();
242    let rowid = connection.last_insert_rowid().unwrap_or_default();
243    let _ = connection;
244    let mut text = table(&columns, &cells, &context.null);
245    if kept < total {
246        text.push_str(&format!("\n({kept} of {total} rows)"));
247    }
248    Outcome {
249        command: command.to_string(),
250        columns,
251        rows: cells,
252        total,
253        more: kept < total,
254        changes,
255        last_insert_rowid: rowid,
256        elapsed_ms: elapsed,
257        text,
258        extra: Vec::new(),
259    }
260}
261
262/// Returns the limit a call asked for, or the context's default.
263///
264/// Zero means every row, which is what an export wants and what a console
265/// never does.
266///
267/// @param context - for the default
268/// @param arguments - what was passed
269fn limit_of(context: &Context, arguments: &Arguments) -> Result<usize, Failed> {
270    let asked = match arguments.integer("limit") {
271        // **A negative limit used to become zero, and zero means every row.**
272        // So `limit=-1` - which is how a caller spells "no limit" in most other
273        // things, and how an off-by-one in a client's arithmetic comes out -
274        // asked a confined server for the whole table. It is a refusal now.
275        Some(asked) if asked < 0 => {
276            return Err(Failed::misuse(format!(
277                "limit={asked} is not a number of rows. Write 0 for every row, or a positive \
278                 count."
279            )))
280        }
281        Some(asked) => asked as usize,
282        None => context.limit,
283    };
284    context.cap_rows(asked)
285}
286
287/// Refuses a script where one statement was asked for.
288///
289/// `query` and `exec` both compile one statement and run it. Handed several, they used to compile
290/// the first, run it, and report success - so `inillucent exec "<twenty CREATE TABLEs>"` produced a
291/// database holding one table and printed `ok. 0 rows changed.` Nothing said the other nineteen had
292/// not run. `batch` is the verb for several statements, and it runs them in one transaction, so the
293/// refusal can name it.
294///
295/// @param context - the open shell
296/// @param command - which verb is refusing, so the message names it
297/// @param sql - the text the caller passed
298fn refuse_a_script(context: &mut Context, command: &str, sql: &str) -> Result<(), Failed> {
299    let Some(rest) = context.shell().trailing_statement(sql) else {
300        return Ok(());
301    };
302    Err(Failed::said(
303        Status::InvalidState,
304        format!(
305            "{command} runs one statement and this is several; the next one begins {rest:?}. \
306             Use `batch`, which runs them all in one transaction."
307        ),
308    ))
309}
310
311/// Returns the values to bind, from `params` or from `params-file`.
312///
313/// **A command line has a length ceiling and a parameter can be past it
314/// (task-1979, D17).** About 32 KB on Windows: the Node and PHP wrappers spawn
315/// this binary and put the JSON array in an argument, so a parameter larger
316/// than that failed outright with an operating system error rather than with
317/// anything about SQL. A file, or `-` for standard input, has no such limit,
318/// and it is also where `{"blob": "<hex>"}` becomes practical - bytes are
319/// exactly what a caller has a lot of.
320///
321/// Naming both is a refusal rather than a precedence rule, because a caller
322/// that supplied two sets of values has made a mistake and guessing which one
323/// it meant is how the wrong values get bound.
324///
325/// @param context - the surface, which says whether it is confined
326/// @param arguments - the command line as it was parsed
327fn bound_values(context: &Context, arguments: &Arguments) -> Result<Vec<Json>, Failed> {
328    let inline = arguments.values("params");
329    let Some(named) = arguments.text("params-file") else {
330        return Ok(inline);
331    };
332    if !inline.is_empty() {
333        return Err(Failed::misuse(
334            "give the values in 'params' or in 'params-file', not both.",
335        ));
336    }
337    let text = match named {
338        // **A confined surface has no standard input of its own** (task-2066
339        // §4.1.5), and over MCP reading it would make the server consume its
340        // own JSON-RPC stream. `resolve_source` already refuses `-` for the
341        // same reason and in the same words.
342        "-" if context.confined() => {
343            return Err(Failed::said(
344                Status::InvalidState,
345                "this surface is confined to a directory with --root, and '-' reads the \
346                 parameters from standard input, which such a surface does not have to itself. \
347                 Write them with 'params', or name a file inside the root.",
348            ))
349        }
350        "-" => {
351            let mut held = String::new();
352            std::io::Read::read_to_string(&mut std::io::stdin(), &mut held)
353                .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
354            held
355        }
356        // **This read any file on the machine** (task-2066 §4.1.5). It was a
357        // bare `read_to_string`, and `params-file` is a parameter of `query`
358        // and `exec`, both of which are served over MCP - so a server started
359        // `--root <root> --readonly` answered a request naming
360        // `C:/Windows/Temp/probe.json` with that file's contents. A file that
361        // is not a JSON array still leaked its opening bytes and its existence
362        // through the parse error. `confinement.rs` covered ATTACH, VACUUM
363        // INTO, backup, restore, import and export, and had no case for this.
364        path => {
365            let admitted = context.confine(path)?;
366            std::fs::read_to_string(&admitted)
367                .map_err(|error| Failed::said(Status::Io, format!("{path}: {error}")))?
368        }
369    };
370    // **A size cap, because this is `read_to_string` of a whole file**
371    // (task-2066 §4.1.6). The JSON parser below is now depth bounded, which
372    // stops a deep document overflowing the stack; this stops a large one
373    // being read into memory before the parser ever sees it. A megabyte is the
374    // same bound `mcp.rs` puts on a request line.
375    if text.len() > MAX_PARAMS_FILE_BYTES {
376        return Err(Failed::said(
377            Status::TooBig,
378            format!(
379                "'params-file' is {} bytes, past the {MAX_PARAMS_FILE_BYTES} byte limit. \
380                 Parameters are a list of values, not a data file.",
381                text.len()
382            ),
383        ));
384    }
385    let parsed = json::parse(text.trim())
386        .map_err(|why| Failed::misuse(format!("'params-file' is not JSON: {why}")))?;
387    match parsed {
388        Json::Array(values) => Ok(values),
389        _ => Err(Failed::misuse("'params-file' has to hold a JSON array.")),
390    }
391}
392
393/// The most a `params-file` may hold.
394///
395/// One mebibyte, matching `MAX_REQUEST_BYTES` in `mcp.rs`: a list of bound
396/// values is small, and a file larger than this is a mistake rather than a
397/// parameter list.
398const MAX_PARAMS_FILE_BYTES: usize = 1024 * 1024;
399
400/// `query`: runs a statement that returns rows.
401pub fn query(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
402    let sql = arguments.required_text("sql")?.to_string();
403    let params = bound_values(context, arguments)?;
404    let limit = limit_of(context, arguments)?;
405    produce(context, "query", &sql, &params, limit)
406}
407
408/// `exec`: runs one statement for its effect.
409pub fn exec(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
410    let sql = arguments.required_text("sql")?.to_string();
411    let params = bound_values(context, arguments)?;
412    let before = context
413        .shell()
414        .connection()
415        .total_changes()
416        .map_err(|error| Failed::from_engine(&error))?;
417    let mut produced = produce(context, "exec", &sql, &params, 0)?;
418    let after = context
419        .shell()
420        .connection()
421        .total_changes()
422        .map_err(|error| Failed::from_engine(&error))?;
423    produced.changes = after - before;
424    produced.text = match produced.rows.is_empty() {
425        true => format!(
426            "ok. {} row{} changed.",
427            produced.changes,
428            if produced.changes == 1 { "" } else { "s" }
429        ),
430        // `RETURNING` makes a write produce rows, and a caller that asked for
431        // them should be shown them rather than a count they did not ask about.
432        false => produced.text.clone(),
433    };
434    Ok(produced)
435}
436
437/// `batch`: runs several statements as one transaction.
438///
439/// **It is a transaction as of task-1932, and until then it was not.** The
440/// command's own description says "either all of them take effect or none of
441/// them do, which is what you want when creating a schema or loading related
442/// rows", and the MCP tool `inillucent_batch` inherits that description - but
443/// nothing opened a transaction. `execute_batch` is a loop of `execute_any`
444/// with nothing around it, so each statement committed as it succeeded, and
445/// `inillucent batch "INSERT ...; INSERT ...; GARBAGE"` reported failure with
446/// two rows committed. That is the exact case the description names as the
447/// reason to use it.
448///
449/// A script run inside a transaction the caller already opened joins it and
450/// does not commit: closing somebody else's transaction because a command
451/// inside it finished would be a worse surprise than the one being fixed, and
452/// the outcome's `detail` says which of the two happened. An explicit `BEGIN`
453/// inside the script is left to the engine, which refuses it with "cannot
454/// start a transaction within a transaction".
455pub fn batch(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
456    let sql = arguments.required_text("sql")?.to_string();
457    context.refuse_if_it_writes(&sql)?;
458    let joined = !context
459        .shell()
460        .connection()
461        .autocommit()
462        .map_err(|error| Failed::from_engine(&error))?;
463    let before = context
464        .shell()
465        .connection()
466        .total_changes()
467        .map_err(|error| Failed::from_engine(&error))?;
468    // **`BEGIN` and `COMMIT` keep the engine's error too (task-2173).** They
469    // went through `Shell::execute` and every failure was labelled `syntax`,
470    // so a `COMMIT` refused because another process held the file came back
471    // as a syntax error with exit code 1, and a script could not tell it to
472    // retry.
473    if !joined {
474        context
475            .shell()
476            .connection()
477            .execute_batch("BEGIN")
478            .map_err(|error| Failed::from_engine(&error))?;
479    }
480    // **The engine's error is kept, so its status reaches the caller.** This
481    // went through `Shell::execute`, which returns the error's text alone, and
482    // every failure was then reported as `syntax`. A statement the engine has
483    // not built came back `syntax` with exit code 1 here and `unsupported` with
484    // exit code 3 from `exec`, so a script could not tell "not built yet" from
485    // "wrong" by running it in a batch.
486    let ran = context.shell().connection().execute_batch(&sql);
487    if let Err(error) = ran {
488        let failed = Failed::from_engine(&error);
489        if !joined {
490            // **The rollback's own failure is not reported over the
491            // statement's.** The script's error is what the caller asked
492            // about; a rollback that could not run is reported beside it
493            // rather than instead of it, because a caller who reads only
494            // "cannot rollback" learns nothing about what went wrong.
495            if let Err(second) = context.shell().execute("ROLLBACK") {
496                return Err(Failed {
497                    message: format!("{} (and the rollback failed: {second})", failed.message),
498                    ..failed
499                });
500            }
501        }
502        return Err(failed);
503    }
504    if !joined {
505        context
506            .shell()
507            .connection()
508            .execute_batch("COMMIT")
509            .map_err(|error| Failed::from_engine(&error))?;
510    }
511    let after = context
512        .shell()
513        .connection()
514        .total_changes()
515        .map_err(|error| Failed::from_engine(&error))?;
516    let changes = after - before;
517    let mut produced = Outcome::said(
518        "batch",
519        format!(
520            "ok. {changes} row{} changed.",
521            if changes == 1 { "" } else { "s" }
522        ),
523    );
524    produced.changes = changes;
525    produced.extra.push((
526        "transaction".to_string(),
527        Json::Text(
528            if joined {
529                "joined the open transaction; not committed"
530            } else {
531                "committed"
532            }
533            .to_string(),
534        ),
535    ));
536    Ok(produced)
537}
538
539/// Returns the failure the shell recorded while running a command, and clears it.
540///
541/// The message is what the shell printed, because that is the text a person
542/// reads: the line number, the statement and the caret. The status comes from
543/// the engine's error when there is one, so a statement the engine has not
544/// built is `unsupported` with exit code 3 here exactly as it is under `exec`.
545/// A failure with no engine error behind it came from the shell itself - a dot
546/// command's own complaint - and stays `syntax`.
547///
548/// @param context - the command's context, whose shell ran the input
549/// @param printed - what the shell printed while running it
550fn take_failure(context: &mut Context, printed: &str) -> Option<Failed> {
551    let shell = context.shell();
552    let failed = std::mem::replace(&mut shell.failed, false);
553    let error = shell.first_error.take();
554    if !failed {
555        return None;
556    }
557    let message = printed.trim_end().to_string();
558    Some(match error {
559        Some(error) => Failed {
560            message,
561            ..Failed::from_engine(&error)
562        },
563        None => Failed::said(Status::Syntax, message),
564    })
565}
566
567/// `run`: runs shell input, dot commands included, and returns what it printed.
568pub fn run_input(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
569    let input = arguments.required_text("input")?.to_string();
570    if context.readonly() {
571        for line in input.lines() {
572            let trimmed = line.trim();
573            if trimmed.is_empty() || trimmed.starts_with('.') {
574                continue;
575            }
576            context.refuse_if_it_writes(trimmed)?;
577        }
578    }
579    let printed = context.collect_output(&input);
580    // **A failing statement is a failure** (task-2066 section 4.2, item 27).
581    // This used to answer `Ok` with a `shell_reported_an_error` field beside
582    // the printed text, so `inillucent run "SELECT * FROM nothing;"` exited 0
583    // where `exec` exits 1, and the same refusal over MCP came back with
584    // `"isError": false` - an agent branching on the status was told the
585    // command had run. The other four verbs that drive the shell go through
586    // `dot`, which has reported this as a failure all along; `run` was the one
587    // that did not, and it is the one an agent reaches for.
588    if let Some(failed) = take_failure(context, &printed) {
589        return Err(failed);
590    }
591    Ok(Outcome::said("run", printed.trim_end()))
592}
593
594/// `create`: makes a new database file.
595pub fn create(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
596    let path = arguments.required_text("path")?.to_string();
597    let confined = context.confine(&path)?;
598    if confined.exists() {
599        return Err(Failed::said(
600            Status::InvalidState,
601            format!("\"{path}\" already exists. Open it instead of creating it."),
602        ));
603    }
604    let named = confined.to_string_lossy().into_owned();
605    context.use_database(&named)?;
606    // A database file with no objects in it is not written until something is,
607    // so the file a caller asked for has to be brought into existence by an
608    // actual write. `user_version` is the smallest one that changes no schema.
609    context
610        .shell()
611        .execute("PRAGMA user_version = 0")
612        .map_err(|message| Failed::said(Status::Io, message))?;
613    Ok(Outcome::said("create", format!("created {named}")).with("path", json::text(&named)))
614}
615
616/// Runs a statement and returns its rows as an outcome, without binding.
617///
618/// @param context - where to run
619/// @param command - the verb
620/// @param sql - the statement
621fn listing(context: &mut Context, command: &str, sql: &str) -> Result<Outcome, Failed> {
622    produce(context, command, sql, &[], 0)
623}
624
625/// `tables`: the tables and views in the database.
626pub fn tables(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
627    let mut sql = String::from(
628        "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') \
629         AND name NOT LIKE 'sqlite_%'",
630    );
631    if let Some(pattern) = arguments.text("pattern") {
632        sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
633    }
634    sql.push_str(" ORDER BY name");
635    listing(context, "tables", &sql)
636}
637
638/// `indexes`: the indexes in the database, and what each is on.
639///
640/// **Two sources, because a vector index is not written to `sqlite_master` as
641/// an index (task-1979, R19).** `CREATE INDEX v ON t USING inillucent_hnsw (c)`
642/// records its backing store as a virtual table, so this command listed nothing
643/// at all for a database whose only index was a vector one - and the answer
644/// "there are no indexes" was wrong on a file that had just been given one.
645/// `PRAGMA index_list` walks the table's own index chain, which holds both
646/// kinds, and reports `v` for the module owned ones.
647pub fn indexes(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
648    let pattern = arguments.text("pattern").map(str::to_string);
649    let mut sql =
650        String::from("SELECT name, tbl_name AS \"table\" FROM sqlite_master WHERE type = 'index'");
651    if let Some(pattern) = &pattern {
652        sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
653    }
654    context.refuse_if_it_writes(&sql)?;
655    let started = std::time::Instant::now();
656    let (names, mut rows) = context
657        .shell()
658        .collect(&sql)
659        .map_err(|failure| Failed::from_shell(&failure))?;
660    rows.extend(module_indexes(context, pattern.as_deref())?);
661    rows.sort_by(|left, right| {
662        let key = |row: &Vec<Value<'static>>| {
663            (
664                row.get(1).map(text_of_value).unwrap_or_default(),
665                row.first().map(text_of_value).unwrap_or_default(),
666            )
667        };
668        key(left).cmp(&key(right))
669    });
670    let elapsed = started.elapsed().as_secs_f64() * 1000.0;
671    Ok(rows_to_outcome(context, "indexes", names, rows, 0, elapsed))
672}
673
674/// Returns one row per vector index, in the shape the `indexes` listing uses.
675///
676/// Every table is asked for its own index chain, because that chain is the one
677/// place both kinds of index are recorded; see [`indexes`] for why
678/// `sqlite_master` is not enough.
679///
680/// @param context - the open database
681/// @param pattern - the `LIKE` pattern the caller gave, if any
682fn module_indexes(
683    context: &mut Context,
684    pattern: Option<&str>,
685) -> Result<Vec<Vec<Value<'static>>>, Failed> {
686    let tables = context
687        .shell()
688        .column("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'");
689    let mut found = Vec::new();
690    for table in tables {
691        let listed = context
692            .shell()
693            .collect(&format!("PRAGMA index_list({})", quoted_text(&table)))
694            .map_err(|failure| Failed::from_shell(&failure))?
695            .1;
696        for row in listed {
697            if row.get(3).map(text_of_value).as_deref() != Some("v") {
698                continue;
699            }
700            let Some(name) = row.get(1).map(text_of_value) else {
701                continue;
702            };
703            if let Some(pattern) = pattern {
704                if !like(&name, pattern) {
705                    continue;
706                }
707            }
708            let (Ok(named), Ok(owner)) = (
709                Value::owned_text(name.as_bytes()),
710                Value::owned_text(table.as_bytes()),
711            ) else {
712                continue;
713            };
714            found.push(vec![named, owner]);
715        }
716    }
717    Ok(found)
718}
719
720/// Returns a value's text, or the empty string for anything else.
721///
722/// @param value - the cell
723fn text_of_value(value: &Value<'static>) -> String {
724    match value {
725        Value::Text(text) => String::from_utf8_lossy(text.raw()).into_owned(),
726        _ => String::new(),
727    }
728}
729
730/// Answers SQLite's `LIKE` for the two patterns this command accepts.
731///
732/// Only `%` is honoured, which is every pattern the command's own help
733/// describes; `_` is left alone because a name holding one is commoner here
734/// than a caller meaning it as a wildcard.
735///
736/// @param name - the index's name
737/// @param pattern - what the caller asked for
738fn like(name: &str, pattern: &str) -> bool {
739    let folded = name.to_lowercase();
740    let wanted = pattern.to_lowercase();
741    let parts: Vec<&str> = wanted.split('%').collect();
742    let mut at = 0usize;
743    for (which, part) in parts.iter().enumerate() {
744        if part.is_empty() {
745            continue;
746        }
747        let Some(found) = folded.get(at..).and_then(|rest| rest.find(part)) else {
748            return false;
749        };
750        if which == 0 && !wanted.starts_with('%') && found != 0 {
751            return false;
752        }
753        at = at.saturating_add(found).saturating_add(part.len());
754    }
755    if !wanted.ends_with('%') {
756        if let Some(last) = parts.last() {
757            if !last.is_empty() && at != folded.len() {
758                return false;
759            }
760        }
761    }
762    true
763}
764
765/// `databases`: what is attached, and the file behind each.
766pub fn databases(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
767    listing(context, "databases", "PRAGMA database_list")
768}
769
770/// `schema`: the `CREATE` statements, as the shell writes them.
771pub fn schema(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
772    let mut line = String::from(".schema");
773    if arguments.flag("indent") {
774        line.push_str(" --indent");
775    }
776    if let Some(pattern) = arguments.text("pattern") {
777        line.push(' ');
778        line.push_str(pattern);
779    }
780    let printed = context.collect_output(&line);
781    context.shell().failed = false;
782    context.shell().first_error = None;
783    Ok(Outcome::said("schema", printed.trim_end()))
784}
785
786/// `describe`: everything about one table, in one call.
787///
788/// **One call on purpose.** A model that has to make four - `table_info`,
789/// `index_list`, `foreign_key_list`, then the DDL - makes three of them and
790/// answers from an incomplete picture. This is the single most useful tool on
791/// the list for an agent, and it is the one whose absence was most visible when
792/// the local model was first pointed at the server.
793///
794/// **Every column, including the generated ones.** It read `PRAGMA table_info`,
795/// which leaves out a generated column exactly as SQLite's does, so a table of
796/// 18 columns with two `GENERATED ALWAYS AS (...) STORED` among them was
797/// described as having 16, and a reader learned of the other two only from the
798/// DDL printed underneath. `table_xinfo` lists every column, and `kind` says
799/// which ones are generated and which are the hidden columns of a virtual
800/// table.
801pub fn describe(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
802    let name = arguments.required_text("table")?.to_string();
803    let info = format!(
804        "SELECT cid, name, type, \"notnull\", dflt_value, pk,          CASE hidden WHEN 1 THEN 'hidden' WHEN 2 THEN 'generated virtual'          WHEN 3 THEN 'generated stored' ELSE '' END AS kind          FROM pragma_table_xinfo({})",
805        quoted_text(&name)
806    );
807    let mut produced = produce(context, "describe", &info, &[], 0)?;
808    if produced.rows.is_empty() {
809        return Err(Failed::said(
810            Status::NotFound,
811            format!("no such table: {name}"),
812        ));
813    }
814    let ddl = context
815        .shell()
816        .scalar(&format!(
817            "SELECT sql FROM sqlite_master WHERE name = {}",
818            quoted_text(&name)
819        ))
820        .unwrap_or_default();
821    let index_rows = context.shell().column(&format!(
822        "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = {} ORDER BY name",
823        quoted_text(&name)
824    ));
825    let count = context
826        .shell()
827        .scalar(&format!("SELECT count(*) FROM {}", quoted(&name)))
828        .unwrap_or_default();
829    let indexes: Vec<Json> = index_rows.iter().map(json::text).collect();
830    let drawn = table(&produced.columns, &produced.rows, &context.null);
831    produced.text = format!(
832        "{name}: {} column{}, {count} row{}\n\n{drawn}\n\nindexes: {}\n\n{ddl}",
833        produced.rows.len(),
834        if produced.rows.len() == 1 { "" } else { "s" },
835        if count == "1" { "" } else { "s" },
836        match index_rows.is_empty() {
837            true => "none".to_string(),
838            false => index_rows.join(", "),
839        }
840    );
841    Ok(produced
842        .with("table", json::text(&name))
843        .with("ddl", json::text(ddl))
844        .with("indexes", Json::Array(indexes))
845        .with(
846            "row_count_in_table",
847            Json::Int(count.parse::<i64>().unwrap_or(-1)),
848        ))
849}
850
851/// `explain`: the query plan, drawn the way the shell draws it.
852pub fn explain(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
853    let sql = arguments.required_text("sql")?.to_string();
854    let plan = context
855        .shell()
856        .connection()
857        .explain(&sql)
858        .map_err(|error| Failed::from_engine(&error))?;
859    let rows: Vec<Vec<Json>> = plan.iter().map(|line| vec![json::text(line)]).collect();
860    let columns = vec![Column {
861        name: "plan".to_string(),
862        kind: "text".to_string(),
863    }];
864    Ok(Outcome {
865        command: "explain".to_string(),
866        text: plan.join("\n"),
867        total: rows.len(),
868        rows,
869        columns,
870        more: false,
871        changes: 0,
872        last_insert_rowid: 0,
873        elapsed_ms: 0.0,
874        extra: Vec::new(),
875    })
876}
877
878/// Runs a dot command and hands back what it printed.
879///
880/// @param context - where to run
881/// @param command - the verb, for the outcome
882/// @param line - the dot command, already assembled
883fn dot(context: &mut Context, command: &str, line: &str) -> Result<Outcome, Failed> {
884    // **Safe mode is about a dot command a caller typed, and this is not one.**
885    // `import`, `dump`, `export`, `backup` and `restore` are commands of the
886    // table in `registry.rs` with their own parameters, and each one confined
887    // its path through `Context::confine` before building this line - so the
888    // check that stops `.import` reaching outside an MCP server has already
889    // been made, by the command, against the argument the caller passed. Left
890    // on, it refused `inillucent_import` over MCP with
891    // `.import is prohibited in safe mode`, which is a refusal of the server's
892    // own verb rather than of anything the caller could have escaped through.
893    let guarded = std::mem::replace(&mut context.shell().safe, false);
894    let printed = context.collect_output(line);
895    context.shell().safe = guarded;
896    if let Some(failed) = take_failure(context, &printed) {
897        return Err(failed);
898    }
899    Ok(Outcome::said(command, printed.trim_end()))
900}
901
902/// `dump`: the database as the SQL that rebuilds it.
903pub fn dump(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
904    let mut line = String::from(".dump");
905    if arguments.flag("data_only") {
906        line.push_str(" --data-only");
907    }
908    if let Some(objects) = arguments.text("objects") {
909        line.push(' ');
910        line.push_str(objects);
911    }
912    dot(context, "dump", &line)
913}
914
915/// `import`: reads a delimited file into a table.
916pub fn import(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
917    let file = arguments.required_text("file")?.to_string();
918    let table_name = arguments.required_text("table")?.to_string();
919    let confined = context.confine(&file)?;
920    let mut line = String::from(".import");
921    match arguments.text("format").unwrap_or("csv") {
922        "csv" => line.push_str(" --csv"),
923        "ascii" => line.push_str(" --ascii"),
924        "tabs" => line.push_str(" --colsep \"\t\""),
925        other => {
926            return Err(Failed::misuse(format!(
927                "'{other}' is not a format this reads. Use csv, tabs or ascii."
928            )))
929        }
930    }
931    if let Some(skip) = arguments.integer("skip") {
932        line.push_str(&format!(" --skip {skip}"));
933    }
934    line.push_str(&format!(
935        " \"{}\" \"{table_name}\"",
936        confined.to_string_lossy()
937    ));
938    // Counted two ways, because neither alone is right. `total_changes` is what
939    // an ordinary table's insert moves and it is exact. A **virtual** table's
940    // insert does not move it at all, so a 2,661 row load into an FTS5 table
941    // reported `imported 0 rows` while every one of those rows was in fact
942    // there - a number that says the opposite of what happened. The row count
943    // of the target covers that case, and is the fallback rather than the
944    // primary because a table with a trigger on it can change more rows than it
945    // gained.
946    let before_changes = context
947        .shell()
948        .connection()
949        .total_changes()
950        .map_err(|error| Failed::from_engine(&error))?;
951    let before_rows = row_count(context, &table_name);
952    let mut produced = dot(context, "import", &line)?;
953    let after_changes = context
954        .shell()
955        .connection()
956        .total_changes()
957        .map_err(|error| Failed::from_engine(&error))?;
958    produced.changes = after_changes - before_changes;
959    if produced.changes == 0 {
960        produced.changes = row_count(context, &table_name).saturating_sub(before_rows);
961    }
962    if produced.text.is_empty() {
963        produced.text = format!("imported {} rows into {table_name}", produced.changes);
964    }
965    Ok(produced)
966}
967
968/// Returns how many rows a table holds, or zero when it holds none or is absent.
969///
970/// @param context - the open database
971/// @param table - the table to count
972fn row_count(context: &mut Context, table: &str) -> i64 {
973    let sql = format!("SELECT count(*) FROM \"{}\"", table.replace('"', "\"\""));
974    context
975        .shell()
976        .scalar(&sql)
977        .and_then(|text| text.parse::<i64>().ok())
978        .unwrap_or(0)
979}
980
981/// `export`: writes rows out in a chosen format.
982pub fn export(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
983    let sql = match (arguments.text("sql"), arguments.text("table")) {
984        (Some(_), Some(_)) => {
985            return Err(Failed::misuse(
986                "export accepts either 'sql' or 'table', not both.",
987            ))
988        }
989        (Some(sql), None) => sql.to_string(),
990        (None, Some(name)) => format!("SELECT * FROM {}", quoted(name)),
991        (None, None) => return Err(Failed::misuse("export needs either 'sql' or 'table'.")),
992    };
993    let format = arguments.text("format").unwrap_or("csv").to_string();
994    let mode = match format.as_str() {
995        "csv" | "json" | "tabs" | "markdown" | "insert" | "quote" | "line" | "html" => format,
996        other => {
997            return Err(Failed::misuse(format!(
998                "'{other}' is not an export format. Use csv, json, tabs, markdown, insert, \
999                 quote, line or html."
1000            )))
1001        }
1002    };
1003    context.refuse_if_it_writes(&sql)?;
1004    // **The redirect is taken here rather than written into the script
1005    // (task-2044).** This built `.once "<path>"` at the top of the script and
1006    // then ran the script through `collect_output`, which is two callers
1007    // claiming the shell's output stream; `say` gave it to the collecting one,
1008    // so the file was created, stayed empty, and the rows came back in the
1009    // report while `"ok": true` said the export had happened. `say` now gives
1010    // a redirect the rows, and asking for it directly is what the command
1011    // meant in the first place - it removes a path assembled into a quoted
1012    // argument, it makes a file that will not open an `io` failure instead of
1013    // a syntax one, and it is what `backup` beside this already does.
1014    //
1015    // It also settles the question safe mode was answering by accident. A
1016    // typed `.once` is still refused on a server started `--root`, which is
1017    // the reference's behaviour and is kept; this is not a typed `.once`, it
1018    // is a command whose path `confine` has already admitted, exactly as for
1019    // `backup`, `import` and `restore` - see the argument in `dot` above.
1020    let destination = match arguments.text("out") {
1021        None => None,
1022        Some(out) => Some(context.confine(out)?),
1023    };
1024    if let Some(path) = destination.as_ref() {
1025        let named = path.to_string_lossy().into_owned();
1026        context
1027            .shell()
1028            .redirect(Some(&named), true)
1029            .map_err(|message| {
1030                Failed::said(Status::Io, format!("cannot open \"{named}\": {message}"))
1031            })?;
1032    }
1033    let script = format!(".mode {mode}\n.headers on\n{sql};");
1034    // An export of nothing still writes its header, so a reader of the file
1035    // learns the columns. The shell prints no header for an empty result,
1036    // as `sqlite3` does, unless it is asked to here.
1037    context.shell().header_when_empty = true;
1038    let printed = context.collect_output(&script);
1039    context.shell().header_when_empty = false;
1040    let rows = context.shell().rows_since_redirect;
1041    // The `.once` releases itself after the statement, and this releases it
1042    // after a statement that never ran - an empty `sql`, or one the parser
1043    // refused before the shell reached it. A redirect left open on a server
1044    // that stays up would send the next command's rows into this file.
1045    if destination.is_some() {
1046        let _ = context.shell().redirect(None, false);
1047    }
1048    if let Some(failed) = take_failure(context, &printed) {
1049        return Err(failed);
1050    }
1051    let Some(path) = destination else {
1052        return Ok(Outcome::said("export", printed.trim_end()));
1053    };
1054    wrote_a_file(&path, rows)
1055}
1056
1057/// Reports an export that went to a file rather than into the answer.
1058///
1059/// **The rows are not repeated here.** They are in the file, and a caller that
1060/// asked for a file asked for them to be there; an export of a million rows
1061/// that also carried a million rows back through the report would cost a copy
1062/// of the whole table in memory and a second one in the JSON, for something
1063/// nobody reads. What the report carries instead is the three things a caller
1064/// checks: where it went, how many rows went into it, and how large it is -
1065/// the last of which is read back off the file rather than counted up, so a
1066/// short write is visible in the report that claims the write happened.
1067///
1068/// @param path - the file that was written
1069/// @param rows - how many result rows the renderer was handed
1070fn wrote_a_file(path: &std::path::Path, rows: usize) -> Result<Outcome, Failed> {
1071    let named = path.to_string_lossy().into_owned();
1072    let bytes = std::fs::metadata(path)
1073        .map(|found| found.len())
1074        .unwrap_or(0);
1075    let mut produced = Outcome::said(
1076        "export",
1077        format!(
1078            "wrote {rows} row{} ({bytes} bytes) to {named}",
1079            if rows == 1 { "" } else { "s" }
1080        ),
1081    );
1082    produced.total = rows;
1083    Ok(produced
1084        .with("wrote", json::text(&named))
1085        .with("bytes", Json::Int(bytes as i64)))
1086}
1087
1088/// `backup`: copies the database to a file.
1089pub fn backup(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1090    let file = arguments.required_text("file")?.to_string();
1091    let confined = context.confine(&file)?;
1092    let named = confined.to_string_lossy().into_owned();
1093    context
1094        .shell()
1095        .backup_to(&named)
1096        .map_err(|message| Failed::said(Status::Io, message))?;
1097    Ok(Outcome::said("backup", format!("wrote {named}")).with("wrote", json::text(&named)))
1098}
1099
1100/// `encrypt`: writes an encrypted copy of a plaintext database.
1101///
1102/// The key is the one this process was given - `--key-file` or
1103/// `INILLUCENT_KEY` - and the source is opened without it, which the
1104/// dispatcher arranges through `crate::keys::opens_source_without_key`.
1105pub fn encrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1106    let Some(key) = crate::keys::configured() else {
1107        return Err(Failed::said(
1108            Status::InvalidState,
1109            "encrypt needs a key for the copy: pass --key-file or set INILLUCENT_KEY",
1110        ));
1111    };
1112    let named = copy_target(context, arguments)?;
1113    context
1114        .shell()
1115        .export_to(&named, Some(key))
1116        .map_err(|error| Failed::from_engine(&error))?;
1117    Ok(
1118        Outcome::said("encrypt", format!("wrote {named}, encrypted"))
1119            .with("wrote", json::text(&named))
1120            .with("encryption", json::text(inillucent_driver::CIPHER_NAME)),
1121    )
1122}
1123
1124/// `decrypt`: writes a plaintext copy of an encrypted database.
1125pub fn decrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1126    if !context.shell().is_encrypted() {
1127        return Err(Failed::said(
1128            Status::InvalidState,
1129            "this database is not encrypted, so there is nothing to decrypt",
1130        ));
1131    }
1132    let named = copy_target(context, arguments)?;
1133    context
1134        .shell()
1135        .export_to(&named, None)
1136        .map_err(|error| Failed::from_engine(&error))?;
1137    Ok(
1138        Outcome::said("decrypt", format!("wrote {named}, not encrypted"))
1139            .with("wrote", json::text(&named))
1140            .with("encryption", json::text("none")),
1141    )
1142}
1143
1144/// `rekey`: changes the key of an encrypted database.
1145///
1146/// The new key comes from `--new-key-file` or `INILLUCENT_NEW_KEY`, never
1147/// from a word on the command line; see `crate::keys` for why.
1148pub fn rekey(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1149    let key = match arguments.text("new-key-file") {
1150        Some(path) => {
1151            let confined = context.confine(path)?;
1152            crate::keys::read_key_file(&confined.to_string_lossy())
1153                .map_err(|message| Failed::said(Status::InvalidState, message))?
1154        }
1155        None => match std::env::var(crate::keys::NEW_KEY_VARIABLE) {
1156            Ok(text) if !text.is_empty() => inillucent_driver::EncryptionKey::parse(&text),
1157            _ => {
1158                return Err(Failed::said(
1159                    Status::InvalidState,
1160                    "rekey needs the new key: pass --new-key-file or set INILLUCENT_NEW_KEY",
1161                ))
1162            }
1163        },
1164    };
1165    context
1166        .shell()
1167        .rekey(key)
1168        .map_err(|error| Failed::from_engine(&error))?;
1169    Ok(Outcome::said("rekey", "the key was changed".to_string()))
1170}
1171
1172/// Reads and confines the output path `encrypt` and `decrypt` write to.
1173///
1174/// @param context - the session, for its root
1175/// @param arguments - the command's arguments
1176fn copy_target(context: &Context, arguments: &Arguments) -> Result<String, Failed> {
1177    let file = arguments.required_text("file")?.to_string();
1178    let confined = context.confine(&file)?;
1179    Ok(confined.to_string_lossy().into_owned())
1180}
1181
1182/// `restore`: replaces this database's contents from a file.
1183pub fn restore(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1184    let file = arguments.required_text("file")?.to_string();
1185    let confined = context.confine(&file)?;
1186    // **A backup that is not there is a refusal, not a new empty database
1187    // (task-1969, 5.2).** `.restore` is implemented as "open the file the
1188    // caller named" - see `dot.rs`, which explains why - and opening a file
1189    // that is not there creates it. So `inillucent --db app.rdb restore
1190    // typo.rdb` exited 0, said `ok`, and left the caller with an empty
1191    // database and no message. It is the shape task-1951 shipped in the
1192    // signing gate: a check that passed having checked nothing.
1193    if !confined.is_file() {
1194        return Err(Failed::said(
1195            Status::NotFound,
1196            format!(
1197                "{}: there is no such backup file to restore from",
1198                confined.to_string_lossy()
1199            ),
1200        ));
1201    }
1202    dot(
1203        context,
1204        "restore",
1205        &format!(".restore \"{}\"", confined.to_string_lossy()),
1206    )
1207}
1208
1209/// `checkpoint`: writes the log back into the database file.
1210pub fn checkpoint(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1211    listing(context, "checkpoint", "PRAGMA wal_checkpoint")
1212}
1213
1214/// `integrity-check`: reads every page and says whether it holds together.
1215///
1216/// **The exit code and the `ok` field follow the answer** (task-2066 §4.1.4).
1217/// `PRAGMA integrity_check` reports damage as a *row of text*, the way SQLite
1218/// does, and this verb listed the rows and stopped there - so
1219/// `outcome.rs`'s unconditional `("ok", Json::Bool(true))` said a corrupt file
1220/// was fine, at exit 0. Any health check written as
1221/// `inillucent integrity-check && echo healthy` was told the wrong thing, and
1222/// this is the one command whose entire purpose is to answer whether a database
1223/// is sound. The pinned SQLite 3.53.4 exits 1 on the equivalent.
1224///
1225/// The pragma still returns rows. What changed is that the verb reads them.
1226pub fn integrity_check(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1227    let produced = listing(context, "integrity-check", "PRAGMA integrity_check")?;
1228    if let Some(damage) = first_damage(&produced) {
1229        return Err(Failed::said(Status::Corrupt, damage));
1230    }
1231    Ok(produced)
1232}
1233
1234/// Returns what an integrity report says is wrong, or `None` when it says `ok`.
1235///
1236/// SQLite's contract is one row reading `ok` for a healthy file, and one row
1237/// per problem otherwise. Anything that is not exactly `ok` is damage, so a
1238/// future check that reports something this does not recognise is read as
1239/// damage rather than as health - which is the direction a health check has to
1240/// fail in.
1241///
1242/// @param produced - what the pragma answered
1243fn first_damage(produced: &Outcome) -> Option<String> {
1244    let said: Vec<String> = produced
1245        .rows
1246        .iter()
1247        .flatten()
1248        .map(|value| match value {
1249            Json::Text(text) => text.clone(),
1250            other => format!("{other:?}"),
1251        })
1252        .collect();
1253    if said.is_empty() {
1254        return Some(
1255            "PRAGMA integrity_check returned no rows at all, so this database's soundness is \
1256             unknown rather than confirmed"
1257                .to_string(),
1258        );
1259    }
1260    if said.iter().all(|line| line.trim() == "ok") {
1261        return None;
1262    }
1263    Some(said.join("; "))
1264}
1265
1266/// `analyze`: gathers the statistics the planner reads.
1267pub fn analyze(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1268    let sql = match arguments.text("table") {
1269        Some(name) => format!("ANALYZE {}", quoted(name)),
1270        None => "ANALYZE".to_string(),
1271    };
1272    // The engine's error, so a refusal because another process holds the file
1273    // is `busy` and not `syntax` (task-2173).
1274    context
1275        .shell()
1276        .connection()
1277        .execute_batch(&sql)
1278        .map_err(|error| Failed::from_engine(&error))?;
1279    Ok(Outcome::said("analyze", "ok. sqlite_stat1 is up to date."))
1280}
1281
1282/// `stats`: what the page cache and the file are doing.
1283pub fn stats(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1284    let cache = context.shell().cache_stats();
1285    let pool = context.shell().pool_bytes();
1286    let pages = context
1287        .shell()
1288        .scalar("PRAGMA page_count")
1289        .unwrap_or_default();
1290    let size = context
1291        .shell()
1292        .scalar("PRAGMA page_size")
1293        .unwrap_or_default();
1294    let free = context
1295        .shell()
1296        .scalar("PRAGMA freelist_count")
1297        .unwrap_or_default();
1298    let text = format!(
1299        "pool bytes:      {pool}\npage size:       {size}\npage count:      {pages}\n\
1300         free pages:      {free}\ncache hits:      {}\ncache misses:    {}",
1301        cache.hits, cache.misses
1302    );
1303    Ok(Outcome::said("stats", text)
1304        .with("pool_bytes", Json::Int(pool as i64))
1305        .with("page_size", Json::Int(size.parse::<i64>().unwrap_or(0)))
1306        .with("page_count", Json::Int(pages.parse::<i64>().unwrap_or(0)))
1307        .with("free_pages", Json::Int(free.parse::<i64>().unwrap_or(0)))
1308        .with("cache_hits", Json::Int(cache.hits as i64))
1309        .with("cache_misses", Json::Int(cache.misses as i64)))
1310}
1311
1312/// Returns how many neighbours a search was asked for.
1313///
1314/// **`--k 0` and `--k -1` used to answer one row (task-1979, R10).** The count
1315/// was clamped with `.max(1)`, so a caller asking for none - which a loop over
1316/// a configured page size does - was given one, and a caller who had computed a
1317/// negative count from a mistake elsewhere was given one too. Neither is what
1318/// was asked for, and a row nobody asked for is worse than an error.
1319///
1320/// @param arguments - the command line as it was parsed
1321fn neighbours_asked_for(arguments: &Arguments) -> Result<i64, Failed> {
1322    let k = arguments.integer("k").unwrap_or(10);
1323    if k < 1 {
1324        return Err(Failed::misuse(
1325            "'k' has to be one or more: it is how many rows to return.",
1326        ));
1327    }
1328    Ok(k)
1329}
1330
1331/// `search`: full-text and hybrid retrieval, without writing the idiom.
1332///
1333/// One statement over an `inillucent_search` or FTS5 table, in the form both
1334/// modules answer: `WHERE <table> MATCH ? ORDER BY rank`. A caller that wants
1335/// something else writes it with `query`; this exists because the idiom is the
1336/// part nobody remembers.
1337pub fn search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1338    let query_text = arguments.required_text("query")?.to_string();
1339    let name = arguments.required_text("table")?.to_string();
1340    let k = neighbours_asked_for(arguments)?;
1341    // `--rerank` names the query text as the question, which turns reranking on for the search
1342    // and asks for `k` rows so the reranker's candidates are counted from the same number.
1343    let reranked = match arguments.flag("rerank") {
1344        true => format!(" AND question = {} AND k = {k}", quoted_text(&query_text)),
1345        false => String::new(),
1346    };
1347    let sql = format!(
1348        "SELECT rowid, * FROM {0} WHERE {0} MATCH {1}{reranked} ORDER BY rank LIMIT {k}",
1349        quoted(&name),
1350        quoted_text(&query_text)
1351    );
1352    let mut produced = produce(context, "search", &sql, &[], 0)?;
1353    produced.command = "search".to_string();
1354    Ok(produced.with("query", json::text(&query_text)))
1355}
1356
1357/// `vector-search`: the nearest rows to a vector, by cosine distance.
1358pub fn vector_search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1359    let name = arguments.required_text("table")?.to_string();
1360    let column = arguments.required_text("column")?.to_string();
1361    let numbers = arguments.values("vector");
1362    if numbers.is_empty() {
1363        return Err(Failed::misuse(
1364            "'vector' has to be an array of numbers, one per dimension.",
1365        ));
1366    }
1367    let mut blob = String::from("x'");
1368    for value in &numbers {
1369        let Some(number) = value.integer().map(|whole| whole as f64).or(match value {
1370            Json::Real(real) => Some(*real),
1371            _ => None,
1372        }) else {
1373            return Err(Failed::misuse(
1374                "every element of 'vector' has to be a number.",
1375            ));
1376        };
1377        for byte in (number as f32).to_bits().to_le_bytes() {
1378            blob.push_str(&format!("{byte:02x}"));
1379        }
1380    }
1381    blob.push('\'');
1382    let k = neighbours_asked_for(arguments)?;
1383    let measure = arguments.text("measure").unwrap_or("cos");
1384    let function = match measure {
1385        "cos" => "vector_distance_cos",
1386        "l2" => "vector_distance_l2",
1387        "dot" => "vector_dot",
1388        other => {
1389            return Err(Failed::misuse(format!(
1390                "'{other}' is not a measure. Use cos, l2 or dot."
1391            )))
1392        }
1393    };
1394    let shape = searched_table_shape(context, &name);
1395    // **The rowid only when the table has no key of its own.** `SELECT rowid, *`
1396    // on a table with an `INTEGER PRIMARY KEY` printed that key twice, because
1397    // the engine names the rowid after the key and `*` includes it again.
1398    let rowid = if shape.integer_key { "" } else { "rowid, " };
1399    let sql = format!(
1400        "SELECT {rowid}*, {function}({1}, {blob}) AS distance FROM {0} \
1401         WHERE {1} IS NOT NULL ORDER BY {function}({1}, {blob}) LIMIT {k}",
1402        quoted(&name),
1403        quoted(&column)
1404    );
1405    let mut produced = produce(context, "vector-search", &sql, &[], 0)?;
1406    let offset = usize::from(!shape.integer_key);
1407    let positions: Vec<usize> = shape.vectors.iter().map(|nth| nth + offset).collect();
1408    for row in &mut produced.rows {
1409        for &nth in &positions {
1410            if let Some(cell) = row.get_mut(nth) {
1411                if let Some(numbers) = vector_numbers(cell) {
1412                    *cell = numbers;
1413                }
1414            }
1415        }
1416    }
1417    if !positions.is_empty() {
1418        let names: Vec<String> = produced.columns.iter().map(|c| c.name.clone()).collect();
1419        produced.columns = columns_from(&names, &produced.rows);
1420        produced.text = table(&produced.columns, &produced.rows, &context.null);
1421    }
1422    Ok(produced)
1423}
1424
1425/// What `vector-search` needs to know about the table it searches.
1426struct SearchedTable {
1427    /// The table has a single `INTEGER PRIMARY KEY` column, which is its rowid.
1428    integer_key: bool,
1429    /// The positions, among the table's own columns, of every `VECTOR` column.
1430    vectors: Vec<usize>,
1431}
1432
1433/// Reads the table's columns to decide how `vector-search` lays out a row.
1434///
1435/// A table the engine cannot describe gets the old layout: the rowid is
1436/// selected and no cell is converted. The search itself then reports the real
1437/// error, which is better than inventing one here.
1438///
1439/// @param context - the open database
1440/// @param name - the table being searched
1441fn searched_table_shape(context: &mut Context, name: &str) -> SearchedTable {
1442    let info = context
1443        .shell()
1444        .collect(&format!("PRAGMA table_info({})", quoted(name)))
1445        .map(|(_, rows)| rows)
1446        .unwrap_or_default();
1447    let text_of = |value: Option<&Value<'static>>| match value {
1448        Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).to_ascii_uppercase(),
1449        _ => String::new(),
1450    };
1451    let key_of = |row: &Vec<Value<'static>>| match row.get(5) {
1452        Some(Value::Integer(key)) => *key,
1453        _ => 0,
1454    };
1455    let keys: Vec<&Vec<Value<'static>>> = info.iter().filter(|row| key_of(row) > 0).collect();
1456    let integer_key = matches!(keys.as_slice(), [only] if text_of(only.get(2)) == "INTEGER");
1457    let vectors = info
1458        .iter()
1459        .enumerate()
1460        .filter(|(_, row)| text_of(row.get(2)).starts_with("VECTOR"))
1461        .map(|(nth, _)| nth)
1462        .collect();
1463    SearchedTable {
1464        integer_key,
1465        vectors,
1466    }
1467}
1468
1469/// Turns a `VECTOR` cell into the JSON array of its numbers.
1470///
1471/// A vector is stored as little endian 32 bit floats. The result used to print
1472/// it as `{"blob": "0000803f..."}`, which nobody can read. A cell that is not
1473/// a blob, or whose length is not a whole number of floats, is left alone.
1474///
1475/// @param cell - the value the query produced for a `VECTOR` column
1476fn vector_numbers(cell: &Json) -> Option<Json> {
1477    let hex = cell.get("blob").and_then(Json::text)?;
1478    let bytes: Vec<u8> = hex
1479        .as_bytes()
1480        .chunks(2)
1481        .map(|pair| {
1482            std::str::from_utf8(pair)
1483                .ok()
1484                .and_then(|digits| u8::from_str_radix(digits, 16).ok())
1485        })
1486        .collect::<Option<Vec<u8>>>()?;
1487    if !bytes.len().is_multiple_of(4) {
1488        return None;
1489    }
1490    let numbers = bytes
1491        .chunks_exact(4)
1492        .map(|word| {
1493            let mut four = [0u8; 4];
1494            four.copy_from_slice(word);
1495            Json::Real(f64::from(f32::from_le_bytes(four)))
1496        })
1497        .collect();
1498    Some(Json::Array(numbers))
1499}
1500
1501/// `capabilities`: what the engine says it does, checked in both directions.
1502pub fn capabilities(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1503    let wanted = arguments.text("name");
1504    let rows: Vec<Vec<Json>> = inillucent_driver::CAPABILITIES
1505        .iter()
1506        .filter(|entry| wanted.is_none_or(|name| entry.name == name))
1507        .map(|entry| {
1508            vec![
1509                json::text(entry.name),
1510                json::text(support_name(entry.support)),
1511                json::text(entry.note),
1512            ]
1513        })
1514        .collect();
1515    if rows.is_empty() {
1516        return Err(Failed::said(
1517            Status::NotFound,
1518            format!(
1519                "no capability named \"{}\". An unknown name means no, never yes: a capability \
1520                 that was never declared was never checked.",
1521                wanted.unwrap_or_default()
1522            ),
1523        ));
1524    }
1525    let names = vec![
1526        "capability".to_string(),
1527        "support".to_string(),
1528        "note".to_string(),
1529    ];
1530    let columns = columns_from(&names, &rows);
1531    // **Not the aligned table, for this one command.** A note runs to two
1532    // hundred characters - `cancel`'s explains why a Stop button would be a lie
1533    // - and padding a column to the widest of those produces lines nothing can
1534    // read, on a terminal or in a model's context. The rows are still in the
1535    // result for a program; this is what a reader gets.
1536    let text = wrapped_notes(&rows);
1537    Ok(Outcome {
1538        command: "capabilities".to_string(),
1539        total: rows.len(),
1540        rows,
1541        columns,
1542        more: false,
1543        changes: 0,
1544        last_insert_rowid: 0,
1545        elapsed_ms: 0.0,
1546        text,
1547        extra: Vec::new(),
1548    })
1549}
1550
1551/// Lays capability rows out as a name, a verdict and a wrapped note.
1552///
1553/// @param rows - the capability rows, name then support then note
1554fn wrapped_notes(rows: &[Vec<Json>]) -> String {
1555    let mut lines = Vec::with_capacity(rows.len() * 3);
1556    for row in rows {
1557        let name = row.first().and_then(Json::text).unwrap_or_default();
1558        let support = row.get(1).and_then(Json::text).unwrap_or_default();
1559        let note = row.get(2).and_then(Json::text).unwrap_or_default();
1560        lines.push(format!("{name:<22} {support}"));
1561        for line in wrap(note, 74) {
1562            lines.push(format!("    {line}"));
1563        }
1564    }
1565    lines.join(
1566        "
1567",
1568    )
1569}
1570
1571/// Breaks a sentence into lines no wider than a limit, on word boundaries.
1572///
1573/// A word longer than the limit is left whole rather than cut: a broken
1574/// identifier is harder to read than a long line, and these notes name SQL
1575/// constructs.
1576///
1577/// @param text - the sentence
1578/// @param width - the widest line to produce
1579fn wrap(text: &str, width: usize) -> Vec<String> {
1580    let mut lines = Vec::new();
1581    let mut current = String::new();
1582    for word in text.split_whitespace() {
1583        if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
1584            lines.push(std::mem::take(&mut current));
1585        }
1586        if !current.is_empty() {
1587            current.push(' ');
1588        }
1589        current.push_str(word);
1590    }
1591    if !current.is_empty() {
1592        lines.push(current);
1593    }
1594    lines
1595}
1596
1597/// Returns the word a support level is reported as.
1598///
1599/// @param support - the level
1600fn support_name(support: Support) -> &'static str {
1601    match support {
1602        Support::Yes => "yes",
1603        Support::Partial => "partial",
1604        Support::No => "no",
1605    }
1606}
1607
1608/// `functions`: the SQL functions this engine answers.
1609pub fn functions(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1610    let mut sql = String::from("PRAGMA function_list");
1611    let produced = listing(context, "functions", &sql);
1612    // `function_list` is the enumeration `registers.rs` compares against the
1613    // pinned library on every build, so it is the authority here. If this
1614    // engine ever stops answering it, saying so is better than a hand-written
1615    // list that would then be the only one.
1616    let mut produced = produced?;
1617    if let Some(pattern) = arguments.text("pattern") {
1618        produced.rows.retain(|row| {
1619            row.first()
1620                .and_then(Json::text)
1621                .is_some_and(|name| like_matches(pattern, name))
1622        });
1623        produced.total = produced.rows.len();
1624        produced.text = table(&produced.columns, &produced.rows, &context.null);
1625    }
1626    sql.clear();
1627    Ok(produced)
1628}
1629
1630/// Says whether a name matches a SQL LIKE pattern, ignoring ASCII case.
1631///
1632/// `functions` filters rows the engine has already returned, so it cannot hand
1633/// the pattern to the engine's own LIKE. This used to be a substring test, so
1634/// `json%` matched nothing, because no function name contains a percent sign.
1635/// `%` matches any run of characters and `_` matches one, as in SQLite. There
1636/// is no escape character because no function name needs one.
1637///
1638/// @param pattern - the LIKE pattern the caller typed
1639/// @param name - the function name to test
1640fn like_matches(pattern: &str, name: &str) -> bool {
1641    let pattern: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
1642    let name: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
1643    // A two pointer walk that remembers only the last `%` and where in the name
1644    // it began matching. It never recurses, so a pattern made of many `%`
1645    // characters cannot exhaust the stack.
1646    let (mut p, mut n) = (0usize, 0usize);
1647    let mut star: Option<(usize, usize)> = None;
1648    while n < name.len() {
1649        match pattern.get(p) {
1650            Some('%') => {
1651                star = Some((p, n));
1652                p += 1;
1653            }
1654            Some(&c) if c == '_' || name.get(n) == Some(&c) => {
1655                p += 1;
1656                n += 1;
1657            }
1658            _ => match star {
1659                Some((star_p, star_n)) => {
1660                    p = star_p + 1;
1661                    n = star_n + 1;
1662                    star = Some((star_p, star_n + 1));
1663                }
1664                None => return false,
1665            },
1666        }
1667    }
1668    pattern
1669        .get(p..)
1670        .is_some_and(|rest| rest.iter().all(|&c| c == '%'))
1671}
1672
1673/// `migrate`: brings a SQLite file, a running server, or a legacy index into
1674/// this engine.
1675///
1676/// **The kind is defaulted from the source and not guessed at.** The other
1677/// migration tool argues, correctly, that deciding which migration to run by
1678/// looking at the source would "pick wrongly exactly once, on somebody's real
1679/// data" - but that argument is about a *directory* against a *file*, which are
1680/// both just paths and cannot be told apart. `postgres://host/db` is not a path
1681/// on any platform this runs on, so there is nothing here to be ambiguous
1682/// about, and the failure mode of getting it wrong is "there is no such file"
1683/// rather than a migration of the wrong thing. `--kind` still overrides it.
1684pub fn migrate(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1685    let destination = arguments.required_text("destination")?.to_string();
1686    let source = resolve_source(context, arguments.text("source"))?;
1687    let kind = arguments
1688        .text("kind")
1689        .map(str::to_string)
1690        .unwrap_or_else(|| kind_of_source(&source));
1691    if kind == "postgres" || kind == "mysql" {
1692        return migrate_remote(context, &source, &destination, arguments);
1693    }
1694
1695    let from = context.confine(&source)?;
1696    let to = context.confine(&destination)?;
1697    if !from.exists() {
1698        return Err(Failed::said(
1699            Status::NotFound,
1700            format!("there is no \"{source}\" to migrate from."),
1701        ));
1702    }
1703    if to.exists() {
1704        return Err(Failed::said(
1705            Status::InvalidState,
1706            format!("\"{destination}\" already exists. This tool never overwrites."),
1707        ));
1708    }
1709    match kind.as_str() {
1710        "sqlite" => migrate_sqlite_file(&from, &to),
1711        "index" => Err(Failed::unsupported(
1712            "migrate --kind index",
1713            "the retrieval-index migration runs in inillucent-migrate, which links the retrieval \
1714             engine. Run: inillucent-migrate <source-index-dir> <destination.db>",
1715        )),
1716        other => Err(Failed::misuse(format!(
1717            "'{other}' is not a migration kind. Use sqlite, postgres, mysql or index."
1718        ))),
1719    }
1720}
1721
1722/// The environment variable a source may be given in instead of an argument.
1723const SOURCE_URL_VARIABLE: &str = "INILLUCENT_SOURCE_URL";
1724
1725/// Returns the source to migrate from, in the three ways it may be given.
1726///
1727/// **A connection URL holds a password, and an argument is in the process list
1728/// for the whole run** - which for a large database is hours, and which every
1729/// other process on the machine can read. So there are three ways to say it,
1730/// in this order:
1731///
1732/// 1. the argument, when it is present and is not `-`;
1733/// 2. `INILLUCENT_SOURCE_URL`, when it holds something;
1734/// 3. one line of standard input, when the argument is `-`.
1735///
1736/// Standard input is only read for an explicit `-`, and never on a confined
1737/// surface: `--root` is how this command table is handed to an agent over MCP,
1738/// and MCP speaks on standard input, so a verb that read a line from it there
1739/// would consume the transport rather than a URL.
1740///
1741/// The line is trimmed of its newline and nothing else, because a password may
1742/// legitimately end in a space.
1743///
1744/// @param context - the surface, which may be confined
1745/// @param argument - the source the caller passed, when it passed one
1746fn resolve_source(context: &Context, argument: Option<&str>) -> Result<String, Failed> {
1747    if let Some(source) = argument {
1748        if source != "-" {
1749            return Ok(source.to_string());
1750        }
1751    }
1752    if let Ok(held) = std::env::var(SOURCE_URL_VARIABLE) {
1753        if !held.trim().is_empty() {
1754            return Ok(held);
1755        }
1756    }
1757    if argument == Some("-") {
1758        if context.confined() {
1759            return Err(Failed::said(
1760                Status::InvalidState,
1761                "this surface is confined to a directory with --root, and '-' reads the source \
1762                 from standard input, which such a surface does not have to itself. Set \
1763                 INILLUCENT_SOURCE_URL instead.",
1764            ));
1765        }
1766        let mut line = String::new();
1767        std::io::stdin()
1768            .read_line(&mut line)
1769            .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
1770        let line = line.trim_end_matches(['\r', '\n']).to_string();
1771        if line.is_empty() {
1772            return Err(Failed::misuse(
1773                "standard input held no source. Write the file path or the connection URL on one \
1774                 line.",
1775            ));
1776        }
1777        return Ok(line);
1778    }
1779    Err(Failed::misuse(format!(
1780        "migrate needs a source: a database file, or a postgres:// or mysql:// URL. Pass it as \
1781         the first argument, set {SOURCE_URL_VARIABLE}, or pass '-' to read one line from \
1782         standard input."
1783    )))
1784}
1785
1786/// Returns the migration kind a source names, when it names one.
1787///
1788/// @param source - what the caller passed as the source
1789fn kind_of_source(source: &str) -> String {
1790    match inillucent_remote::ConnectionUrl::parse(source) {
1791        Ok(url) => url.scheme.name().to_string(),
1792        Err(_) => "sqlite".to_string(),
1793    }
1794}
1795
1796/// Migrates a running PostgreSQL or MySQL server into a new `.rdb`.
1797///
1798/// **Refused when the surface is confined.** `--root DIR` exists so that an MCP
1799/// server can be handed to an agent without handing it the file system, and a
1800/// verb that dialled an arbitrary host and port would be a hole straight
1801/// through that: the confinement is about reach, not about paths. So a
1802/// confined surface refuses a remote source by name, the same way `--readonly`
1803/// refuses a write.
1804///
1805/// @param context - the surface, which may be confined
1806/// @param source - the connection URL
1807/// @param destination - the file to write
1808/// @param arguments - the rest of the command line
1809fn migrate_remote(
1810    context: &mut Context,
1811    source: &str,
1812    destination: &str,
1813    arguments: &Arguments,
1814) -> Result<Outcome, Failed> {
1815    if context.confined() {
1816        return Err(Failed::said(
1817            Status::InvalidState,
1818            "this surface is confined to a directory with --root, and a migration from a server \
1819             reaches a host and a port rather than a path. Run it from an unconfined command \
1820             line.",
1821        ));
1822    }
1823    let url = inillucent_remote::ConnectionUrl::parse(source)
1824        .map_err(|error| Failed::misuse(error.detail().unwrap_or_else(|| error.message())))?;
1825    let to = context.confine(destination)?;
1826    if to.exists() {
1827        return Err(Failed::said(
1828            Status::InvalidState,
1829            format!("\"{destination}\" already exists. This tool never overwrites."),
1830        ));
1831    }
1832    let mut plan = inillucent_remote::Plan::new(url, &to);
1833    // The surface's own ceiling. `command::run` has already armed it on this
1834    // thread, so this is belt and braces rather than the only bound - but a
1835    // migration is the one verb long enough that being explicit about which
1836    // budget it is under is worth the line.
1837    plan.limits = Some(context.limits());
1838    if let Some(batch) = arguments.integer("batch") {
1839        plan.batch = (batch.max(1)) as u64;
1840    }
1841    plan.insecure_plaintext = arguments.flag("insecure-plaintext");
1842    // **Asked before anything is dialled.** A refusal here has cost a URL parse
1843    // and nothing else - no socket, no staging file, and no password on a
1844    // wire. The message says which of the two halves of the policy is missing.
1845    plan.transport().map_err(|error| {
1846        Failed::said(
1847            Status::InvalidState,
1848            error.detail().unwrap_or_else(|| error.message()),
1849        )
1850    })?;
1851    let report = inillucent_remote::migrate::migrate(&plan).map_err(|error| {
1852        Failed::said(
1853            Status::Io,
1854            error.detail().unwrap_or_else(|| error.message()),
1855        )
1856    })?;
1857
1858    let checks: Vec<json::Json> = report
1859        .checks
1860        .iter()
1861        .map(|check| {
1862            json::object(vec![
1863                ("name", json::text(&check.name)),
1864                ("passed", json::Json::Bool(check.passed)),
1865                ("detail", json::text(&check.detail)),
1866            ])
1867        })
1868        .collect();
1869    let tables: Vec<json::Json> = report
1870        .tables
1871        .iter()
1872        .map(|table| {
1873            json::object(vec![
1874                ("source", json::text(&table.source)),
1875                ("destination", json::text(&table.target)),
1876                ("rows", json::Json::Int(table.rows as i64)),
1877                ("digest", json::text(&table.digest)),
1878            ])
1879        })
1880        .collect();
1881    let not_carried: Vec<json::Json> = report
1882        .not_carried
1883        .iter()
1884        .map(|(kind, name)| {
1885            json::object(vec![("kind", json::text(kind)), ("name", json::text(name))])
1886        })
1887        .collect();
1888
1889    // **Every check is printed whether it passed or not.** A migration that is
1890    // wrong is worth describing completely: knowing that the counts are right
1891    // and one table's digest is not is a different problem from knowing that
1892    // nothing arrived.
1893    let mut text = format!(
1894        "{} -> {}\n{}, {} tables, {} rows\ntransport: {}\n",
1895        report.source,
1896        to.display(),
1897        report.server,
1898        report.tables.len(),
1899        report.rows(),
1900        report.transport
1901    );
1902    for check in &report.checks {
1903        text.push_str(&format!("  {}\n", check.line()));
1904    }
1905    if !report.passed() {
1906        text.push_str(&format!(
1907            "verification failed; nothing was published. The staging file is at {}",
1908            report.staged.display()
1909        ));
1910        return Err(Failed::said(Status::Io, text));
1911    }
1912    text.push_str(&format!("published: {}", to.display()));
1913
1914    Ok(Outcome::said("migrate", text)
1915        .with("destination", json::text(to.to_string_lossy()))
1916        // How the connection was made, so the answer to "were those rows
1917        // encrypted in transit" is in the result rather than in whoever ran it.
1918        .with("transport", json::text(&report.transport))
1919        // The **redacted** URL: an MCP call's result is written into an agent
1920        // transcript, and the transcript outlives the run.
1921        .with("source", json::text(&report.source))
1922        .with("server", json::text(&report.server))
1923        .with("rows", json::Json::Int(report.rows() as i64))
1924        .with("tables", json::Json::Array(tables))
1925        .with("checks", json::Json::Array(checks))
1926        .with("notCarried", json::Json::Array(not_carried)))
1927}
1928
1929/// Migrates a SQLite file, verified, the way the tool of the same name does.
1930///
1931/// **This used to import and rename, and call that a migration** (task-2066
1932/// §4.1.7). `AGENTS.md` and `agent-skills/inillucent-migrate` both say a
1933/// migration is verified by row count and digest and published only if every
1934/// check passes. The verb did none of it: `Database::import_sqlite_into`
1935/// followed by `std::fs::rename`. Measured on the shipped binary, that meant an
1936/// FTS5 table was dropped and the migration exited 0 with no warning even under
1937/// `--output json` - so a database whose only content was an FTS5 table
1938/// migrated to an empty file and reported success. `application_id` and
1939/// `user_version` went the same way, and every *successful* migration leaked
1940/// its staging segments, because the cleanup ran only on the error path.
1941///
1942/// `inillucent_migrate::sqlite::migrate` is the implementation that does what
1943/// the documentation says, and the `inillucent-migrate` binary has used it
1944/// since it was written. Two implementations of one job, and the shipped verb
1945/// had the one nobody was grading.
1946///
1947/// The report is carried out rather than reduced to a sentence: `checks`,
1948/// `rows`, `tables` and anything the source held that the destination does not,
1949/// which is the shape the PostgreSQL path already reports.
1950///
1951/// @param from - the SQLite file to read
1952/// @param to - the `.rdb` to publish
1953fn migrate_sqlite_file(from: &std::path::Path, to: &std::path::Path) -> Result<Outcome, Failed> {
1954    // The staging file is left where it fell on a failure, by design, so there
1955    // is something to look at; `migrate`'s message says where.
1956    let report = inillucent_migrate::sqlite::migrate(from, to)
1957        .map_err(|error| Failed::from_engine(&error))?;
1958    let failures: Vec<String> = report
1959        .failures()
1960        .iter()
1961        .map(|check| format!("{}: {}", check.name, check.detail))
1962        .collect();
1963    let checks = Json::Array(
1964        report
1965            .checks
1966            .iter()
1967            .map(|check| {
1968                json::object(vec![
1969                    ("name", json::text(&check.name)),
1970                    ("passed", Json::Bool(check.passed)),
1971                    ("detail", json::text(&check.detail)),
1972                ])
1973            })
1974            .collect(),
1975    );
1976    // **A report that did not pass is a failure, not a note.** `migrate`
1977    // answers `Ok(report)` for one, because publishing is its decision and
1978    // reporting is the caller's - and the caller used to have no opinion.
1979    if !report.passed() {
1980        return Err(Failed::said(
1981            Status::Corrupt,
1982            format!(
1983                "{} was not published: {}",
1984                to.display(),
1985                failures.join("; ")
1986            ),
1987        ));
1988    }
1989    Ok(Outcome::said(
1990        "migrate",
1991        format!("imported {} into {}", from.display(), to.display()),
1992    )
1993    .with("destination", json::text(to.to_string_lossy()))
1994    .with("checks", checks))
1995}
1996
1997/// `version`: what this build is.
1998pub fn version(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1999    let printed = context.collect_output(".version");
2000    context.shell().failed = false;
2001    context.shell().first_error = None;
2002    let text = format!(
2003        "{}
2004inillucent-cli {}
2005{}",
2006        printed.trim_end(),
2007        env!("CARGO_PKG_VERSION"),
2008        inillucent_driver::version()
2009    );
2010    Ok(Outcome::said("version", text)
2011        .with("cli", json::text(env!("CARGO_PKG_VERSION")))
2012        .with("driver", json::text(inillucent_driver::version())))
2013}
2014
2015/// `help`: the command table, or one entry from it.
2016pub fn help(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
2017    match arguments.text("topic") {
2018        None => {
2019            let rows: Vec<Vec<Json>> = super::COMMANDS
2020                .iter()
2021                .map(|command| vec![json::text(command.name), json::text(command.summary)])
2022                .collect();
2023            let names = vec!["command".to_string(), "what it does".to_string()];
2024            let columns = columns_from(&names, &rows);
2025            let text = table(&columns, &rows, "");
2026            Ok(Outcome {
2027                command: "help".to_string(),
2028                total: rows.len(),
2029                rows,
2030                columns,
2031                more: false,
2032                changes: 0,
2033                last_insert_rowid: 0,
2034                elapsed_ms: 0.0,
2035                text,
2036                extra: Vec::new(),
2037            })
2038        }
2039        Some(topic) => {
2040            let Some(command) = super::find(topic) else {
2041                return Err(Failed::said(
2042                    Status::NotFound,
2043                    format!("there is no '{topic}' command. Run 'inillucent help' for the list."),
2044                ));
2045            };
2046            let mut text = format!(
2047                "{}\n\n{}\n\n{}",
2048                command.usage(),
2049                command.summary,
2050                command.detail
2051            );
2052            if !command.params.is_empty() {
2053                text.push_str("\n\nParameters:");
2054                for param in command.params {
2055                    text.push_str(&format!(
2056                        "\n  {:<12} {}{}",
2057                        param.name,
2058                        if param.required { "(required) " } else { "" },
2059                        param.description
2060                    ));
2061                }
2062            }
2063            Ok(Outcome::said("help", text))
2064        }
2065    }
2066}
2067
2068/// `shell` and `mcp` are handled by the binary, and never reach the table.
2069///
2070/// They are in [`super::COMMANDS`] so that `inillucent help` lists them and so
2071/// that the parity test can assert their `cli_only` reason exists. Calling one
2072/// through the table is a mistake in a front end rather than in a request, and
2073/// it says so.
2074///
2075/// @param name - which of the two was reached
2076fn front_end_only(name: &'static str) -> Failed {
2077    Failed::misuse(format!(
2078        "'{name}' is run by the inillucent binary itself and cannot be dispatched here."
2079    ))
2080}
2081
2082/// The stand-in for `shell`.
2083pub fn shell_placeholder(
2084    _context: &mut Context,
2085    _arguments: &Arguments,
2086) -> Result<Outcome, Failed> {
2087    Err(front_end_only("shell"))
2088}
2089
2090/// The stand-in for `mcp`.
2091pub fn mcp_placeholder(_context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
2092    Err(front_end_only("mcp"))
2093}
2094
2095#[cfg(test)]
2096mod source_tests {
2097    use super::*;
2098    use crate::shell::Shell;
2099
2100    /// Serializes the cases that move `INILLUCENT_SOURCE_URL`.
2101    ///
2102    /// An environment variable is process-wide and `cargo test` runs the cases in
2103    /// one binary on several threads, so two of these racing is not a
2104    /// possibility - it is what happens. Three of the five below set the
2105    /// variable and three clear it, and a run of the five on their own failed
2106    /// four times out of five: a case that had just set the variable read the
2107    /// empty value another case had cleared, and the failure named the refusal
2108    /// rather than the race.
2109    ///
2110    /// The lock is poison-tolerant on purpose. A case that panics with it held
2111    /// has already failed and reported why; turning that into a second failure
2112    /// in every later case would bury the message that matters.
2113    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2114        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2115        LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
2116    }
2117
2118    /// Returns a surface, confined or not.
2119    ///
2120    /// @param root - the directory to confine to, when the case wants one
2121    fn context(root: Option<std::path::PathBuf>) -> Context {
2122        Context::for_test(
2123            Shell::open(":memory:").expect("a memory database opens"),
2124            root,
2125        )
2126    }
2127
2128    /// The argument is used when there is one, and it is not `-`.
2129    #[test]
2130    fn an_argument_is_the_source() {
2131        let context = context(None);
2132        let held = resolve_source(&context, Some("postgres://user@host/db"))
2133            .expect("the argument is accepted");
2134        assert_eq!(held, "postgres://user@host/db");
2135    }
2136
2137    /// With no argument, the source comes from the environment.
2138    ///
2139    /// **Which is what this exists for.** A connection URL holds a password
2140    /// and an argument is in the process list for the whole run.
2141    #[test]
2142    fn the_environment_supplies_a_source_that_was_not_an_argument() {
2143        let _held = env_guard();
2144        let context = context(None);
2145        std::env::set_var(SOURCE_URL_VARIABLE, "postgres://user:secret@host/db");
2146        let held = resolve_source(&context, None).expect("the variable is read");
2147        std::env::remove_var(SOURCE_URL_VARIABLE);
2148        assert_eq!(held, "postgres://user:secret@host/db");
2149    }
2150
2151    /// With nothing anywhere, the refusal names all three ways to say it.
2152    #[test]
2153    fn no_source_anywhere_is_refused_by_name() {
2154        let _held = env_guard();
2155        let context = context(None);
2156        std::env::remove_var(SOURCE_URL_VARIABLE);
2157        let error = resolve_source(&context, None).expect_err("there is no source");
2158        let said = format!("{error:?}");
2159        assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2160        assert!(said.contains("standard input"), "{said}");
2161    }
2162
2163    /// A confined surface refuses `-` rather than reading its own transport.
2164    ///
2165    /// `--root` is how this command table is handed to an agent over MCP, and
2166    /// MCP speaks on standard input: a verb that read a line from it there
2167    /// would consume the transport rather than a URL.
2168    #[test]
2169    fn a_confined_surface_refuses_to_read_standard_input() {
2170        let _held = env_guard();
2171        let context = context(Some(std::env::temp_dir()));
2172        std::env::remove_var(SOURCE_URL_VARIABLE);
2173        let error = resolve_source(&context, Some("-")).expect_err("a confined surface refuses");
2174        let said = format!("{error:?}");
2175        assert!(said.contains("--root"), "{said}");
2176        assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2177    }
2178
2179    /// Export refuses an ambiguous request before reading either data source.
2180    #[test]
2181    fn export_refuses_table_and_sql_together() {
2182        let mut arguments = Arguments::default();
2183        arguments.set("table", crate::json::text("expected"));
2184        arguments.set("sql", crate::json::text("SELECT 'other' AS v"));
2185        let failure = export(&mut context(None), &arguments)
2186            .expect_err("export must require one data source");
2187        assert!(failure.message.contains("not both"), "{}", failure.message);
2188    }
2189
2190    /// `-` with the variable set takes the variable and never touches stdin.
2191    ///
2192    /// The order matters: a caller that scripts `-` and also exports the
2193    /// variable should not block on a transport nobody is writing to.
2194    #[test]
2195    fn the_environment_wins_over_reading_standard_input() {
2196        let _held = env_guard();
2197        let context = context(None);
2198        std::env::set_var(SOURCE_URL_VARIABLE, "mysql://user@host/db");
2199        let held = resolve_source(&context, Some("-")).expect("the variable is read");
2200        std::env::remove_var(SOURCE_URL_VARIABLE);
2201        assert_eq!(held, "mysql://user@host/db");
2202    }
2203}