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    let printed = context.collect_output(&script);
1035    let rows = context.shell().rows_since_redirect;
1036    // The `.once` releases itself after the statement, and this releases it
1037    // after a statement that never ran - an empty `sql`, or one the parser
1038    // refused before the shell reached it. A redirect left open on a server
1039    // that stays up would send the next command's rows into this file.
1040    if destination.is_some() {
1041        let _ = context.shell().redirect(None, false);
1042    }
1043    if let Some(failed) = take_failure(context, &printed) {
1044        return Err(failed);
1045    }
1046    let Some(path) = destination else {
1047        return Ok(Outcome::said("export", printed.trim_end()));
1048    };
1049    wrote_a_file(&path, rows)
1050}
1051
1052/// Reports an export that went to a file rather than into the answer.
1053///
1054/// **The rows are not repeated here.** They are in the file, and a caller that
1055/// asked for a file asked for them to be there; an export of a million rows
1056/// that also carried a million rows back through the report would cost a copy
1057/// of the whole table in memory and a second one in the JSON, for something
1058/// nobody reads. What the report carries instead is the three things a caller
1059/// checks: where it went, how many rows went into it, and how large it is -
1060/// the last of which is read back off the file rather than counted up, so a
1061/// short write is visible in the report that claims the write happened.
1062///
1063/// @param path - the file that was written
1064/// @param rows - how many result rows the renderer was handed
1065fn wrote_a_file(path: &std::path::Path, rows: usize) -> Result<Outcome, Failed> {
1066    let named = path.to_string_lossy().into_owned();
1067    let bytes = std::fs::metadata(path)
1068        .map(|found| found.len())
1069        .unwrap_or(0);
1070    let mut produced = Outcome::said(
1071        "export",
1072        format!(
1073            "wrote {rows} row{} ({bytes} bytes) to {named}",
1074            if rows == 1 { "" } else { "s" }
1075        ),
1076    );
1077    produced.total = rows;
1078    Ok(produced
1079        .with("wrote", json::text(&named))
1080        .with("bytes", Json::Int(bytes as i64)))
1081}
1082
1083/// `backup`: copies the database to a file.
1084pub fn backup(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1085    let file = arguments.required_text("file")?.to_string();
1086    let confined = context.confine(&file)?;
1087    let named = confined.to_string_lossy().into_owned();
1088    context
1089        .shell()
1090        .backup_to(&named)
1091        .map_err(|message| Failed::said(Status::Io, message))?;
1092    Ok(Outcome::said("backup", format!("wrote {named}")).with("wrote", json::text(&named)))
1093}
1094
1095/// `encrypt`: writes an encrypted copy of a plaintext database.
1096///
1097/// The key is the one this process was given - `--key-file` or
1098/// `INILLUCENT_KEY` - and the source is opened without it, which the
1099/// dispatcher arranges through `crate::keys::opens_source_without_key`.
1100pub fn encrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1101    let Some(key) = crate::keys::configured() else {
1102        return Err(Failed::said(
1103            Status::InvalidState,
1104            "encrypt needs a key for the copy: pass --key-file or set INILLUCENT_KEY",
1105        ));
1106    };
1107    let named = copy_target(context, arguments)?;
1108    context
1109        .shell()
1110        .export_to(&named, Some(key))
1111        .map_err(|error| Failed::from_engine(&error))?;
1112    Ok(
1113        Outcome::said("encrypt", format!("wrote {named}, encrypted"))
1114            .with("wrote", json::text(&named))
1115            .with("encryption", json::text(inillucent_driver::CIPHER_NAME)),
1116    )
1117}
1118
1119/// `decrypt`: writes a plaintext copy of an encrypted database.
1120pub fn decrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1121    if !context.shell().is_encrypted() {
1122        return Err(Failed::said(
1123            Status::InvalidState,
1124            "this database is not encrypted, so there is nothing to decrypt",
1125        ));
1126    }
1127    let named = copy_target(context, arguments)?;
1128    context
1129        .shell()
1130        .export_to(&named, None)
1131        .map_err(|error| Failed::from_engine(&error))?;
1132    Ok(
1133        Outcome::said("decrypt", format!("wrote {named}, not encrypted"))
1134            .with("wrote", json::text(&named))
1135            .with("encryption", json::text("none")),
1136    )
1137}
1138
1139/// `rekey`: changes the key of an encrypted database.
1140///
1141/// The new key comes from `--new-key-file` or `INILLUCENT_NEW_KEY`, never
1142/// from a word on the command line; see `crate::keys` for why.
1143pub fn rekey(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1144    let key = match arguments.text("new-key-file") {
1145        Some(path) => {
1146            let confined = context.confine(path)?;
1147            crate::keys::read_key_file(&confined.to_string_lossy())
1148                .map_err(|message| Failed::said(Status::InvalidState, message))?
1149        }
1150        None => match std::env::var(crate::keys::NEW_KEY_VARIABLE) {
1151            Ok(text) if !text.is_empty() => inillucent_driver::EncryptionKey::parse(&text),
1152            _ => {
1153                return Err(Failed::said(
1154                    Status::InvalidState,
1155                    "rekey needs the new key: pass --new-key-file or set INILLUCENT_NEW_KEY",
1156                ))
1157            }
1158        },
1159    };
1160    context
1161        .shell()
1162        .rekey(key)
1163        .map_err(|error| Failed::from_engine(&error))?;
1164    Ok(Outcome::said("rekey", "the key was changed".to_string()))
1165}
1166
1167/// Reads and confines the output path `encrypt` and `decrypt` write to.
1168///
1169/// @param context - the session, for its root
1170/// @param arguments - the command's arguments
1171fn copy_target(context: &Context, arguments: &Arguments) -> Result<String, Failed> {
1172    let file = arguments.required_text("file")?.to_string();
1173    let confined = context.confine(&file)?;
1174    Ok(confined.to_string_lossy().into_owned())
1175}
1176
1177/// `restore`: replaces this database's contents from a file.
1178pub fn restore(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1179    let file = arguments.required_text("file")?.to_string();
1180    let confined = context.confine(&file)?;
1181    // **A backup that is not there is a refusal, not a new empty database
1182    // (task-1969, 5.2).** `.restore` is implemented as "open the file the
1183    // caller named" - see `dot.rs`, which explains why - and opening a file
1184    // that is not there creates it. So `inillucent --db app.rdb restore
1185    // typo.rdb` exited 0, said `ok`, and left the caller with an empty
1186    // database and no message. It is the shape task-1951 shipped in the
1187    // signing gate: a check that passed having checked nothing.
1188    if !confined.is_file() {
1189        return Err(Failed::said(
1190            Status::NotFound,
1191            format!(
1192                "{}: there is no such backup file to restore from",
1193                confined.to_string_lossy()
1194            ),
1195        ));
1196    }
1197    dot(
1198        context,
1199        "restore",
1200        &format!(".restore \"{}\"", confined.to_string_lossy()),
1201    )
1202}
1203
1204/// `checkpoint`: writes the log back into the database file.
1205pub fn checkpoint(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1206    listing(context, "checkpoint", "PRAGMA wal_checkpoint")
1207}
1208
1209/// `integrity-check`: reads every page and says whether it holds together.
1210///
1211/// **The exit code and the `ok` field follow the answer** (task-2066 §4.1.4).
1212/// `PRAGMA integrity_check` reports damage as a *row of text*, the way SQLite
1213/// does, and this verb listed the rows and stopped there - so
1214/// `outcome.rs`'s unconditional `("ok", Json::Bool(true))` said a corrupt file
1215/// was fine, at exit 0. Any health check written as
1216/// `inillucent integrity-check && echo healthy` was told the wrong thing, and
1217/// this is the one command whose entire purpose is to answer whether a database
1218/// is sound. The pinned SQLite 3.53.4 exits 1 on the equivalent.
1219///
1220/// The pragma still returns rows. What changed is that the verb reads them.
1221pub fn integrity_check(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1222    let produced = listing(context, "integrity-check", "PRAGMA integrity_check")?;
1223    if let Some(damage) = first_damage(&produced) {
1224        return Err(Failed::said(Status::Corrupt, damage));
1225    }
1226    Ok(produced)
1227}
1228
1229/// Returns what an integrity report says is wrong, or `None` when it says `ok`.
1230///
1231/// SQLite's contract is one row reading `ok` for a healthy file, and one row
1232/// per problem otherwise. Anything that is not exactly `ok` is damage, so a
1233/// future check that reports something this does not recognise is read as
1234/// damage rather than as health - which is the direction a health check has to
1235/// fail in.
1236///
1237/// @param produced - what the pragma answered
1238fn first_damage(produced: &Outcome) -> Option<String> {
1239    let said: Vec<String> = produced
1240        .rows
1241        .iter()
1242        .flatten()
1243        .map(|value| match value {
1244            Json::Text(text) => text.clone(),
1245            other => format!("{other:?}"),
1246        })
1247        .collect();
1248    if said.is_empty() {
1249        return Some(
1250            "PRAGMA integrity_check returned no rows at all, so this database's soundness is \
1251             unknown rather than confirmed"
1252                .to_string(),
1253        );
1254    }
1255    if said.iter().all(|line| line.trim() == "ok") {
1256        return None;
1257    }
1258    Some(said.join("; "))
1259}
1260
1261/// `analyze`: gathers the statistics the planner reads.
1262pub fn analyze(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1263    let sql = match arguments.text("table") {
1264        Some(name) => format!("ANALYZE {}", quoted(name)),
1265        None => "ANALYZE".to_string(),
1266    };
1267    // The engine's error, so a refusal because another process holds the file
1268    // is `busy` and not `syntax` (task-2173).
1269    context
1270        .shell()
1271        .connection()
1272        .execute_batch(&sql)
1273        .map_err(|error| Failed::from_engine(&error))?;
1274    Ok(Outcome::said("analyze", "ok. sqlite_stat1 is up to date."))
1275}
1276
1277/// `stats`: what the page cache and the file are doing.
1278pub fn stats(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1279    let cache = context.shell().cache_stats();
1280    let pool = context.shell().pool_bytes();
1281    let pages = context
1282        .shell()
1283        .scalar("PRAGMA page_count")
1284        .unwrap_or_default();
1285    let size = context
1286        .shell()
1287        .scalar("PRAGMA page_size")
1288        .unwrap_or_default();
1289    let free = context
1290        .shell()
1291        .scalar("PRAGMA freelist_count")
1292        .unwrap_or_default();
1293    let text = format!(
1294        "pool bytes:      {pool}\npage size:       {size}\npage count:      {pages}\n\
1295         free pages:      {free}\ncache hits:      {}\ncache misses:    {}",
1296        cache.hits, cache.misses
1297    );
1298    Ok(Outcome::said("stats", text)
1299        .with("pool_bytes", Json::Int(pool as i64))
1300        .with("page_size", Json::Int(size.parse::<i64>().unwrap_or(0)))
1301        .with("page_count", Json::Int(pages.parse::<i64>().unwrap_or(0)))
1302        .with("free_pages", Json::Int(free.parse::<i64>().unwrap_or(0)))
1303        .with("cache_hits", Json::Int(cache.hits as i64))
1304        .with("cache_misses", Json::Int(cache.misses as i64)))
1305}
1306
1307/// Returns how many neighbours a search was asked for.
1308///
1309/// **`--k 0` and `--k -1` used to answer one row (task-1979, R10).** The count
1310/// was clamped with `.max(1)`, so a caller asking for none - which a loop over
1311/// a configured page size does - was given one, and a caller who had computed a
1312/// negative count from a mistake elsewhere was given one too. Neither is what
1313/// was asked for, and a row nobody asked for is worse than an error.
1314///
1315/// @param arguments - the command line as it was parsed
1316fn neighbours_asked_for(arguments: &Arguments) -> Result<i64, Failed> {
1317    let k = arguments.integer("k").unwrap_or(10);
1318    if k < 1 {
1319        return Err(Failed::misuse(
1320            "'k' has to be one or more: it is how many rows to return.",
1321        ));
1322    }
1323    Ok(k)
1324}
1325
1326/// `search`: full-text and hybrid retrieval, without writing the idiom.
1327///
1328/// One statement over an `inillucent_search` or FTS5 table, in the form both
1329/// modules answer: `WHERE <table> MATCH ? ORDER BY rank`. A caller that wants
1330/// something else writes it with `query`; this exists because the idiom is the
1331/// part nobody remembers.
1332pub fn search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1333    let query_text = arguments.required_text("query")?.to_string();
1334    let name = arguments.required_text("table")?.to_string();
1335    let k = neighbours_asked_for(arguments)?;
1336    // `--rerank` names the query text as the question, which turns reranking on for the search
1337    // and asks for `k` rows so the reranker's candidates are counted from the same number.
1338    let reranked = match arguments.flag("rerank") {
1339        true => format!(" AND question = {} AND k = {k}", quoted_text(&query_text)),
1340        false => String::new(),
1341    };
1342    let sql = format!(
1343        "SELECT rowid, * FROM {0} WHERE {0} MATCH {1}{reranked} ORDER BY rank LIMIT {k}",
1344        quoted(&name),
1345        quoted_text(&query_text)
1346    );
1347    let mut produced = produce(context, "search", &sql, &[], 0)?;
1348    produced.command = "search".to_string();
1349    Ok(produced.with("query", json::text(&query_text)))
1350}
1351
1352/// `vector-search`: the nearest rows to a vector, by cosine distance.
1353pub fn vector_search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1354    let name = arguments.required_text("table")?.to_string();
1355    let column = arguments.required_text("column")?.to_string();
1356    let numbers = arguments.values("vector");
1357    if numbers.is_empty() {
1358        return Err(Failed::misuse(
1359            "'vector' has to be an array of numbers, one per dimension.",
1360        ));
1361    }
1362    let mut blob = String::from("x'");
1363    for value in &numbers {
1364        let Some(number) = value.integer().map(|whole| whole as f64).or(match value {
1365            Json::Real(real) => Some(*real),
1366            _ => None,
1367        }) else {
1368            return Err(Failed::misuse(
1369                "every element of 'vector' has to be a number.",
1370            ));
1371        };
1372        for byte in (number as f32).to_bits().to_le_bytes() {
1373            blob.push_str(&format!("{byte:02x}"));
1374        }
1375    }
1376    blob.push('\'');
1377    let k = neighbours_asked_for(arguments)?;
1378    let measure = arguments.text("measure").unwrap_or("cos");
1379    let function = match measure {
1380        "cos" => "vector_distance_cos",
1381        "l2" => "vector_distance_l2",
1382        "dot" => "vector_dot",
1383        other => {
1384            return Err(Failed::misuse(format!(
1385                "'{other}' is not a measure. Use cos, l2 or dot."
1386            )))
1387        }
1388    };
1389    let shape = searched_table_shape(context, &name);
1390    // **The rowid only when the table has no key of its own.** `SELECT rowid, *`
1391    // on a table with an `INTEGER PRIMARY KEY` printed that key twice, because
1392    // the engine names the rowid after the key and `*` includes it again.
1393    let rowid = if shape.integer_key { "" } else { "rowid, " };
1394    let sql = format!(
1395        "SELECT {rowid}*, {function}({1}, {blob}) AS distance FROM {0} \
1396         WHERE {1} IS NOT NULL ORDER BY {function}({1}, {blob}) LIMIT {k}",
1397        quoted(&name),
1398        quoted(&column)
1399    );
1400    let mut produced = produce(context, "vector-search", &sql, &[], 0)?;
1401    let offset = usize::from(!shape.integer_key);
1402    let positions: Vec<usize> = shape.vectors.iter().map(|nth| nth + offset).collect();
1403    for row in &mut produced.rows {
1404        for &nth in &positions {
1405            if let Some(cell) = row.get_mut(nth) {
1406                if let Some(numbers) = vector_numbers(cell) {
1407                    *cell = numbers;
1408                }
1409            }
1410        }
1411    }
1412    if !positions.is_empty() {
1413        let names: Vec<String> = produced.columns.iter().map(|c| c.name.clone()).collect();
1414        produced.columns = columns_from(&names, &produced.rows);
1415        produced.text = table(&produced.columns, &produced.rows, &context.null);
1416    }
1417    Ok(produced)
1418}
1419
1420/// What `vector-search` needs to know about the table it searches.
1421struct SearchedTable {
1422    /// The table has a single `INTEGER PRIMARY KEY` column, which is its rowid.
1423    integer_key: bool,
1424    /// The positions, among the table's own columns, of every `VECTOR` column.
1425    vectors: Vec<usize>,
1426}
1427
1428/// Reads the table's columns to decide how `vector-search` lays out a row.
1429///
1430/// A table the engine cannot describe gets the old layout: the rowid is
1431/// selected and no cell is converted. The search itself then reports the real
1432/// error, which is better than inventing one here.
1433///
1434/// @param context - the open database
1435/// @param name - the table being searched
1436fn searched_table_shape(context: &mut Context, name: &str) -> SearchedTable {
1437    let info = context
1438        .shell()
1439        .collect(&format!("PRAGMA table_info({})", quoted(name)))
1440        .map(|(_, rows)| rows)
1441        .unwrap_or_default();
1442    let text_of = |value: Option<&Value<'static>>| match value {
1443        Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).to_ascii_uppercase(),
1444        _ => String::new(),
1445    };
1446    let key_of = |row: &Vec<Value<'static>>| match row.get(5) {
1447        Some(Value::Integer(key)) => *key,
1448        _ => 0,
1449    };
1450    let keys: Vec<&Vec<Value<'static>>> = info.iter().filter(|row| key_of(row) > 0).collect();
1451    let integer_key = matches!(keys.as_slice(), [only] if text_of(only.get(2)) == "INTEGER");
1452    let vectors = info
1453        .iter()
1454        .enumerate()
1455        .filter(|(_, row)| text_of(row.get(2)).starts_with("VECTOR"))
1456        .map(|(nth, _)| nth)
1457        .collect();
1458    SearchedTable {
1459        integer_key,
1460        vectors,
1461    }
1462}
1463
1464/// Turns a `VECTOR` cell into the JSON array of its numbers.
1465///
1466/// A vector is stored as little endian 32 bit floats. The result used to print
1467/// it as `{"blob": "0000803f..."}`, which nobody can read. A cell that is not
1468/// a blob, or whose length is not a whole number of floats, is left alone.
1469///
1470/// @param cell - the value the query produced for a `VECTOR` column
1471fn vector_numbers(cell: &Json) -> Option<Json> {
1472    let hex = cell.get("blob").and_then(Json::text)?;
1473    let bytes: Vec<u8> = hex
1474        .as_bytes()
1475        .chunks(2)
1476        .map(|pair| {
1477            std::str::from_utf8(pair)
1478                .ok()
1479                .and_then(|digits| u8::from_str_radix(digits, 16).ok())
1480        })
1481        .collect::<Option<Vec<u8>>>()?;
1482    if !bytes.len().is_multiple_of(4) {
1483        return None;
1484    }
1485    let numbers = bytes
1486        .chunks_exact(4)
1487        .map(|word| {
1488            let mut four = [0u8; 4];
1489            four.copy_from_slice(word);
1490            Json::Real(f64::from(f32::from_le_bytes(four)))
1491        })
1492        .collect();
1493    Some(Json::Array(numbers))
1494}
1495
1496/// `capabilities`: what the engine says it does, checked in both directions.
1497pub fn capabilities(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1498    let wanted = arguments.text("name");
1499    let rows: Vec<Vec<Json>> = inillucent_driver::CAPABILITIES
1500        .iter()
1501        .filter(|entry| wanted.is_none_or(|name| entry.name == name))
1502        .map(|entry| {
1503            vec![
1504                json::text(entry.name),
1505                json::text(support_name(entry.support)),
1506                json::text(entry.note),
1507            ]
1508        })
1509        .collect();
1510    if rows.is_empty() {
1511        return Err(Failed::said(
1512            Status::NotFound,
1513            format!(
1514                "no capability named \"{}\". An unknown name means no, never yes: a capability \
1515                 that was never declared was never checked.",
1516                wanted.unwrap_or_default()
1517            ),
1518        ));
1519    }
1520    let names = vec![
1521        "capability".to_string(),
1522        "support".to_string(),
1523        "note".to_string(),
1524    ];
1525    let columns = columns_from(&names, &rows);
1526    // **Not the aligned table, for this one command.** A note runs to two
1527    // hundred characters - `cancel`'s explains why a Stop button would be a lie
1528    // - and padding a column to the widest of those produces lines nothing can
1529    // read, on a terminal or in a model's context. The rows are still in the
1530    // result for a program; this is what a reader gets.
1531    let text = wrapped_notes(&rows);
1532    Ok(Outcome {
1533        command: "capabilities".to_string(),
1534        total: rows.len(),
1535        rows,
1536        columns,
1537        more: false,
1538        changes: 0,
1539        last_insert_rowid: 0,
1540        elapsed_ms: 0.0,
1541        text,
1542        extra: Vec::new(),
1543    })
1544}
1545
1546/// Lays capability rows out as a name, a verdict and a wrapped note.
1547///
1548/// @param rows - the capability rows, name then support then note
1549fn wrapped_notes(rows: &[Vec<Json>]) -> String {
1550    let mut lines = Vec::with_capacity(rows.len() * 3);
1551    for row in rows {
1552        let name = row.first().and_then(Json::text).unwrap_or_default();
1553        let support = row.get(1).and_then(Json::text).unwrap_or_default();
1554        let note = row.get(2).and_then(Json::text).unwrap_or_default();
1555        lines.push(format!("{name:<22} {support}"));
1556        for line in wrap(note, 74) {
1557            lines.push(format!("    {line}"));
1558        }
1559    }
1560    lines.join(
1561        "
1562",
1563    )
1564}
1565
1566/// Breaks a sentence into lines no wider than a limit, on word boundaries.
1567///
1568/// A word longer than the limit is left whole rather than cut: a broken
1569/// identifier is harder to read than a long line, and these notes name SQL
1570/// constructs.
1571///
1572/// @param text - the sentence
1573/// @param width - the widest line to produce
1574fn wrap(text: &str, width: usize) -> Vec<String> {
1575    let mut lines = Vec::new();
1576    let mut current = String::new();
1577    for word in text.split_whitespace() {
1578        if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
1579            lines.push(std::mem::take(&mut current));
1580        }
1581        if !current.is_empty() {
1582            current.push(' ');
1583        }
1584        current.push_str(word);
1585    }
1586    if !current.is_empty() {
1587        lines.push(current);
1588    }
1589    lines
1590}
1591
1592/// Returns the word a support level is reported as.
1593///
1594/// @param support - the level
1595fn support_name(support: Support) -> &'static str {
1596    match support {
1597        Support::Yes => "yes",
1598        Support::Partial => "partial",
1599        Support::No => "no",
1600    }
1601}
1602
1603/// `functions`: the SQL functions this engine answers.
1604pub fn functions(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1605    let mut sql = String::from("PRAGMA function_list");
1606    let produced = listing(context, "functions", &sql);
1607    // `function_list` is the enumeration `registers.rs` compares against the
1608    // pinned library on every build, so it is the authority here. If this
1609    // engine ever stops answering it, saying so is better than a hand-written
1610    // list that would then be the only one.
1611    let mut produced = produced?;
1612    if let Some(pattern) = arguments.text("pattern") {
1613        produced.rows.retain(|row| {
1614            row.first()
1615                .and_then(Json::text)
1616                .is_some_and(|name| like_matches(pattern, name))
1617        });
1618        produced.total = produced.rows.len();
1619        produced.text = table(&produced.columns, &produced.rows, &context.null);
1620    }
1621    sql.clear();
1622    Ok(produced)
1623}
1624
1625/// Says whether a name matches a SQL LIKE pattern, ignoring ASCII case.
1626///
1627/// `functions` filters rows the engine has already returned, so it cannot hand
1628/// the pattern to the engine's own LIKE. This used to be a substring test, so
1629/// `json%` matched nothing, because no function name contains a percent sign.
1630/// `%` matches any run of characters and `_` matches one, as in SQLite. There
1631/// is no escape character because no function name needs one.
1632///
1633/// @param pattern - the LIKE pattern the caller typed
1634/// @param name - the function name to test
1635fn like_matches(pattern: &str, name: &str) -> bool {
1636    let pattern: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
1637    let name: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
1638    // A two pointer walk that remembers only the last `%` and where in the name
1639    // it began matching. It never recurses, so a pattern made of many `%`
1640    // characters cannot exhaust the stack.
1641    let (mut p, mut n) = (0usize, 0usize);
1642    let mut star: Option<(usize, usize)> = None;
1643    while n < name.len() {
1644        match pattern.get(p) {
1645            Some('%') => {
1646                star = Some((p, n));
1647                p += 1;
1648            }
1649            Some(&c) if c == '_' || name.get(n) == Some(&c) => {
1650                p += 1;
1651                n += 1;
1652            }
1653            _ => match star {
1654                Some((star_p, star_n)) => {
1655                    p = star_p + 1;
1656                    n = star_n + 1;
1657                    star = Some((star_p, star_n + 1));
1658                }
1659                None => return false,
1660            },
1661        }
1662    }
1663    pattern
1664        .get(p..)
1665        .is_some_and(|rest| rest.iter().all(|&c| c == '%'))
1666}
1667
1668/// `migrate`: brings a SQLite file, a running server, or a legacy index into
1669/// this engine.
1670///
1671/// **The kind is defaulted from the source and not guessed at.** The other
1672/// migration tool argues, correctly, that deciding which migration to run by
1673/// looking at the source would "pick wrongly exactly once, on somebody's real
1674/// data" - but that argument is about a *directory* against a *file*, which are
1675/// both just paths and cannot be told apart. `postgres://host/db` is not a path
1676/// on any platform this runs on, so there is nothing here to be ambiguous
1677/// about, and the failure mode of getting it wrong is "there is no such file"
1678/// rather than a migration of the wrong thing. `--kind` still overrides it.
1679pub fn migrate(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1680    let destination = arguments.required_text("destination")?.to_string();
1681    let source = resolve_source(context, arguments.text("source"))?;
1682    let kind = arguments
1683        .text("kind")
1684        .map(str::to_string)
1685        .unwrap_or_else(|| kind_of_source(&source));
1686    if kind == "postgres" || kind == "mysql" {
1687        return migrate_remote(context, &source, &destination, arguments);
1688    }
1689
1690    let from = context.confine(&source)?;
1691    let to = context.confine(&destination)?;
1692    if !from.exists() {
1693        return Err(Failed::said(
1694            Status::NotFound,
1695            format!("there is no \"{source}\" to migrate from."),
1696        ));
1697    }
1698    if to.exists() {
1699        return Err(Failed::said(
1700            Status::InvalidState,
1701            format!("\"{destination}\" already exists. This tool never overwrites."),
1702        ));
1703    }
1704    match kind.as_str() {
1705        "sqlite" => migrate_sqlite_file(&from, &to),
1706        "index" => Err(Failed::unsupported(
1707            "migrate --kind index",
1708            "the retrieval-index migration runs in inillucent-migrate, which links the retrieval \
1709             engine. Run: inillucent-migrate <source-index-dir> <destination.db>",
1710        )),
1711        other => Err(Failed::misuse(format!(
1712            "'{other}' is not a migration kind. Use sqlite, postgres, mysql or index."
1713        ))),
1714    }
1715}
1716
1717/// The environment variable a source may be given in instead of an argument.
1718const SOURCE_URL_VARIABLE: &str = "INILLUCENT_SOURCE_URL";
1719
1720/// Returns the source to migrate from, in the three ways it may be given.
1721///
1722/// **A connection URL holds a password, and an argument is in the process list
1723/// for the whole run** - which for a large database is hours, and which every
1724/// other process on the machine can read. So there are three ways to say it,
1725/// in this order:
1726///
1727/// 1. the argument, when it is present and is not `-`;
1728/// 2. `INILLUCENT_SOURCE_URL`, when it holds something;
1729/// 3. one line of standard input, when the argument is `-`.
1730///
1731/// Standard input is only read for an explicit `-`, and never on a confined
1732/// surface: `--root` is how this command table is handed to an agent over MCP,
1733/// and MCP speaks on standard input, so a verb that read a line from it there
1734/// would consume the transport rather than a URL.
1735///
1736/// The line is trimmed of its newline and nothing else, because a password may
1737/// legitimately end in a space.
1738///
1739/// @param context - the surface, which may be confined
1740/// @param argument - the source the caller passed, when it passed one
1741fn resolve_source(context: &Context, argument: Option<&str>) -> Result<String, Failed> {
1742    if let Some(source) = argument {
1743        if source != "-" {
1744            return Ok(source.to_string());
1745        }
1746    }
1747    if let Ok(held) = std::env::var(SOURCE_URL_VARIABLE) {
1748        if !held.trim().is_empty() {
1749            return Ok(held);
1750        }
1751    }
1752    if argument == Some("-") {
1753        if context.confined() {
1754            return Err(Failed::said(
1755                Status::InvalidState,
1756                "this surface is confined to a directory with --root, and '-' reads the source \
1757                 from standard input, which such a surface does not have to itself. Set \
1758                 INILLUCENT_SOURCE_URL instead.",
1759            ));
1760        }
1761        let mut line = String::new();
1762        std::io::stdin()
1763            .read_line(&mut line)
1764            .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
1765        let line = line.trim_end_matches(['\r', '\n']).to_string();
1766        if line.is_empty() {
1767            return Err(Failed::misuse(
1768                "standard input held no source. Write the file path or the connection URL on one \
1769                 line.",
1770            ));
1771        }
1772        return Ok(line);
1773    }
1774    Err(Failed::misuse(format!(
1775        "migrate needs a source: a database file, or a postgres:// or mysql:// URL. Pass it as \
1776         the first argument, set {SOURCE_URL_VARIABLE}, or pass '-' to read one line from \
1777         standard input."
1778    )))
1779}
1780
1781/// Returns the migration kind a source names, when it names one.
1782///
1783/// @param source - what the caller passed as the source
1784fn kind_of_source(source: &str) -> String {
1785    match inillucent_remote::ConnectionUrl::parse(source) {
1786        Ok(url) => url.scheme.name().to_string(),
1787        Err(_) => "sqlite".to_string(),
1788    }
1789}
1790
1791/// Migrates a running PostgreSQL or MySQL server into a new `.rdb`.
1792///
1793/// **Refused when the surface is confined.** `--root DIR` exists so that an MCP
1794/// server can be handed to an agent without handing it the file system, and a
1795/// verb that dialled an arbitrary host and port would be a hole straight
1796/// through that: the confinement is about reach, not about paths. So a
1797/// confined surface refuses a remote source by name, the same way `--readonly`
1798/// refuses a write.
1799///
1800/// @param context - the surface, which may be confined
1801/// @param source - the connection URL
1802/// @param destination - the file to write
1803/// @param arguments - the rest of the command line
1804fn migrate_remote(
1805    context: &mut Context,
1806    source: &str,
1807    destination: &str,
1808    arguments: &Arguments,
1809) -> Result<Outcome, Failed> {
1810    if context.confined() {
1811        return Err(Failed::said(
1812            Status::InvalidState,
1813            "this surface is confined to a directory with --root, and a migration from a server \
1814             reaches a host and a port rather than a path. Run it from an unconfined command \
1815             line.",
1816        ));
1817    }
1818    let url = inillucent_remote::ConnectionUrl::parse(source)
1819        .map_err(|error| Failed::misuse(error.detail().unwrap_or_else(|| error.message())))?;
1820    let to = context.confine(destination)?;
1821    if to.exists() {
1822        return Err(Failed::said(
1823            Status::InvalidState,
1824            format!("\"{destination}\" already exists. This tool never overwrites."),
1825        ));
1826    }
1827    let mut plan = inillucent_remote::Plan::new(url, &to);
1828    // The surface's own ceiling. `command::run` has already armed it on this
1829    // thread, so this is belt and braces rather than the only bound - but a
1830    // migration is the one verb long enough that being explicit about which
1831    // budget it is under is worth the line.
1832    plan.limits = Some(context.limits());
1833    if let Some(batch) = arguments.integer("batch") {
1834        plan.batch = (batch.max(1)) as u64;
1835    }
1836    plan.insecure_plaintext = arguments.flag("insecure-plaintext");
1837    // **Asked before anything is dialled.** A refusal here has cost a URL parse
1838    // and nothing else - no socket, no staging file, and no password on a
1839    // wire. The message says which of the two halves of the policy is missing.
1840    plan.transport().map_err(|error| {
1841        Failed::said(
1842            Status::InvalidState,
1843            error.detail().unwrap_or_else(|| error.message()),
1844        )
1845    })?;
1846    let report = inillucent_remote::migrate::migrate(&plan).map_err(|error| {
1847        Failed::said(
1848            Status::Io,
1849            error.detail().unwrap_or_else(|| error.message()),
1850        )
1851    })?;
1852
1853    let checks: Vec<json::Json> = report
1854        .checks
1855        .iter()
1856        .map(|check| {
1857            json::object(vec![
1858                ("name", json::text(&check.name)),
1859                ("passed", json::Json::Bool(check.passed)),
1860                ("detail", json::text(&check.detail)),
1861            ])
1862        })
1863        .collect();
1864    let tables: Vec<json::Json> = report
1865        .tables
1866        .iter()
1867        .map(|table| {
1868            json::object(vec![
1869                ("source", json::text(&table.source)),
1870                ("destination", json::text(&table.target)),
1871                ("rows", json::Json::Int(table.rows as i64)),
1872                ("digest", json::text(&table.digest)),
1873            ])
1874        })
1875        .collect();
1876    let not_carried: Vec<json::Json> = report
1877        .not_carried
1878        .iter()
1879        .map(|(kind, name)| {
1880            json::object(vec![("kind", json::text(kind)), ("name", json::text(name))])
1881        })
1882        .collect();
1883
1884    // **Every check is printed whether it passed or not.** A migration that is
1885    // wrong is worth describing completely: knowing that the counts are right
1886    // and one table's digest is not is a different problem from knowing that
1887    // nothing arrived.
1888    let mut text = format!(
1889        "{} -> {}\n{}, {} tables, {} rows\ntransport: {}\n",
1890        report.source,
1891        to.display(),
1892        report.server,
1893        report.tables.len(),
1894        report.rows(),
1895        report.transport
1896    );
1897    for check in &report.checks {
1898        text.push_str(&format!("  {}\n", check.line()));
1899    }
1900    if !report.passed() {
1901        text.push_str(&format!(
1902            "verification failed; nothing was published. The staging file is at {}",
1903            report.staged.display()
1904        ));
1905        return Err(Failed::said(Status::Io, text));
1906    }
1907    text.push_str(&format!("published: {}", to.display()));
1908
1909    Ok(Outcome::said("migrate", text)
1910        .with("destination", json::text(to.to_string_lossy()))
1911        // How the connection was made, so the answer to "were those rows
1912        // encrypted in transit" is in the result rather than in whoever ran it.
1913        .with("transport", json::text(&report.transport))
1914        // The **redacted** URL: an MCP call's result is written into an agent
1915        // transcript, and the transcript outlives the run.
1916        .with("source", json::text(&report.source))
1917        .with("server", json::text(&report.server))
1918        .with("rows", json::Json::Int(report.rows() as i64))
1919        .with("tables", json::Json::Array(tables))
1920        .with("checks", json::Json::Array(checks))
1921        .with("notCarried", json::Json::Array(not_carried)))
1922}
1923
1924/// Migrates a SQLite file, verified, the way the tool of the same name does.
1925///
1926/// **This used to import and rename, and call that a migration** (task-2066
1927/// §4.1.7). `AGENTS.md` and `agent-skills/inillucent-migrate` both say a
1928/// migration is verified by row count and digest and published only if every
1929/// check passes. The verb did none of it: `Database::import_sqlite_into`
1930/// followed by `std::fs::rename`. Measured on the shipped binary, that meant an
1931/// FTS5 table was dropped and the migration exited 0 with no warning even under
1932/// `--output json` - so a database whose only content was an FTS5 table
1933/// migrated to an empty file and reported success. `application_id` and
1934/// `user_version` went the same way, and every *successful* migration leaked
1935/// its staging segments, because the cleanup ran only on the error path.
1936///
1937/// `inillucent_migrate::sqlite::migrate` is the implementation that does what
1938/// the documentation says, and the `inillucent-migrate` binary has used it
1939/// since it was written. Two implementations of one job, and the shipped verb
1940/// had the one nobody was grading.
1941///
1942/// The report is carried out rather than reduced to a sentence: `checks`,
1943/// `rows`, `tables` and anything the source held that the destination does not,
1944/// which is the shape the PostgreSQL path already reports.
1945///
1946/// @param from - the SQLite file to read
1947/// @param to - the `.rdb` to publish
1948fn migrate_sqlite_file(from: &std::path::Path, to: &std::path::Path) -> Result<Outcome, Failed> {
1949    // The staging file is left where it fell on a failure, by design, so there
1950    // is something to look at; `migrate`'s message says where.
1951    let report = inillucent_migrate::sqlite::migrate(from, to)
1952        .map_err(|error| Failed::from_engine(&error))?;
1953    let failures: Vec<String> = report
1954        .failures()
1955        .iter()
1956        .map(|check| format!("{}: {}", check.name, check.detail))
1957        .collect();
1958    let checks = Json::Array(
1959        report
1960            .checks
1961            .iter()
1962            .map(|check| {
1963                json::object(vec![
1964                    ("name", json::text(&check.name)),
1965                    ("passed", Json::Bool(check.passed)),
1966                    ("detail", json::text(&check.detail)),
1967                ])
1968            })
1969            .collect(),
1970    );
1971    // **A report that did not pass is a failure, not a note.** `migrate`
1972    // answers `Ok(report)` for one, because publishing is its decision and
1973    // reporting is the caller's - and the caller used to have no opinion.
1974    if !report.passed() {
1975        return Err(Failed::said(
1976            Status::Corrupt,
1977            format!(
1978                "{} was not published: {}",
1979                to.display(),
1980                failures.join("; ")
1981            ),
1982        ));
1983    }
1984    Ok(Outcome::said(
1985        "migrate",
1986        format!("imported {} into {}", from.display(), to.display()),
1987    )
1988    .with("destination", json::text(to.to_string_lossy()))
1989    .with("checks", checks))
1990}
1991
1992/// `version`: what this build is.
1993pub fn version(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1994    let printed = context.collect_output(".version");
1995    context.shell().failed = false;
1996    context.shell().first_error = None;
1997    let text = format!(
1998        "{}
1999inillucent-cli {}
2000{}",
2001        printed.trim_end(),
2002        env!("CARGO_PKG_VERSION"),
2003        inillucent_driver::version()
2004    );
2005    Ok(Outcome::said("version", text)
2006        .with("cli", json::text(env!("CARGO_PKG_VERSION")))
2007        .with("driver", json::text(inillucent_driver::version())))
2008}
2009
2010/// `help`: the command table, or one entry from it.
2011pub fn help(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
2012    match arguments.text("topic") {
2013        None => {
2014            let rows: Vec<Vec<Json>> = super::COMMANDS
2015                .iter()
2016                .map(|command| vec![json::text(command.name), json::text(command.summary)])
2017                .collect();
2018            let names = vec!["command".to_string(), "what it does".to_string()];
2019            let columns = columns_from(&names, &rows);
2020            let text = table(&columns, &rows, "");
2021            Ok(Outcome {
2022                command: "help".to_string(),
2023                total: rows.len(),
2024                rows,
2025                columns,
2026                more: false,
2027                changes: 0,
2028                last_insert_rowid: 0,
2029                elapsed_ms: 0.0,
2030                text,
2031                extra: Vec::new(),
2032            })
2033        }
2034        Some(topic) => {
2035            let Some(command) = super::find(topic) else {
2036                return Err(Failed::said(
2037                    Status::NotFound,
2038                    format!("there is no '{topic}' command. Run 'inillucent help' for the list."),
2039                ));
2040            };
2041            let mut text = format!(
2042                "{}\n\n{}\n\n{}",
2043                command.usage(),
2044                command.summary,
2045                command.detail
2046            );
2047            if !command.params.is_empty() {
2048                text.push_str("\n\nParameters:");
2049                for param in command.params {
2050                    text.push_str(&format!(
2051                        "\n  {:<12} {}{}",
2052                        param.name,
2053                        if param.required { "(required) " } else { "" },
2054                        param.description
2055                    ));
2056                }
2057            }
2058            Ok(Outcome::said("help", text))
2059        }
2060    }
2061}
2062
2063/// `shell` and `mcp` are handled by the binary, and never reach the table.
2064///
2065/// They are in [`super::COMMANDS`] so that `inillucent help` lists them and so
2066/// that the parity test can assert their `cli_only` reason exists. Calling one
2067/// through the table is a mistake in a front end rather than in a request, and
2068/// it says so.
2069///
2070/// @param name - which of the two was reached
2071fn front_end_only(name: &'static str) -> Failed {
2072    Failed::misuse(format!(
2073        "'{name}' is run by the inillucent binary itself and cannot be dispatched here."
2074    ))
2075}
2076
2077/// The stand-in for `shell`.
2078pub fn shell_placeholder(
2079    _context: &mut Context,
2080    _arguments: &Arguments,
2081) -> Result<Outcome, Failed> {
2082    Err(front_end_only("shell"))
2083}
2084
2085/// The stand-in for `mcp`.
2086pub fn mcp_placeholder(_context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
2087    Err(front_end_only("mcp"))
2088}
2089
2090#[cfg(test)]
2091mod source_tests {
2092    use super::*;
2093    use crate::shell::Shell;
2094
2095    /// Serializes the cases that move `INILLUCENT_SOURCE_URL`.
2096    ///
2097    /// An environment variable is process-wide and `cargo test` runs the cases in
2098    /// one binary on several threads, so two of these racing is not a
2099    /// possibility - it is what happens. Three of the five below set the
2100    /// variable and three clear it, and a run of the five on their own failed
2101    /// four times out of five: a case that had just set the variable read the
2102    /// empty value another case had cleared, and the failure named the refusal
2103    /// rather than the race.
2104    ///
2105    /// The lock is poison-tolerant on purpose. A case that panics with it held
2106    /// has already failed and reported why; turning that into a second failure
2107    /// in every later case would bury the message that matters.
2108    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2109        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2110        LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
2111    }
2112
2113    /// Returns a surface, confined or not.
2114    ///
2115    /// @param root - the directory to confine to, when the case wants one
2116    fn context(root: Option<std::path::PathBuf>) -> Context {
2117        Context::for_test(
2118            Shell::open(":memory:").expect("a memory database opens"),
2119            root,
2120        )
2121    }
2122
2123    /// The argument is used when there is one, and it is not `-`.
2124    #[test]
2125    fn an_argument_is_the_source() {
2126        let context = context(None);
2127        let held = resolve_source(&context, Some("postgres://user@host/db"))
2128            .expect("the argument is accepted");
2129        assert_eq!(held, "postgres://user@host/db");
2130    }
2131
2132    /// With no argument, the source comes from the environment.
2133    ///
2134    /// **Which is what this exists for.** A connection URL holds a password
2135    /// and an argument is in the process list for the whole run.
2136    #[test]
2137    fn the_environment_supplies_a_source_that_was_not_an_argument() {
2138        let _held = env_guard();
2139        let context = context(None);
2140        std::env::set_var(SOURCE_URL_VARIABLE, "postgres://user:secret@host/db");
2141        let held = resolve_source(&context, None).expect("the variable is read");
2142        std::env::remove_var(SOURCE_URL_VARIABLE);
2143        assert_eq!(held, "postgres://user:secret@host/db");
2144    }
2145
2146    /// With nothing anywhere, the refusal names all three ways to say it.
2147    #[test]
2148    fn no_source_anywhere_is_refused_by_name() {
2149        let _held = env_guard();
2150        let context = context(None);
2151        std::env::remove_var(SOURCE_URL_VARIABLE);
2152        let error = resolve_source(&context, None).expect_err("there is no source");
2153        let said = format!("{error:?}");
2154        assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2155        assert!(said.contains("standard input"), "{said}");
2156    }
2157
2158    /// A confined surface refuses `-` rather than reading its own transport.
2159    ///
2160    /// `--root` is how this command table is handed to an agent over MCP, and
2161    /// MCP speaks on standard input: a verb that read a line from it there
2162    /// would consume the transport rather than a URL.
2163    #[test]
2164    fn a_confined_surface_refuses_to_read_standard_input() {
2165        let _held = env_guard();
2166        let context = context(Some(std::env::temp_dir()));
2167        std::env::remove_var(SOURCE_URL_VARIABLE);
2168        let error = resolve_source(&context, Some("-")).expect_err("a confined surface refuses");
2169        let said = format!("{error:?}");
2170        assert!(said.contains("--root"), "{said}");
2171        assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2172    }
2173
2174    /// Export refuses an ambiguous request before reading either data source.
2175    #[test]
2176    fn export_refuses_table_and_sql_together() {
2177        let mut arguments = Arguments::default();
2178        arguments.set("table", crate::json::text("expected"));
2179        arguments.set("sql", crate::json::text("SELECT 'other' AS v"));
2180        let failure = export(&mut context(None), &arguments)
2181            .expect_err("export must require one data source");
2182        assert!(failure.message.contains("not both"), "{}", failure.message);
2183    }
2184
2185    /// `-` with the variable set takes the variable and never touches stdin.
2186    ///
2187    /// The order matters: a caller that scripts `-` and also exports the
2188    /// variable should not block on a transport nobody is writing to.
2189    #[test]
2190    fn the_environment_wins_over_reading_standard_input() {
2191        let _held = env_guard();
2192        let context = context(None);
2193        std::env::set_var(SOURCE_URL_VARIABLE, "mysql://user@host/db");
2194        let held = resolve_source(&context, Some("-")).expect("the variable is read");
2195        std::env::remove_var(SOURCE_URL_VARIABLE);
2196        assert_eq!(held, "mysql://user@host/db");
2197    }
2198}