Skip to main content

inillucent_cli/command/
mod.rs

1//! The command table: one array, read by every front end.
2//!
3//! Invariant: **a command exists once.** [`COMMANDS`] holds its name, its
4//! summary, the parameters it takes and the function that runs it, and the
5//! three front ends read that array rather than each carrying a list:
6//!
7//! - `inillucent <verb>` builds its usage and its argument parsing from it;
8//! - `inillucent-mcp` builds `tools/list` and its JSON Schemas from it;
9//! - `inillucent help` prints it.
10//!
11//! `crates/inillucent-compat/tests/tooling/command_parity.rs` fails the build if a
12//! command loses its description, if a parameter loses one, if a command is
13//! hidden from MCP without a stated reason, or if the two surfaces stop naming
14//! the same set. That test is the whole point of the arrangement: this
15//! repository already argues, in `drivers/README.md`, that a capability list
16//! nobody runs decays into a list of claims that were true once, and a command
17//! list is the same kind of claim.
18//!
19//! **Everything here goes through [`crate::shell::Shell`].** The shell is
20//! already an adapter over the public facade, and the 416-case differential
21//! probe covers that path. A command table that reached past it to the engine
22//! would be a second path to the same data, answering slightly differently, and
23//! nobody would find out from the tests that exist.
24
25pub mod outcome;
26pub mod verbs;
27
28use std::path::PathBuf;
29use std::sync::Arc;
30
31use inillucent_driver::vfs::confine::{self, Root};
32use inillucent_driver::Status;
33
34use crate::json::Json;
35use crate::shell::Shell;
36
37pub use outcome::{Column, Failed, Outcome};
38
39/// What kind of value a parameter takes.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Kind {
42    /// A string.
43    Text,
44    /// A whole number.
45    Integer,
46    /// True or false.
47    Boolean,
48    /// An array of SQL values, for binding to `?1`, `?2`, ...
49    Values,
50}
51
52impl Kind {
53    /// Returns the JSON Schema type an MCP client is told to send.
54    pub fn schema_type(self) -> &'static str {
55        match self {
56            Kind::Text => "string",
57            Kind::Integer => "integer",
58            Kind::Boolean => "boolean",
59            Kind::Values => "array",
60        }
61    }
62
63    /// Returns whether a JSON value has this parameter kind.
64    ///
65    /// @param value - the value a client supplied
66    pub fn accepts(self, value: &Json) -> bool {
67        match self {
68            Kind::Text => matches!(value, Json::Text(_)),
69            Kind::Integer => value.integer().is_some(),
70            Kind::Boolean => matches!(value, Json::Bool(_)),
71            // **An array and an object are values too (task-1979, section 8.2,
72            // gap 2, and D15).** A nested array of numbers is a vector and
73            // `{"blob": "<hex>"}` is bytes; neither had a spelling at all, so a
74            // caller binding into a `VECTOR(N)` column or binding a byte string
75            // had to build a hex literal itself. The command line accepted both
76            // once `literal_of` learned them and this did not, so the two
77            // surfaces disagreed about the same JSON.
78            Kind::Values => value.array().is_some_and(|items| {
79                items.iter().all(|item| match item {
80                    Json::Null | Json::Bool(_) | Json::Int(_) | Json::Real(_) | Json::Text(_) => {
81                        true
82                    }
83                    Json::Array(numbers) => numbers
84                        .iter()
85                        .all(|number| matches!(number, Json::Int(_) | Json::Real(_))),
86                    Json::Object(fields) => {
87                        fields.len() == 1
88                            && fields.iter().all(|(name, value)| {
89                                name == "blob" && matches!(value, Json::Text(_))
90                            })
91                    }
92                })
93            }),
94        }
95    }
96}
97
98/// One parameter a command takes.
99#[derive(Debug, Clone, Copy)]
100pub struct Param {
101    /// The name, which is the MCP property name and the CLI's `--name`.
102    pub name: &'static str,
103    /// What kind of value it takes.
104    pub kind: Kind,
105    /// Whether the command refuses without it.
106    pub required: bool,
107    /// Whether it can be given as a bare word on the command line.
108    ///
109    /// At most one positional per command, and it is always the first one: a
110    /// command line with two unnamed arguments is one nobody can read back.
111    pub positional: bool,
112    /// What it is for, in one sentence.
113    ///
114    /// **This is what the model reads.** A parameter whose description says
115    /// "the table" tells an agent nothing it could not guess; one that says
116    /// which name form is expected saves a failed call. The parity test refuses
117    /// an empty one.
118    pub description: &'static str,
119}
120
121/// What a command does to the database, for the two questions that asks.
122///
123/// **Three states rather than two, because `run` is neither** (task-2066
124/// section 4.2, item 26). The flag this replaces answered one question with
125/// one bit and two different callers read it: `--readonly` refuses a command
126/// that writes, and a command that writes creates the file it was pointed at.
127/// `run` drives the shell, so it may do either, and marking it `true` refused
128/// `inillucent --readonly run "SELECT count(*) FROM t;"` and the
129/// `inillucent_run` MCP tool with it - while marking it `false` would stop
130/// `inillucent --db new.rdb run ".read schema.sql"` from making the file.
131///
132/// The shell it drives already refuses a write statement by statement when it
133/// is read only, from `inillucent_driver::readonly::admits`, which is the same
134/// classification `Context::refuse_if_it_writes` and the driver use. So `run`
135/// needs the verb gate to stand aside and let that refusal happen, which is
136/// the third state.
137#[derive(Clone, Copy, PartialEq, Eq, Debug)]
138pub enum Writes {
139    /// It only reads. `--readonly` admits it and it never creates a file.
140    No,
141    /// It changes the database. `--readonly` refuses it by name.
142    Yes,
143    /// It may change the database, and refuses each statement that does.
144    ///
145    /// `--readonly` admits the verb and the shell underneath refuses the
146    /// writes, one statement at a time, with "attempt to write a readonly
147    /// database".
148    PerStatement,
149}
150
151impl Writes {
152    /// Whether `--readonly` refuses this command before it runs.
153    pub fn refused_when_read_only(self) -> bool {
154        self == Writes::Yes
155    }
156
157    /// Whether this command may create the database file it was pointed at.
158    ///
159    /// A read verb does not make the file it was pointed at, so
160    /// `inillucent --db typo.rdb tables` reports a missing database rather
161    /// than leaving an empty one behind. `run` may, because
162    /// `sqlite3 new.db ".read schema.sql"` does.
163    pub fn may_create(self) -> bool {
164        self != Writes::No
165    }
166}
167
168/// One command.
169pub struct Command {
170    /// The verb, as `inillucent <name>` and as `inillucent_<name>`.
171    pub name: &'static str,
172    /// One line, shown in the command list and used as the MCP description.
173    pub summary: &'static str,
174    /// The longer explanation, shown by `inillucent help <name>` and appended
175    /// to the MCP description so a model reads the same thing a person does.
176    pub detail: &'static str,
177    /// What it takes.
178    pub params: &'static [Param],
179    /// Why it is not offered over MCP, when it is not.
180    pub cli_only: Option<&'static str>,
181    /// Whether it can change the database, and what a read only surface does
182    /// about it.
183    pub writes: Writes,
184    /// What it does.
185    pub run: fn(&mut Context, &Arguments) -> Result<Outcome, Failed>,
186}
187
188impl Command {
189    /// Returns this command's parameter of a given name.
190    ///
191    /// @param name - the parameter name
192    pub fn param(&self, name: &str) -> Option<&'static Param> {
193        self.params.iter().find(|param| param.name == name)
194    }
195
196    /// Returns the parameter that may be written without its name.
197    pub fn positional(&self) -> Option<&'static Param> {
198        self.params.iter().find(|param| param.positional)
199    }
200
201    /// Returns the usage line the CLI prints for this command.
202    pub fn usage(&self) -> String {
203        let mut line = format!("inillucent {}", self.name);
204        for param in self.params {
205            let form = match (param.positional, param.required) {
206                (true, true) => format!(" <{}>", param.name),
207                (true, false) => format!(" [{}]", param.name),
208                (false, true) => format!(" --{} <{}>", param.name, param.name),
209                (false, false) => format!(" [--{} <{}>]", param.name, param.name),
210            };
211            line.push_str(&form);
212        }
213        line
214    }
215
216    /// Returns the finite text values a parameter accepts when it has any.
217    ///
218    /// @param name - the parameter name
219    pub fn allowed_values(&self, name: &str) -> Option<&'static [&'static str]> {
220        match (self.name, name) {
221            (_, "output") => Some(&["text", "json"]),
222            ("import", "format") => Some(&["csv", "tabs", "ascii"]),
223            ("export", "format") => Some(&[
224                "csv", "json", "tabs", "markdown", "insert", "quote", "line", "html",
225            ]),
226            _ => None,
227        }
228    }
229}
230
231/// The values a command was given.
232#[derive(Debug, Clone, Default)]
233pub struct Arguments {
234    /// Each name and what was passed under it.
235    values: Vec<(String, Json)>,
236}
237
238impl Arguments {
239    /// Builds an argument set from an MCP `arguments` object.
240    ///
241    /// @param command - the command that declares the accepted arguments
242    /// @param object - the object the client sent
243    pub fn from_json(command: &Command, object: &Json) -> Result<Arguments, Failed> {
244        let Json::Object(pairs) = object else {
245            return Err(Failed::misuse("tool arguments must be an object."));
246        };
247        for (name, value) in pairs {
248            let Some(param) = command.param(name) else {
249                return Err(Failed::misuse(format!(
250                    "'{}' has no '{name}' argument.",
251                    command.name
252                )));
253            };
254            if !param.kind.accepts(value) {
255                return Err(Failed::misuse(format!(
256                    "'{name}' has to be a {}.",
257                    param.kind.schema_type()
258                )));
259            }
260            if let Some(allowed) = command.allowed_values(name) {
261                let Some(text) = value.text() else {
262                    return Err(Failed::misuse(format!("'{name}' has to be text.")));
263                };
264                if !allowed.contains(&text) {
265                    return Err(Failed::misuse(format!(
266                        "'{name}' must be one of: {}.",
267                        allowed.join(", ")
268                    )));
269                }
270            }
271        }
272        for param in command.params {
273            if param.required && !pairs.iter().any(|(name, _)| name == param.name) {
274                return Err(Failed::misuse(format!("'{}' is required.", param.name)));
275            }
276        }
277        Ok(Arguments {
278            values: pairs.clone(),
279        })
280    }
281
282    /// Records one value.
283    ///
284    /// @param name - the parameter
285    /// @param value - what was given
286    pub fn set(&mut self, name: &str, value: Json) {
287        self.values.retain(|(held, _)| held != name);
288        self.values.push((name.to_string(), value));
289    }
290
291    /// Returns what was given under a name.
292    ///
293    /// @param name - the parameter
294    pub fn get(&self, name: &str) -> Option<&Json> {
295        self.values
296            .iter()
297            .find(|(held, _)| held == name)
298            .map(|(_, value)| value)
299    }
300
301    /// Returns a text parameter, if it was given as text.
302    ///
303    /// @param name - the parameter
304    pub fn text(&self, name: &str) -> Option<&str> {
305        self.get(name).and_then(Json::text)
306    }
307
308    /// Returns a text parameter, or the failure for having left it out.
309    ///
310    /// @param name - the parameter
311    pub fn required_text(&self, name: &str) -> Result<&str, Failed> {
312        match self.get(name) {
313            Some(Json::Text(text)) => Ok(text),
314            Some(_) => Err(Failed::misuse(format!("'{name}' has to be a string."))),
315            None => Err(Failed::misuse(format!("'{name}' is required."))),
316        }
317    }
318
319    /// Returns an integer parameter.
320    ///
321    /// @param name - the parameter
322    pub fn integer(&self, name: &str) -> Option<i64> {
323        self.get(name).and_then(Json::integer)
324    }
325
326    /// Returns a boolean parameter, treating an absent one as false.
327    ///
328    /// @param name - the parameter
329    pub fn flag(&self, name: &str) -> bool {
330        self.get(name).and_then(Json::boolean).unwrap_or(false)
331    }
332
333    /// Returns the array a `values` parameter carries.
334    ///
335    /// @param name - the parameter
336    pub fn values(&self, name: &str) -> Vec<Json> {
337        self.get(name)
338            .and_then(Json::array)
339            .map(<[Json]>::to_vec)
340            .unwrap_or_default()
341    }
342}
343
344/// Where a command runs: the database, and the limits placed on it.
345pub struct Context {
346    /// The shell every command drives.
347    shell: Shell,
348    /// The log segments beside the file that its chain cannot reach.
349    ///
350    /// Read once at the open. See [`Context::strays_beside`] for why the moment
351    /// matters.
352    strays: Vec<u64>,
353    /// The file it is open on.
354    path: String,
355    /// Whether a statement that changes anything is refused.
356    readonly: bool,
357    /// The directory outside which no path may be named.
358    ///
359    /// The same [`Root`] the whole process is confined to, so a refusal a
360    /// person reads and a refusal the file system enforces are one decision
361    /// rather than two that can disagree.
362    root: Option<Arc<Root>>,
363    /// How many rows a command hands back when it was not told.
364    pub limit: usize,
365    /// The most rows one call may hand back, when this surface has a ceiling.
366    max_rows: Option<usize>,
367    /// What one command may spend inside the engine.
368    ///
369    /// Different from `max_rows`, and both are needed. `max_rows` bounds what a
370    /// call *hands back*; this bounds what the engine does on the way there, so
371    /// a `SELECT` whose `WHERE` rejects everything after scanning a hundred
372    /// million rows still stops. A row ceiling alone would let that run to the
373    /// end and then report zero rows.
374    limits: inillucent_driver::StatementLimits,
375    /// Whether arming a budget clears the cancellation flag first.
376    ///
377    /// True everywhere but the MCP server, which reads its input on a second
378    /// thread and therefore owns the ordering itself; see
379    /// `budget::arm_as_it_stands` (task-1932, H11).
380    preserve_cancel: std::cell::Cell<bool>,
381    /// The flag that stops whatever this session is running.
382    ///
383    /// **One per session, not one per call (task-1932, H11).** `run` used to
384    /// arm the budget with a fresh `AtomicBool` it dropped on the way out, so
385    /// the flag the executor polled every batch was one nothing else in the
386    /// process had a handle to: `Connection::cancel` in the driver was correct
387    /// and unreachable, and an MCP `notifications/cancelled` had nothing to
388    /// set. Handing out a clone of this is what makes a cancel arriving from
389    /// another thread land on the statement that is running.
390    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
391    /// What to print where a value is null.
392    pub null: String,
393}
394
395/// Whether a command surface may write to the database it opens.
396///
397/// **An enum rather than a bare `bool` (task-1962, A9).** `Context::open(path,
398/// true, root)` at a call site says nothing about what the `true` decides, and
399/// the surface it opens is the one an operator reaches for when they want to be
400/// certain nothing is written.
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub enum OpenMode {
403    /// Statements that write are refused.
404    ReadOnly,
405    /// The ordinary surface.
406    ReadWrite,
407}
408
409impl OpenMode {
410    /// Reads the `--readonly` flag a command surface was invoked with.
411    ///
412    /// @param readonly - whether the flag was given
413    pub fn of(readonly: bool) -> OpenMode {
414        if readonly {
415            OpenMode::ReadOnly
416        } else {
417            OpenMode::ReadWrite
418        }
419    }
420}
421
422impl Context {
423    /// Opens a context on a database.
424    ///
425    /// @param path - the file, or an in-memory name
426    /// @param readonly - whether writes are refused
427    /// @param root - the directory paths are confined to, if any
428    pub fn open(path: &str, mode: OpenMode, root: Option<PathBuf>) -> Result<Context, Failed> {
429        Context::open_for(path, mode, root, true)
430    }
431
432    /// Opens a database for one command, which may or may not make the file.
433    ///
434    /// @param path - the database to open
435    /// @param mode - whether writes are refused
436    /// @param root - the directory every file is confined to, when there is one
437    /// @param may_create - whether this caller is allowed to make the file
438    pub fn open_for(
439        path: &str,
440        mode: OpenMode,
441        root: Option<PathBuf>,
442        may_create: bool,
443    ) -> Result<Context, Failed> {
444        let readonly = mode == OpenMode::ReadOnly;
445        // **The confinement is installed before the first file is opened.**
446        // The database this surface starts on is a path like any other, and
447        // installing the root afterwards would exempt exactly the one path an
448        // operator is most likely to have got wrong. It also puts the root
449        // where the VFS can see it, which is what confines every file the
450        // engine opens later without this module having to name them.
451        let root = match root {
452            None => None,
453            Some(directory) => {
454                confine::confine_process(&directory).map_err(|error| {
455                    Failed::said(Status::InvalidState, error.detail().to_string())
456                })?;
457                confine::process_root()
458            }
459        };
460        let opened = match &root {
461            Some(root) => root
462                .admit(path)
463                .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))?
464                .to_string_lossy()
465                .into_owned(),
466            None => path.to_string(),
467        };
468        // **A read verb does not make the file it was pointed at (task-1979,
469        // E2).** `inillucent --db typo.rdb tables` used to create `typo.rdb`,
470        // write a log segment beside it, print an empty table and exit 0 - so a
471        // mistyped path answered "this database has no tables" and left a file
472        // behind that the next command would then open happily.
473        //
474        // **A verb that writes still makes one**, because that is what
475        // `sqlite3 new.db "CREATE TABLE ..."` does and what every script that
476        // sets a database up in one line expects. `Command::writes` is the same
477        // flag `--readonly` refuses on, so the two questions have one answer.
478        if !may_create
479            && !opened.is_empty()
480            && opened != ":memory:"
481            && !std::path::Path::new(&opened).exists()
482        {
483            return Err(Failed::said(
484                Status::NotFound,
485                format!(
486                    "there is no database at \"{opened}\". `inillucent create {opened}` makes one."
487                ),
488            ));
489        }
490        // **The engine's own status, not `io` for everything.** A file another
491        // process holds is `busy`, which a caller can act on by retrying; a
492        // file that is not a database of this engine is `corrupt`. Reporting
493        // both as `io` told a script nothing (task-1979, C6).
494        let mut shell = Shell::open_reporting(&opened, readonly).map_err(|error| {
495            let said = Failed::from_engine(&error);
496            Failed::said(
497                said.status,
498                format!("could not open \"{opened}\": {}", said.message),
499            )
500        })?;
501        // **`--root` turns safe mode on, because otherwise it does not confine
502        // anything (task-1979, H1).** A caller that names a directory has said
503        // the program may touch that directory and nothing else; a `.shell` or
504        // `.system` that spawns `cmd /C` walks straight past that, and the
505        // reviewer's run wrote a file outside the root through the `run` verb
506        // and exited 0. The MCP server turns it on whether or not a root was
507        // given - see `Context::refuse_the_world`.
508        shell.safe = root.is_some();
509        // Read once, here, while the chain this open recovered is still the one
510        // on the disk - see `Context::strays_beside`.
511        let reported = shell.recovery();
512        let strays = Context::strays_beside(&opened, reported.last_sequence, reported.last_lsn);
513        Ok(Context {
514            shell,
515            strays,
516            path: opened,
517            readonly,
518            root,
519            limit: 200,
520            max_rows: None,
521            limits: inillucent_driver::StatementLimits::unbounded(),
522            preserve_cancel: std::cell::Cell::new(false),
523            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
524            null: String::new(),
525        })
526    }
527
528    /// Points this context at a different file, reopening only if it moved.
529    ///
530    /// **One open database at a time, and that is a decision rather than a
531    /// simplification.** One file is one buffer pool, and a server holding four
532    /// of them holds four pools whose sizes nobody asked about. A caller that
533    /// alternates pays a reopen; a caller that does not pays nothing.
534    ///
535    /// @param path - the file to use
536    pub fn use_database(&mut self, path: &str) -> Result<(), Failed> {
537        if path == self.path {
538            return Ok(());
539        }
540        let confined = self.confine(path)?;
541        let named = confined.to_string_lossy().into_owned();
542        self.shell.reopen(&named).map_err(|message| {
543            Failed::said(Status::Io, format!("could not open \"{named}\": {message}"))
544        })?;
545        self.path = named;
546        Ok(())
547    }
548
549    /// Refuses every shell command that reaches outside the database.
550    ///
551    /// **Always on over MCP, and there is no flag to turn it off (task-1979,
552    /// H1).** The set is the reference's `-safe`: running a program
553    /// (`.shell`, `.system`), loading a shared library (`.load`), changing the
554    /// working directory (`.cd`), handing a file to whatever the system opens
555    /// it with (`.excel`, `.www`), and writing output through a pipe. An MCP
556    /// server that left it off handed an agent a shell on the host, whatever
557    /// `--root` said - and a child spawned that way inherits the server's
558    /// standard output, which is the JSON-RPC channel, so its output landed in
559    /// the middle of a reply and the client could not match one to its id.
560    ///
561    /// A flag was the alternative and was rejected: an operator who forgets it
562    /// hands an agent a shell, and there is no case for an MCP server with safe
563    /// mode off.
564    pub fn refuse_the_world(&mut self) {
565        self.shell.safe = true;
566    }
567
568    /// Returns what opening this context's database did to it.
569    ///
570    /// See [`Outcome::with_recovery`] for what the command surface does with
571    /// it.
572    pub fn recovery(&self) -> inillucent_driver::Recovery {
573        self.shell.recovery()
574    }
575
576    /// Returns the log segments beside this database that its chain cannot
577    /// reach.
578    ///
579    /// **A file nothing will replay and nothing will remove (task-1979, C9).**
580    /// A segment copied or restored at a sequence past the live one is ignored
581    /// by recovery, which is right - the chain is followed by sequence from the
582    /// meta record and stops at the first gap - and was also never mentioned,
583    /// so it sat beside the database through every open and close.
584    ///
585    /// **Read once, at the open, and the moment matters.** `truncate_after` has
586    /// just deleted every segment above where the chain stopped that it could
587    /// walk to, so what is left above a gap is what nothing will ever reach.
588    /// Asking again later would answer differently for a reason that is not
589    /// damage: this connection rolls a segment on every checkpoint, another
590    /// process rolls its own, and retiring the ones below a new recovery point
591    /// leaves the directory with gaps that are simply the log moving on.
592    ///
593    /// The directory is read here rather than in the engine because
594    /// `inillucent_vfs::Vfs` has no listing and the command surface is where
595    /// reading a directory already happens. What a name means is
596    /// `inillucent_wal::segment::sequence_of_segment_name`, reached through the
597    /// engine's own re-export, so the naming convention stays in the crate that
598    /// writes it.
599    ///
600    /// @param path - the database file
601    /// @param reaches - the sequence this open's recovery stopped in
602    /// @param ended_at - the stream position it stopped at
603    fn strays_beside(path: &str, reaches: u64, ended_at: u64) -> Vec<u64> {
604        let path = std::path::Path::new(path);
605        let (Some(directory), Some(stem)) = (path.parent(), path.file_name()) else {
606            return Vec::new();
607        };
608        let stem = stem.to_string_lossy().into_owned();
609        let Ok(entries) = std::fs::read_dir(directory) else {
610            return Vec::new();
611        };
612        let mut present: Vec<u64> = entries
613            .flatten()
614            .filter_map(|entry| {
615                let name = entry.file_name().to_string_lossy().into_owned();
616                inillucent_driver::log::sequence_of_segment_name(&stem, &name)
617            })
618            .collect();
619        present.sort_unstable();
620        // **Above the chain's end AND holding positions it has already passed.**
621        // The sequence on its own is not enough: another process writing this
622        // same database rolls to the next one and retires the one below it, so
623        // a live log leaves exactly the shape a leftover does - a number above
624        // this connection's own with a gap under it. What tells them apart is
625        // where the records start. A segment a writer rolled to begins at or
626        // above where this chain ended; a copy of an older segment begins
627        // below it, which means nothing above it will ever read it.
628        present.retain(|sequence| {
629            *sequence > reaches
630                && Context::first_record_of(directory, &stem, *sequence)
631                    .is_some_and(|first| first < ended_at)
632        });
633        present
634    }
635
636    /// Returns where one segment file's records start.
637    ///
638    /// @param directory - the directory the database is in
639    /// @param stem - the database file's name
640    /// @param sequence - which segment
641    fn first_record_of(directory: &std::path::Path, stem: &str, sequence: u64) -> Option<u64> {
642        let name = format!("{stem}-wal.{sequence:010}");
643        let head = std::fs::read(directory.join(name)).ok()?;
644        inillucent_driver::log::first_lsn_of(&head)
645    }
646
647    /// Returns the strays this context's open found. See
648    /// [`Context::strays_beside`].
649    pub fn stray_log_segments(&self) -> &[u64] {
650        &self.strays
651    }
652
653    /// Returns a handle to this session's cancellation flag.
654    ///
655    /// Setting it stops the statement that is running, at the next batch. It is
656    /// cleared when the next command is armed, so a cancel that arrives between
657    /// two calls belongs to the one that has finished and is discarded rather
658    /// than applied to the one that has not started.
659    pub fn cancel_flag(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
660        std::sync::Arc::clone(&self.cancel)
661    }
662
663    /// Says that this surface clears the cancellation flag itself.
664    ///
665    /// Only `inillucent-mcp` does, because it is the only one that reads its
666    /// input on a second thread. See `budget::arm_as_it_stands`.
667    pub fn preserve_cancellation(&self) {
668        self.preserve_cancel.set(true);
669    }
670
671    /// Returns the shell commands drive.
672    pub fn shell(&mut self) -> &mut Shell {
673        &mut self.shell
674    }
675
676    /// Returns the file this context is open on.
677    pub fn path(&self) -> &str {
678        &self.path
679    }
680
681    /// Returns whether writes are refused.
682    pub fn readonly(&self) -> bool {
683        self.readonly
684    }
685
686    /// Refuses a row count past this surface's ceiling, when it has one.
687    ///
688    /// **The command line has no ceiling and the MCP server does**, which is
689    /// the whole distinction: a person running `inillucent query` against their
690    /// own database and asking for every row is asking for what they want, and
691    /// an agent doing the same thing to a served database is the case `--root`
692    /// and `--readonly` already exist for. Zero means every row and is refused
693    /// where a ceiling is set, because "every row" is precisely the request the
694    /// ceiling is about.
695    ///
696    /// @param asked - the row count the caller wants
697    pub fn cap_rows(&self, asked: usize) -> Result<usize, Failed> {
698        let Some(most) = self.max_rows else {
699            return Ok(asked);
700        };
701        match asked {
702            0 => Err(Failed::said(
703                Status::InvalidState,
704                format!(
705                    "limit=0 asks for every row, and this server hands back at most {most}. Ask \
706                     for a count, or narrow the query."
707                ),
708            )),
709            asked if asked > most => Err(Failed::said(
710                Status::InvalidState,
711                format!("limit={asked} is past the {most} rows this server hands back."),
712            )),
713            asked => Ok(asked),
714        }
715    }
716
717    /// Sets the ceiling on how many rows one call hands back.
718    ///
719    /// @param most - the ceiling, or `None` for the command line's absence of one
720    pub fn set_max_rows(&mut self, most: Option<usize>) {
721        self.max_rows = most;
722    }
723
724    /// Sets what one command may spend inside the engine.
725    ///
726    /// @param limits - the budget, or `Limits::unbounded` for a command line
727    pub fn set_limits(&mut self, limits: inillucent_driver::StatementLimits) {
728        self.limits = limits;
729    }
730
731    /// Returns what one command on this surface may spend inside the engine.
732    ///
733    /// A verb that runs work outside the executor - a migration reads a remote
734    /// server and writes rows through a second connection - asks so that it can
735    /// put itself under the same ceiling rather than beside it.
736    pub fn limits(&self) -> inillucent_driver::StatementLimits {
737        self.limits.clone()
738    }
739
740    /// Returns whether this surface was confined to a directory.
741    ///
742    /// **Confinement is about reach, not only about paths.** `--root` exists so
743    /// that an MCP server can be handed to an agent without handing it the file
744    /// system, and a verb that dialled a host and a port would be a hole
745    /// straight through it. A command that can reach something other than a
746    /// file asks this and refuses.
747    pub fn confined(&self) -> bool {
748        self.root.is_some()
749    }
750
751    /// Returns a surface over a shell a test already opened.
752    ///
753    /// Here rather than in each test module because `Context`'s fields are
754    /// private to this module, and a test that reached into them would be a
755    /// second definition of what a surface is.
756    ///
757    /// @param shell - the shell to drive
758    /// @param root - the directory to confine to, when there is one
759    #[cfg(test)]
760    pub fn for_test(shell: Shell, root: Option<PathBuf>) -> Context {
761        Context {
762            shell,
763            strays: Vec::new(),
764            path: ":memory:".to_string(),
765            readonly: false,
766            // A test builds its own root rather than installing a process-wide
767            // one: the process root is set once for the life of the process,
768            // and a test that installed it would decide the confinement of
769            // every other test in the binary.
770            root: root.map(|directory| {
771                Arc::new(Root::resolved(confine::resolve_through_links(&directory)))
772            }),
773            limit: 200,
774            max_rows: None,
775            limits: inillucent_driver::StatementLimits::unbounded(),
776            preserve_cancel: std::cell::Cell::new(false),
777            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
778            null: String::new(),
779        }
780    }
781
782    /// Refuses a path outside the root, when a root was set.
783    ///
784    /// **The decision is not made here.** It is made by
785    /// `inillucent_vfs::confine`, which resolves the path through the file
786    /// system rather than reading its text, and which the VFS consults again
787    /// at the moment the file is opened. This method exists so that a person
788    /// reading a refusal is told the path they typed and the directory they
789    /// confined to, neither of which survives as far as the VFS.
790    ///
791    /// The check this replaced compared normalised path text against the root.
792    /// A junction below the root passed it and opened a database outside the
793    /// root.
794    ///
795    /// @param path - the path a caller named
796    pub fn confine(&self, path: &str) -> Result<PathBuf, Failed> {
797        let Some(root) = &self.root else {
798            return Ok(PathBuf::from(path));
799        };
800        root.admit(path)
801            .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))
802    }
803
804    /// Refuses a statement that changes something, when read-only.
805    ///
806    /// **By the statement's class, in one place shared with the driver
807    /// (task-1979, section 5.2).** The check this replaces asked the engine to
808    /// `EXPLAIN` the statement and refused only when the message contained
809    /// "not a read-only statement" - text `compile_explain` never produces for
810    /// an `INSERT`, a write pragma, an `ATTACH` or a `VACUUM INTO`, all of
811    /// which therefore ran and persisted through `--readonly`. The class comes
812    /// from the parser's own `classify_statement`; the pragma lists are in
813    /// `inillucent_driver::readonly` so this and the driver cannot disagree.
814    ///
815    /// This is the layer that gives the caller a good message. The layer that
816    /// makes the property true is the commit path, which refuses a write on a
817    /// connection opened read only whatever reached it.
818    ///
819    /// @param sql - the statement
820    pub fn refuse_if_it_writes(&self, sql: &str) -> Result<(), Failed> {
821        if !self.readonly {
822            return Ok(());
823        }
824        match inillucent_driver::readonly::admits(sql) {
825            true => Ok(()),
826            false => Err(Failed::said(
827                Status::ReadOnly,
828                "this connection is read only, and that statement changes something.",
829            )),
830        }
831    }
832
833    /// Runs shell input, collecting everything it printed.
834    ///
835    /// @param input - the lines, dot commands included
836    pub fn collect_output(&mut self, input: &str) -> String {
837        self.shell.sink = Some(String::new());
838        let lines: Vec<String> = input.lines().map(str::to_string).collect();
839        crate::shell::drive(&mut self.shell, lines.into_iter());
840        self.shell.sink.take().unwrap_or_default()
841    }
842}
843
844/// Returns the command of a given name.
845///
846/// @param name - the verb, with or without the `inillucent_` prefix MCP uses
847pub fn find(name: &str) -> Option<&'static Command> {
848    let bare = name.strip_prefix("inillucent_").unwrap_or(name);
849    // A dash reads better on a command line and an underscore is required in an
850    // MCP tool name, so both spellings find the same command rather than one of
851    // them being a mistake a caller has to learn about.
852    let wanted = bare.replace('-', "_");
853    COMMANDS
854        .iter()
855        .find(|command| command.name.replace('-', "_") == wanted)
856}
857
858/// Runs a command, applying the checks every front end shares.
859///
860/// The order is the whole of it: the database is selected first because a
861/// confinement refusal must happen before anything is opened, the read-only
862/// check is second because a refusal is cheaper than a run, and only then does
863/// the command see its arguments.
864///
865/// @param command - what to run
866/// @param context - where to run it
867/// @param arguments - what it was given
868pub fn run(
869    command: &'static Command,
870    context: &mut Context,
871    arguments: &Arguments,
872) -> Result<Outcome, Failed> {
873    if let Some(path) = arguments.text("db") {
874        context.use_database(path)?;
875    }
876    if command.writes.refused_when_read_only() && context.readonly() {
877        return Err(Failed::said(
878            Status::ReadOnly,
879            format!(
880                "'{}' changes the database, and this is read only.",
881                command.name
882            ),
883        ));
884    }
885    for param in command.params {
886        if param.required && arguments.get(param.name).is_none() {
887            return Err(Failed::misuse(format!(
888                "'{}' needs '{}'. Usage: {}",
889                command.name,
890                param.name,
891                command.usage()
892            )));
893        }
894    }
895    let started = std::time::Instant::now();
896    // **Armed here, which is the one place every command on every surface goes
897    // through.** Arming it inside each verb would be arming it in nineteen
898    // places and forgetting it in the twentieth; arming it in the engine would
899    // put a server's policy inside a library an application also links.
900    let armed = match context.preserve_cancel.get() {
901        true => inillucent_driver::arm_as_it_stands(context.limits.clone(), context.cancel_flag()),
902        false => inillucent_driver::arm(context.limits.clone(), context.cancel_flag()),
903    };
904    let outcome = (command.run)(context, arguments);
905    drop(armed);
906    let mut produced = outcome?;
907    if produced.elapsed_ms == 0.0 {
908        produced.elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
909    }
910    Ok(produced)
911}
912
913/// The parameter every command takes, so a caller can name the file per call.
914const DB: Param = Param {
915    name: "db",
916    kind: Kind::Text,
917    required: false,
918    positional: false,
919    description: "The database file to run against. Defaults to the one this process was started \
920                  on, or :memory: for a scratch database that is discarded when the process ends.",
921};
922
923/// The parameter the row-producing commands take.
924const LIMIT: Param = Param {
925    name: "limit",
926    kind: Kind::Integer,
927    required: false,
928    positional: false,
929    description:
930        "How many rows to hand back. The count in 'total' is still exact, and 'more' says \
931                  whether anything was cut off. Defaults to 200. Over MCP there is a ceiling \
932                  of 10000 rows and 0 (every row) is refused; on the command line there is \
933                  neither. A negative number is refused on both.",
934};
935
936/// The parameter that switches between the two renderings.
937///
938/// **Called `output` and not `format`, because `export` already has a
939/// `format`** - and that one means CSV against JSON against Markdown, which is
940/// a different question from whether the *result object* is drawn as a table or
941/// written as JSON. The collision was real rather than theoretical: with both
942/// called `format`, `inillucent export people --format json` was read as "draw
943/// the result object as JSON" and quietly wrote CSV.
944const FORMAT: Param = Param {
945    name: "output",
946    kind: Kind::Text,
947    required: false,
948    positional: false,
949    description: "'text' for an aligned table a person reads, or 'json' for the whole result object, with typed values, exact counts and the failure class. Defaults to text.",
950};
951
952mod registry;
953
954pub use registry::COMMANDS;
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959
960    /// Both spellings of a name find the same command.
961    #[test]
962    fn a_name_is_found_either_way() {
963        assert!(find("query").is_some());
964        assert!(find("inillucent_query").is_some());
965        assert_eq!(
966            find("integrity_check").map(|command| command.name),
967            find("integrity-check").map(|command| command.name)
968        );
969        assert!(find("nonsense").is_none());
970    }
971
972    /// Every command has a summary, a detail, and described parameters.
973    #[test]
974    fn every_command_is_described() {
975        for command in COMMANDS {
976            assert!(
977                !command.summary.is_empty(),
978                "{} has no summary",
979                command.name
980            );
981            assert!(!command.detail.is_empty(), "{} has no detail", command.name);
982            for param in command.params {
983                assert!(
984                    !param.description.is_empty(),
985                    "{}.{} has no description",
986                    command.name,
987                    param.name
988                );
989            }
990        }
991    }
992
993    /// A command has at most one positional parameter, and it is the first.
994    #[test]
995    fn at_most_one_positional_and_it_comes_first() {
996        for command in COMMANDS {
997            let positions: Vec<usize> = command
998                .params
999                .iter()
1000                .enumerate()
1001                .filter(|(_, param)| param.positional)
1002                .map(|(nth, _)| nth)
1003                .collect();
1004            assert!(positions.len() <= 1, "{} has two positionals", command.name);
1005            if let Some(first) = positions.first() {
1006                assert_eq!(*first, 0, "{}'s positional is not first", command.name);
1007            }
1008        }
1009    }
1010
1011    /// No command's name is repeated.
1012    #[test]
1013    fn names_are_unique() {
1014        let mut seen: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
1015        let total = seen.len();
1016        seen.sort_unstable();
1017        seen.dedup();
1018        assert_eq!(seen.len(), total);
1019    }
1020
1021    /// A confinement refuses a path that climbs out of the root.
1022    ///
1023    /// The root here is a directory that exists, because the service resolves
1024    /// a candidate through the file system and a root that is not there would
1025    /// make every case below pass for the wrong reason.
1026    #[test]
1027    fn confinement_refuses_a_path_that_climbs_out() {
1028        let root = std::env::temp_dir().join("inillucent-cli-confine");
1029        std::fs::create_dir_all(&root).unwrap();
1030        let context = Context {
1031            // A unit test builds its own context and has no directory to read.
1032            strays: Vec::new(),
1033            shell: Shell::open(":memory:").unwrap(),
1034            path: ":memory:".to_string(),
1035            readonly: false,
1036            root: Some(Arc::new(Root::at(&root).unwrap())),
1037            limit: 200,
1038            max_rows: None,
1039            limits: inillucent_driver::StatementLimits::unbounded(),
1040            preserve_cancel: std::cell::Cell::new(false),
1041            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1042            null: String::new(),
1043        };
1044        assert!(context.confine("inner/app.rdb").is_ok());
1045        assert!(context.confine("../outside.rdb").is_err());
1046        // **An absolute path for this platform, not for Windows
1047        // (task-1946, M5).** This was `C:/elsewhere/app.rdb`, which is absolute
1048        // on Windows and a directory named `C:` on Linux - so the case that was
1049        // meant to be "somewhere else entirely" resolved *inside* the root
1050        // there, and the assertion that it is refused failed on the first Linux
1051        // run that ever reached it. Refusing it there would have been the real
1052        // defect: a relative path under the root is exactly what confinement
1053        // allows.
1054        let elsewhere = if cfg!(windows) {
1055            "C:/elsewhere/app.rdb"
1056        } else {
1057            "/elsewhere/app.rdb"
1058        };
1059        assert!(context.confine(elsewhere).is_err());
1060        assert!(context.confine(":memory:").is_ok());
1061    }
1062
1063    /// A path that reaches outside the root through a link is refused, and the
1064    /// refusal names where it landed.
1065    ///
1066    /// The unit-level half of `crates/inillucent-compat/tests/e2e/confinement.rs`:
1067    /// that suite proves the shipped binaries refuse it, and this one proves
1068    /// the message a person reads says which of the two things went wrong.
1069    #[test]
1070    fn a_refusal_through_a_link_names_the_target() {
1071        let base = std::env::temp_dir().join("inillucent-cli-confine-link");
1072        let root = base.join("root");
1073        let outside = base.join("outside");
1074        std::fs::create_dir_all(&root).unwrap();
1075        std::fs::create_dir_all(&outside).unwrap();
1076        let link = root.join("escape");
1077        if !link.exists() {
1078            #[cfg(windows)]
1079            let made = std::process::Command::new("cmd")
1080                .args(["/C", "mklink", "/J"])
1081                .arg(&link)
1082                .arg(&outside)
1083                .output()
1084                .map(|produced| produced.status.success())
1085                .unwrap_or(false);
1086            #[cfg(unix)]
1087            let made = std::os::unix::fs::symlink(&outside, &link).is_ok();
1088            if !made {
1089                inillucent_base::testing::skipping("this machine cannot create a symlink here");
1090                return;
1091            }
1092        }
1093        let context = Context {
1094            // A unit test builds its own context and has no directory to read.
1095            strays: Vec::new(),
1096            shell: Shell::open(":memory:").unwrap(),
1097            path: ":memory:".to_string(),
1098            readonly: false,
1099            root: Some(Arc::new(Root::at(&root).unwrap())),
1100            limit: 200,
1101            max_rows: None,
1102            limits: inillucent_driver::StatementLimits::unbounded(),
1103            preserve_cancel: std::cell::Cell::new(false),
1104            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1105            null: String::new(),
1106        };
1107        let failure = context
1108            .confine("escape/app.rdb")
1109            .expect_err("a link out of the root is refused");
1110        assert!(
1111            failure.message.contains("resolves to"),
1112            "the refusal did not say where the path landed: {}",
1113            failure.message
1114        );
1115    }
1116}