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