inillucent-cli 1.0.32

inillucent's command surface: the sqlite3-shaped shell, the verb-shaped CLI, and the MCP server.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
//! The command table: one array, read by every front end.
//!
//! Invariant: **a command exists once.** [`COMMANDS`] holds its name, its
//! summary, the parameters it takes and the function that runs it, and the
//! three front ends read that array rather than each carrying a list:
//!
//! - `inillucent <verb>` builds its usage and its argument parsing from it;
//! - `inillucent-mcp` builds `tools/list` and its JSON Schemas from it;
//! - `inillucent help` prints it.
//!
//! `crates/inillucent-compat/tests/tooling/command_parity.rs` fails the build if a
//! command loses its description, if a parameter loses one, if a command is
//! hidden from MCP without a stated reason, or if the two surfaces stop naming
//! the same set. That test is the whole point of the arrangement: this
//! repository already argues, in `drivers/README.md`, that a capability list
//! nobody runs decays into a list of claims that were true once, and a command
//! list is the same kind of claim.
//!
//! **Everything here goes through [`crate::shell::Shell`].** The shell is
//! already an adapter over the public facade, and the 416-case differential
//! probe covers that path. A command table that reached past it to the engine
//! would be a second path to the same data, answering slightly differently, and
//! nobody would find out from the tests that exist.

pub mod outcome;
pub mod verbs;

use std::path::PathBuf;
use std::sync::Arc;

use inillucent_driver::vfs::confine::{self, Root};
use inillucent_driver::Status;

use crate::json::Json;
use crate::shell::Shell;

pub use outcome::{Column, Failed, Outcome};

/// What kind of value a parameter takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// A string.
    Text,
    /// A whole number.
    Integer,
    /// True or false.
    Boolean,
    /// An array of SQL values, for binding to `?1`, `?2`, ...
    Values,
}

impl Kind {
    /// Returns the JSON Schema type an MCP client is told to send.
    pub fn schema_type(self) -> &'static str {
        match self {
            Kind::Text => "string",
            Kind::Integer => "integer",
            Kind::Boolean => "boolean",
            Kind::Values => "array",
        }
    }

    /// Returns whether a JSON value has this parameter kind.
    ///
    /// @param value - the value a client supplied
    pub fn accepts(self, value: &Json) -> bool {
        match self {
            Kind::Text => matches!(value, Json::Text(_)),
            Kind::Integer => value.integer().is_some(),
            Kind::Boolean => matches!(value, Json::Bool(_)),
            // **An array and an object are values too (task-1979, section 8.2,
            // gap 2, and D15).** A nested array of numbers is a vector and
            // `{"blob": "<hex>"}` is bytes; neither had a spelling at all, so a
            // caller binding into a `VECTOR(N)` column or binding a byte string
            // had to build a hex literal itself. The command line accepted both
            // once `literal_of` learned them and this did not, so the two
            // surfaces disagreed about the same JSON.
            Kind::Values => value.array().is_some_and(|items| {
                items.iter().all(|item| match item {
                    Json::Null | Json::Bool(_) | Json::Int(_) | Json::Real(_) | Json::Text(_) => {
                        true
                    }
                    Json::Array(numbers) => numbers
                        .iter()
                        .all(|number| matches!(number, Json::Int(_) | Json::Real(_))),
                    Json::Object(fields) => {
                        fields.len() == 1
                            && fields.iter().all(|(name, value)| {
                                name == "blob" && matches!(value, Json::Text(_))
                            })
                    }
                })
            }),
        }
    }
}

/// One parameter a command takes.
#[derive(Debug, Clone, Copy)]
pub struct Param {
    /// The name, which is the MCP property name and the CLI's `--name`.
    pub name: &'static str,
    /// What kind of value it takes.
    pub kind: Kind,
    /// Whether the command refuses without it.
    pub required: bool,
    /// Whether it can be given as a bare word on the command line.
    ///
    /// At most one positional per command, and it is always the first one: a
    /// command line with two unnamed arguments is one nobody can read back.
    pub positional: bool,
    /// What it is for, in one sentence.
    ///
    /// **This is what the model reads.** A parameter whose description says
    /// "the table" tells an agent nothing it could not guess; one that says
    /// which name form is expected saves a failed call. The parity test refuses
    /// an empty one.
    pub description: &'static str,
}

/// What a command does to the database, for the two questions that asks.
///
/// **Three states rather than two, because `run` is neither** (task-2066
/// section 4.2, item 26). The flag this replaces answered one question with
/// one bit and two different callers read it: `--readonly` refuses a command
/// that writes, and a command that writes creates the file it was pointed at.
/// `run` drives the shell, so it may do either, and marking it `true` refused
/// `inillucent --readonly run "SELECT count(*) FROM t;"` and the
/// `inillucent_run` MCP tool with it - while marking it `false` would stop
/// `inillucent --db new.rdb run ".read schema.sql"` from making the file.
///
/// The shell it drives already refuses a write statement by statement when it
/// is read only, from `inillucent_driver::readonly::admits`, which is the same
/// classification `Context::refuse_if_it_writes` and the driver use. So `run`
/// needs the verb gate to stand aside and let that refusal happen, which is
/// the third state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Writes {
    /// It only reads. `--readonly` admits it and it never creates a file.
    No,
    /// It changes the database. `--readonly` refuses it by name.
    Yes,
    /// It may change the database, and refuses each statement that does.
    ///
    /// `--readonly` admits the verb and the shell underneath refuses the
    /// writes, one statement at a time, with "attempt to write a readonly
    /// database".
    PerStatement,
}

impl Writes {
    /// Whether `--readonly` refuses this command before it runs.
    pub fn refused_when_read_only(self) -> bool {
        self == Writes::Yes
    }

    /// Whether this command may create the database file it was pointed at.
    ///
    /// A read verb does not make the file it was pointed at, so
    /// `inillucent --db typo.rdb tables` reports a missing database rather
    /// than leaving an empty one behind. `run` may, because
    /// `sqlite3 new.db ".read schema.sql"` does.
    pub fn may_create(self) -> bool {
        self != Writes::No
    }
}

/// One command.
pub struct Command {
    /// The verb, as `inillucent <name>` and as `inillucent_<name>`.
    pub name: &'static str,
    /// One line, shown in the command list and used as the MCP description.
    pub summary: &'static str,
    /// The longer explanation, shown by `inillucent help <name>` and appended
    /// to the MCP description so a model reads the same thing a person does.
    pub detail: &'static str,
    /// What it takes.
    pub params: &'static [Param],
    /// Why it is not offered over MCP, when it is not.
    pub cli_only: Option<&'static str>,
    /// Whether it can change the database, and what a read only surface does
    /// about it.
    pub writes: Writes,
    /// What it does.
    pub run: fn(&mut Context, &Arguments) -> Result<Outcome, Failed>,
}

impl Command {
    /// Returns this command's parameter of a given name.
    ///
    /// @param name - the parameter name
    pub fn param(&self, name: &str) -> Option<&'static Param> {
        self.params.iter().find(|param| param.name == name)
    }

    /// Returns the parameter that may be written without its name.
    pub fn positional(&self) -> Option<&'static Param> {
        self.params.iter().find(|param| param.positional)
    }

    /// Returns the usage line the CLI prints for this command.
    pub fn usage(&self) -> String {
        let mut line = format!("inillucent {}", self.name);
        for param in self.params {
            let form = match (param.positional, param.required) {
                (true, true) => format!(" <{}>", param.name),
                (true, false) => format!(" [{}]", param.name),
                (false, true) => format!(" --{} <{}>", param.name, param.name),
                (false, false) => format!(" [--{} <{}>]", param.name, param.name),
            };
            line.push_str(&form);
        }
        line
    }

    /// Returns the finite text values a parameter accepts when it has any.
    ///
    /// @param name - the parameter name
    pub fn allowed_values(&self, name: &str) -> Option<&'static [&'static str]> {
        match (self.name, name) {
            (_, "output") => Some(&["text", "json"]),
            ("import", "format") => Some(&["csv", "tabs", "ascii"]),
            ("export", "format") => Some(&[
                "csv", "json", "tabs", "markdown", "insert", "quote", "line", "html",
            ]),
            _ => None,
        }
    }
}

/// The values a command was given.
#[derive(Debug, Clone, Default)]
pub struct Arguments {
    /// Each name and what was passed under it.
    values: Vec<(String, Json)>,
}

impl Arguments {
    /// Builds an argument set from an MCP `arguments` object.
    ///
    /// @param command - the command that declares the accepted arguments
    /// @param object - the object the client sent
    pub fn from_json(command: &Command, object: &Json) -> Result<Arguments, Failed> {
        let Json::Object(pairs) = object else {
            return Err(Failed::misuse("tool arguments must be an object."));
        };
        for (name, value) in pairs {
            let Some(param) = command.param(name) else {
                return Err(Failed::misuse(format!(
                    "'{}' has no '{name}' argument.",
                    command.name
                )));
            };
            if !param.kind.accepts(value) {
                return Err(Failed::misuse(format!(
                    "'{name}' has to be a {}.",
                    param.kind.schema_type()
                )));
            }
            if let Some(allowed) = command.allowed_values(name) {
                let Some(text) = value.text() else {
                    return Err(Failed::misuse(format!("'{name}' has to be text.")));
                };
                if !allowed.contains(&text) {
                    return Err(Failed::misuse(format!(
                        "'{name}' must be one of: {}.",
                        allowed.join(", ")
                    )));
                }
            }
        }
        for param in command.params {
            if param.required && !pairs.iter().any(|(name, _)| name == param.name) {
                return Err(Failed::misuse(format!("'{}' is required.", param.name)));
            }
        }
        Ok(Arguments {
            values: pairs.clone(),
        })
    }

    /// Records one value.
    ///
    /// @param name - the parameter
    /// @param value - what was given
    pub fn set(&mut self, name: &str, value: Json) {
        self.values.retain(|(held, _)| held != name);
        self.values.push((name.to_string(), value));
    }

    /// Returns what was given under a name.
    ///
    /// @param name - the parameter
    pub fn get(&self, name: &str) -> Option<&Json> {
        self.values
            .iter()
            .find(|(held, _)| held == name)
            .map(|(_, value)| value)
    }

    /// Returns a text parameter, if it was given as text.
    ///
    /// @param name - the parameter
    pub fn text(&self, name: &str) -> Option<&str> {
        self.get(name).and_then(Json::text)
    }

    /// Returns a text parameter, or the failure for having left it out.
    ///
    /// @param name - the parameter
    pub fn required_text(&self, name: &str) -> Result<&str, Failed> {
        match self.get(name) {
            Some(Json::Text(text)) => Ok(text),
            Some(_) => Err(Failed::misuse(format!("'{name}' has to be a string."))),
            None => Err(Failed::misuse(format!("'{name}' is required."))),
        }
    }

    /// Returns an integer parameter.
    ///
    /// @param name - the parameter
    pub fn integer(&self, name: &str) -> Option<i64> {
        self.get(name).and_then(Json::integer)
    }

    /// Returns a boolean parameter, treating an absent one as false.
    ///
    /// @param name - the parameter
    pub fn flag(&self, name: &str) -> bool {
        self.get(name).and_then(Json::boolean).unwrap_or(false)
    }

    /// Returns the array a `values` parameter carries.
    ///
    /// @param name - the parameter
    pub fn values(&self, name: &str) -> Vec<Json> {
        self.get(name)
            .and_then(Json::array)
            .map(<[Json]>::to_vec)
            .unwrap_or_default()
    }
}

/// Where a command runs: the database, and the limits placed on it.
pub struct Context {
    /// The shell every command drives.
    shell: Shell,
    /// The log segments beside the file that its chain cannot reach.
    ///
    /// Read once at the open. See [`Context::strays_beside`] for why the moment
    /// matters.
    strays: Vec<u64>,
    /// The file it is open on.
    path: String,
    /// Whether a statement that changes anything is refused.
    readonly: bool,
    /// The directory outside which no path may be named.
    ///
    /// The same [`Root`] the whole process is confined to, so a refusal a
    /// person reads and a refusal the file system enforces are one decision
    /// rather than two that can disagree.
    root: Option<Arc<Root>>,
    /// How many rows a command hands back when it was not told.
    pub limit: usize,
    /// The most rows one call may hand back, when this surface has a ceiling.
    max_rows: Option<usize>,
    /// What one command may spend inside the engine.
    ///
    /// Different from `max_rows`, and both are needed. `max_rows` bounds what a
    /// call *hands back*; this bounds what the engine does on the way there, so
    /// a `SELECT` whose `WHERE` rejects everything after scanning a hundred
    /// million rows still stops. A row ceiling alone would let that run to the
    /// end and then report zero rows.
    limits: inillucent_driver::StatementLimits,
    /// Whether arming a budget clears the cancellation flag first.
    ///
    /// True everywhere but the MCP server, which reads its input on a second
    /// thread and therefore owns the ordering itself; see
    /// `budget::arm_as_it_stands` (task-1932, H11).
    preserve_cancel: std::cell::Cell<bool>,
    /// The flag that stops whatever this session is running.
    ///
    /// **One per session, not one per call (task-1932, H11).** `run` used to
    /// arm the budget with a fresh `AtomicBool` it dropped on the way out, so
    /// the flag the executor polled every batch was one nothing else in the
    /// process had a handle to: `Connection::cancel` in the driver was correct
    /// and unreachable, and an MCP `notifications/cancelled` had nothing to
    /// set. Handing out a clone of this is what makes a cancel arriving from
    /// another thread land on the statement that is running.
    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
    /// What to print where a value is null.
    pub null: String,
}

/// Whether a command surface may write to the database it opens.
///
/// **An enum rather than a bare `bool` (task-1962, A9).** `Context::open(path,
/// true, root)` at a call site says nothing about what the `true` decides, and
/// the surface it opens is the one an operator reaches for when they want to be
/// certain nothing is written.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OpenMode {
    /// Statements that write are refused.
    ReadOnly,
    /// The ordinary surface.
    ReadWrite,
}

impl OpenMode {
    /// Reads the `--readonly` flag a command surface was invoked with.
    ///
    /// @param readonly - whether the flag was given
    pub fn of(readonly: bool) -> OpenMode {
        if readonly {
            OpenMode::ReadOnly
        } else {
            OpenMode::ReadWrite
        }
    }
}

impl Context {
    /// Opens a context on a database.
    ///
    /// @param path - the file, or an in-memory name
    /// @param readonly - whether writes are refused
    /// @param root - the directory paths are confined to, if any
    pub fn open(path: &str, mode: OpenMode, root: Option<PathBuf>) -> Result<Context, Failed> {
        Context::open_for(path, mode, root, true)
    }

    /// Opens a database for one command, which may or may not make the file.
    ///
    /// @param path - the database to open
    /// @param mode - whether writes are refused
    /// @param root - the directory every file is confined to, when there is one
    /// @param may_create - whether this caller is allowed to make the file
    pub fn open_for(
        path: &str,
        mode: OpenMode,
        root: Option<PathBuf>,
        may_create: bool,
    ) -> Result<Context, Failed> {
        let readonly = mode == OpenMode::ReadOnly;
        // **The confinement is installed before the first file is opened.**
        // The database this surface starts on is a path like any other, and
        // installing the root afterwards would exempt exactly the one path an
        // operator is most likely to have got wrong. It also puts the root
        // where the VFS can see it, which is what confines every file the
        // engine opens later without this module having to name them.
        let root = match root {
            None => None,
            Some(directory) => {
                confine::confine_process(&directory).map_err(|error| {
                    Failed::said(Status::InvalidState, error.detail().to_string())
                })?;
                confine::process_root()
            }
        };
        let opened = match &root {
            Some(root) => root
                .admit(path)
                .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))?
                .to_string_lossy()
                .into_owned(),
            None => path.to_string(),
        };
        // **A read verb does not make the file it was pointed at (task-1979,
        // E2).** `inillucent --db typo.rdb tables` used to create `typo.rdb`,
        // write a log segment beside it, print an empty table and exit 0 - so a
        // mistyped path answered "this database has no tables" and left a file
        // behind that the next command would then open happily.
        //
        // **A verb that writes still makes one**, because that is what
        // `sqlite3 new.db "CREATE TABLE ..."` does and what every script that
        // sets a database up in one line expects. `Command::writes` is the same
        // flag `--readonly` refuses on, so the two questions have one answer.
        if !may_create
            && !opened.is_empty()
            && opened != ":memory:"
            && !std::path::Path::new(&opened).exists()
        {
            return Err(Failed::said(
                Status::NotFound,
                format!(
                    "there is no database at \"{opened}\". `inillucent create {opened}` makes one."
                ),
            ));
        }
        // **The engine's own status, not `io` for everything.** A file another
        // process holds is `busy`, which a caller can act on by retrying; a
        // file that is not a database of this engine is `corrupt`. Reporting
        // both as `io` told a script nothing (task-1979, C6).
        let mut shell = Shell::open_reporting(&opened, readonly).map_err(|error| {
            let said = Failed::from_engine(&error);
            Failed::said(
                said.status,
                format!("could not open \"{opened}\": {}", said.message),
            )
        })?;
        // **`--root` turns safe mode on, because otherwise it does not confine
        // anything (task-1979, H1).** A caller that names a directory has said
        // the program may touch that directory and nothing else; a `.shell` or
        // `.system` that spawns `cmd /C` walks straight past that, and the
        // reviewer's run wrote a file outside the root through the `run` verb
        // and exited 0. The MCP server turns it on whether or not a root was
        // given - see `Context::refuse_the_world`.
        shell.safe = root.is_some();
        // Read once, here, while the chain this open recovered is still the one
        // on the disk - see `Context::strays_beside`.
        let reported = shell.recovery();
        let strays = Context::strays_beside(&opened, reported.last_sequence, reported.last_lsn);
        Ok(Context {
            shell,
            strays,
            path: opened,
            readonly,
            root,
            limit: 200,
            max_rows: None,
            limits: inillucent_driver::StatementLimits::unbounded(),
            preserve_cancel: std::cell::Cell::new(false),
            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
            null: String::new(),
        })
    }

    /// Points this context at a different file, reopening only if it moved.
    ///
    /// **One open database at a time, and that is a decision rather than a
    /// simplification.** One file is one buffer pool, and a server holding four
    /// of them holds four pools whose sizes nobody asked about. A caller that
    /// alternates pays a reopen; a caller that does not pays nothing.
    ///
    /// @param path - the file to use
    pub fn use_database(&mut self, path: &str) -> Result<(), Failed> {
        if path == self.path {
            return Ok(());
        }
        let confined = self.confine(path)?;
        let named = confined.to_string_lossy().into_owned();
        self.shell.reopen(&named).map_err(|message| {
            Failed::said(Status::Io, format!("could not open \"{named}\": {message}"))
        })?;
        self.path = named;
        Ok(())
    }

    /// Refuses every shell command that reaches outside the database.
    ///
    /// **Always on over MCP, and there is no flag to turn it off (task-1979,
    /// H1).** The set is the reference's `-safe`: running a program
    /// (`.shell`, `.system`), loading a shared library (`.load`), changing the
    /// working directory (`.cd`), handing a file to whatever the system opens
    /// it with (`.excel`, `.www`), and writing output through a pipe. An MCP
    /// server that left it off handed an agent a shell on the host, whatever
    /// `--root` said - and a child spawned that way inherits the server's
    /// standard output, which is the JSON-RPC channel, so its output landed in
    /// the middle of a reply and the client could not match one to its id.
    ///
    /// A flag was the alternative and was rejected: an operator who forgets it
    /// hands an agent a shell, and there is no case for an MCP server with safe
    /// mode off.
    pub fn refuse_the_world(&mut self) {
        self.shell.safe = true;
    }

    /// Returns what opening this context's database did to it.
    ///
    /// See [`Outcome::with_recovery`] for what the command surface does with
    /// it.
    pub fn recovery(&self) -> inillucent_driver::Recovery {
        self.shell.recovery()
    }

    /// Returns the log segments beside this database that its chain cannot
    /// reach.
    ///
    /// **A file nothing will replay and nothing will remove (task-1979, C9).**
    /// A segment copied or restored at a sequence past the live one is ignored
    /// by recovery, which is right - the chain is followed by sequence from the
    /// meta record and stops at the first gap - and was also never mentioned,
    /// so it sat beside the database through every open and close.
    ///
    /// **Read once, at the open, and the moment matters.** `truncate_after` has
    /// just deleted every segment above where the chain stopped that it could
    /// walk to, so what is left above a gap is what nothing will ever reach.
    /// Asking again later would answer differently for a reason that is not
    /// damage: this connection rolls a segment on every checkpoint, another
    /// process rolls its own, and retiring the ones below a new recovery point
    /// leaves the directory with gaps that are simply the log moving on.
    ///
    /// The directory is read here rather than in the engine because
    /// `inillucent_vfs::Vfs` has no listing and the command surface is where
    /// reading a directory already happens. What a name means is
    /// `inillucent_wal::segment::sequence_of_segment_name`, reached through the
    /// engine's own re-export, so the naming convention stays in the crate that
    /// writes it.
    ///
    /// @param path - the database file
    /// @param reaches - the sequence this open's recovery stopped in
    /// @param ended_at - the stream position it stopped at
    fn strays_beside(path: &str, reaches: u64, ended_at: u64) -> Vec<u64> {
        let path = std::path::Path::new(path);
        let (Some(directory), Some(stem)) = (path.parent(), path.file_name()) else {
            return Vec::new();
        };
        let stem = stem.to_string_lossy().into_owned();
        let Ok(entries) = std::fs::read_dir(directory) else {
            return Vec::new();
        };
        let mut present: Vec<u64> = entries
            .flatten()
            .filter_map(|entry| {
                let name = entry.file_name().to_string_lossy().into_owned();
                inillucent_driver::log::sequence_of_segment_name(&stem, &name)
            })
            .collect();
        present.sort_unstable();
        // **Above the chain's end AND holding positions it has already passed.**
        // The sequence on its own is not enough: another process writing this
        // same database rolls to the next one and retires the one below it, so
        // a live log leaves exactly the shape a leftover does - a number above
        // this connection's own with a gap under it. What tells them apart is
        // where the records start. A segment a writer rolled to begins at or
        // above where this chain ended; a copy of an older segment begins
        // below it, which means nothing above it will ever read it.
        present.retain(|sequence| {
            *sequence > reaches
                && Context::first_record_of(directory, &stem, *sequence)
                    .is_some_and(|first| first < ended_at)
        });
        present
    }

    /// Returns where one segment file's records start.
    ///
    /// @param directory - the directory the database is in
    /// @param stem - the database file's name
    /// @param sequence - which segment
    fn first_record_of(directory: &std::path::Path, stem: &str, sequence: u64) -> Option<u64> {
        let name = format!("{stem}-wal.{sequence:010}");
        let head = std::fs::read(directory.join(name)).ok()?;
        inillucent_driver::log::first_lsn_of(&head)
    }

    /// Returns the strays this context's open found. See
    /// [`Context::strays_beside`].
    pub fn stray_log_segments(&self) -> &[u64] {
        &self.strays
    }

    /// Returns a handle to this session's cancellation flag.
    ///
    /// Setting it stops the statement that is running, at the next batch. It is
    /// cleared when the next command is armed, so a cancel that arrives between
    /// two calls belongs to the one that has finished and is discarded rather
    /// than applied to the one that has not started.
    pub fn cancel_flag(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
        std::sync::Arc::clone(&self.cancel)
    }

    /// Says that this surface clears the cancellation flag itself.
    ///
    /// Only `inillucent-mcp` does, because it is the only one that reads its
    /// input on a second thread. See `budget::arm_as_it_stands`.
    pub fn preserve_cancellation(&self) {
        self.preserve_cancel.set(true);
    }

    /// Returns the shell commands drive.
    pub fn shell(&mut self) -> &mut Shell {
        &mut self.shell
    }

    /// Returns the file this context is open on.
    pub fn path(&self) -> &str {
        &self.path
    }

    /// Returns whether writes are refused.
    pub fn readonly(&self) -> bool {
        self.readonly
    }

    /// Refuses a row count past this surface's ceiling, when it has one.
    ///
    /// **The command line has no ceiling and the MCP server does**, which is
    /// the whole distinction: a person running `inillucent query` against their
    /// own database and asking for every row is asking for what they want, and
    /// an agent doing the same thing to a served database is the case `--root`
    /// and `--readonly` already exist for. Zero means every row and is refused
    /// where a ceiling is set, because "every row" is precisely the request the
    /// ceiling is about.
    ///
    /// @param asked - the row count the caller wants
    pub fn cap_rows(&self, asked: usize) -> Result<usize, Failed> {
        let Some(most) = self.max_rows else {
            return Ok(asked);
        };
        match asked {
            0 => Err(Failed::said(
                Status::InvalidState,
                format!(
                    "limit=0 asks for every row, and this server hands back at most {most}. Ask \
                     for a count, or narrow the query."
                ),
            )),
            asked if asked > most => Err(Failed::said(
                Status::InvalidState,
                format!("limit={asked} is past the {most} rows this server hands back."),
            )),
            asked => Ok(asked),
        }
    }

    /// Sets the ceiling on how many rows one call hands back.
    ///
    /// @param most - the ceiling, or `None` for the command line's absence of one
    pub fn set_max_rows(&mut self, most: Option<usize>) {
        self.max_rows = most;
    }

    /// Sets what one command may spend inside the engine.
    ///
    /// @param limits - the budget, or `Limits::unbounded` for a command line
    pub fn set_limits(&mut self, limits: inillucent_driver::StatementLimits) {
        self.limits = limits;
    }

    /// Returns what one command on this surface may spend inside the engine.
    ///
    /// A verb that runs work outside the executor - a migration reads a remote
    /// server and writes rows through a second connection - asks so that it can
    /// put itself under the same ceiling rather than beside it.
    pub fn limits(&self) -> inillucent_driver::StatementLimits {
        self.limits.clone()
    }

    /// Returns whether this surface was confined to a directory.
    ///
    /// **Confinement is about reach, not only about paths.** `--root` exists so
    /// that an MCP server can be handed to an agent without handing it the file
    /// system, and a verb that dialled a host and a port would be a hole
    /// straight through it. A command that can reach something other than a
    /// file asks this and refuses.
    pub fn confined(&self) -> bool {
        self.root.is_some()
    }

    /// Returns a surface over a shell a test already opened.
    ///
    /// Here rather than in each test module because `Context`'s fields are
    /// private to this module, and a test that reached into them would be a
    /// second definition of what a surface is.
    ///
    /// @param shell - the shell to drive
    /// @param root - the directory to confine to, when there is one
    #[cfg(test)]
    pub fn for_test(shell: Shell, root: Option<PathBuf>) -> Context {
        Context {
            shell,
            strays: Vec::new(),
            path: ":memory:".to_string(),
            readonly: false,
            // A test builds its own root rather than installing a process-wide
            // one: the process root is set once for the life of the process,
            // and a test that installed it would decide the confinement of
            // every other test in the binary.
            root: root.map(|directory| {
                Arc::new(Root::resolved(confine::resolve_through_links(&directory)))
            }),
            limit: 200,
            max_rows: None,
            limits: inillucent_driver::StatementLimits::unbounded(),
            preserve_cancel: std::cell::Cell::new(false),
            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
            null: String::new(),
        }
    }

    /// Refuses a path outside the root, when a root was set.
    ///
    /// **The decision is not made here.** It is made by
    /// `inillucent_vfs::confine`, which resolves the path through the file
    /// system rather than reading its text, and which the VFS consults again
    /// at the moment the file is opened. This method exists so that a person
    /// reading a refusal is told the path they typed and the directory they
    /// confined to, neither of which survives as far as the VFS.
    ///
    /// The check this replaced compared normalised path text against the root.
    /// A junction below the root passed it and opened a database outside the
    /// root.
    ///
    /// @param path - the path a caller named
    pub fn confine(&self, path: &str) -> Result<PathBuf, Failed> {
        let Some(root) = &self.root else {
            return Ok(PathBuf::from(path));
        };
        root.admit(path)
            .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))
    }

    /// Refuses a statement that changes something, when read-only.
    ///
    /// **By the statement's class, in one place shared with the driver
    /// (task-1979, section 5.2).** The check this replaces asked the engine to
    /// `EXPLAIN` the statement and refused only when the message contained
    /// "not a read-only statement" - text `compile_explain` never produces for
    /// an `INSERT`, a write pragma, an `ATTACH` or a `VACUUM INTO`, all of
    /// which therefore ran and persisted through `--readonly`. The class comes
    /// from the parser's own `classify_statement`; the pragma lists are in
    /// `inillucent_driver::readonly` so this and the driver cannot disagree.
    ///
    /// This is the layer that gives the caller a good message. The layer that
    /// makes the property true is the commit path, which refuses a write on a
    /// connection opened read only whatever reached it.
    ///
    /// @param sql - the statement
    pub fn refuse_if_it_writes(&self, sql: &str) -> Result<(), Failed> {
        if !self.readonly {
            return Ok(());
        }
        match inillucent_driver::readonly::admits(sql) {
            true => Ok(()),
            false => Err(Failed::said(
                Status::ReadOnly,
                "this connection is read only, and that statement changes something.",
            )),
        }
    }

    /// Runs shell input, collecting everything it printed.
    ///
    /// @param input - the lines, dot commands included
    pub fn collect_output(&mut self, input: &str) -> String {
        self.shell.sink = Some(String::new());
        let lines: Vec<String> = input.lines().map(str::to_string).collect();
        crate::shell::drive(&mut self.shell, lines.into_iter());
        self.shell.sink.take().unwrap_or_default()
    }
}

/// Returns the command of a given name.
///
/// @param name - the verb, with or without the `inillucent_` prefix MCP uses
pub fn find(name: &str) -> Option<&'static Command> {
    let bare = name.strip_prefix("inillucent_").unwrap_or(name);
    // A dash reads better on a command line and an underscore is required in an
    // MCP tool name, so both spellings find the same command rather than one of
    // them being a mistake a caller has to learn about.
    let wanted = bare.replace('-', "_");
    COMMANDS
        .iter()
        .find(|command| command.name.replace('-', "_") == wanted)
}

/// Runs a command, applying the checks every front end shares.
///
/// The order is the whole of it: the database is selected first because a
/// confinement refusal must happen before anything is opened, the read-only
/// check is second because a refusal is cheaper than a run, and only then does
/// the command see its arguments.
///
/// @param command - what to run
/// @param context - where to run it
/// @param arguments - what it was given
pub fn run(
    command: &'static Command,
    context: &mut Context,
    arguments: &Arguments,
) -> Result<Outcome, Failed> {
    if let Some(path) = arguments.text("db") {
        context.use_database(path)?;
    }
    if command.writes.refused_when_read_only() && context.readonly() {
        return Err(Failed::said(
            Status::ReadOnly,
            format!(
                "'{}' changes the database, and this is read only.",
                command.name
            ),
        ));
    }
    for param in command.params {
        if param.required && arguments.get(param.name).is_none() {
            return Err(Failed::misuse(format!(
                "'{}' needs '{}'. Usage: {}",
                command.name,
                param.name,
                command.usage()
            )));
        }
    }
    let started = std::time::Instant::now();
    // **Armed here, which is the one place every command on every surface goes
    // through.** Arming it inside each verb would be arming it in nineteen
    // places and forgetting it in the twentieth; arming it in the engine would
    // put a server's policy inside a library an application also links.
    let armed = match context.preserve_cancel.get() {
        true => inillucent_driver::arm_as_it_stands(context.limits.clone(), context.cancel_flag()),
        false => inillucent_driver::arm(context.limits.clone(), context.cancel_flag()),
    };
    let outcome = (command.run)(context, arguments);
    drop(armed);
    let mut produced = outcome?;
    if produced.elapsed_ms == 0.0 {
        produced.elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
    }
    Ok(produced)
}

/// The parameter every command takes, so a caller can name the file per call.
const DB: Param = Param {
    name: "db",
    kind: Kind::Text,
    required: false,
    positional: false,
    description: "The database file to run against. Defaults to the one this process was started \
                  on, or :memory: for a scratch database that is discarded when the process ends.",
};

/// The parameter the row-producing commands take.
const LIMIT: Param = Param {
    name: "limit",
    kind: Kind::Integer,
    required: false,
    positional: false,
    description:
        "How many rows to hand back. The count in 'total' is still exact, and 'more' says \
                  whether anything was cut off. Defaults to 200. Over MCP there is a ceiling \
                  of 10000 rows and 0 (every row) is refused; on the command line there is \
                  neither. A negative number is refused on both.",
};

/// The parameter that switches between the two renderings.
///
/// **Called `output` and not `format`, because `export` already has a
/// `format`** - and that one means CSV against JSON against Markdown, which is
/// a different question from whether the *result object* is drawn as a table or
/// written as JSON. The collision was real rather than theoretical: with both
/// called `format`, `inillucent export people --format json` was read as "draw
/// the result object as JSON" and quietly wrote CSV.
const FORMAT: Param = Param {
    name: "output",
    kind: Kind::Text,
    required: false,
    positional: false,
    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.",
};

mod registry;

pub use registry::COMMANDS;

#[cfg(test)]
mod tests {
    use super::*;

    /// Both spellings of a name find the same command.
    #[test]
    fn a_name_is_found_either_way() {
        assert!(find("query").is_some());
        assert!(find("inillucent_query").is_some());
        assert_eq!(
            find("integrity_check").map(|command| command.name),
            find("integrity-check").map(|command| command.name)
        );
        assert!(find("nonsense").is_none());
    }

    /// Every command has a summary, a detail, and described parameters.
    #[test]
    fn every_command_is_described() {
        for command in COMMANDS {
            assert!(
                !command.summary.is_empty(),
                "{} has no summary",
                command.name
            );
            assert!(!command.detail.is_empty(), "{} has no detail", command.name);
            for param in command.params {
                assert!(
                    !param.description.is_empty(),
                    "{}.{} has no description",
                    command.name,
                    param.name
                );
            }
        }
    }

    /// A command has at most one positional parameter, and it is the first.
    #[test]
    fn at_most_one_positional_and_it_comes_first() {
        for command in COMMANDS {
            let positions: Vec<usize> = command
                .params
                .iter()
                .enumerate()
                .filter(|(_, param)| param.positional)
                .map(|(nth, _)| nth)
                .collect();
            assert!(positions.len() <= 1, "{} has two positionals", command.name);
            if let Some(first) = positions.first() {
                assert_eq!(*first, 0, "{}'s positional is not first", command.name);
            }
        }
    }

    /// No command's name is repeated.
    #[test]
    fn names_are_unique() {
        let mut seen: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
        let total = seen.len();
        seen.sort_unstable();
        seen.dedup();
        assert_eq!(seen.len(), total);
    }

    /// A confinement refuses a path that climbs out of the root.
    ///
    /// The root here is a directory that exists, because the service resolves
    /// a candidate through the file system and a root that is not there would
    /// make every case below pass for the wrong reason.
    #[test]
    fn confinement_refuses_a_path_that_climbs_out() {
        let root = std::env::temp_dir().join("inillucent-cli-confine");
        std::fs::create_dir_all(&root).unwrap();
        let context = Context {
            // A unit test builds its own context and has no directory to read.
            strays: Vec::new(),
            shell: Shell::open(":memory:").unwrap(),
            path: ":memory:".to_string(),
            readonly: false,
            root: Some(Arc::new(Root::at(&root).unwrap())),
            limit: 200,
            max_rows: None,
            limits: inillucent_driver::StatementLimits::unbounded(),
            preserve_cancel: std::cell::Cell::new(false),
            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
            null: String::new(),
        };
        assert!(context.confine("inner/app.rdb").is_ok());
        assert!(context.confine("../outside.rdb").is_err());
        // **An absolute path for this platform, not for Windows
        // (task-1946, M5).** This was `C:/elsewhere/app.rdb`, which is absolute
        // on Windows and a directory named `C:` on Linux - so the case that was
        // meant to be "somewhere else entirely" resolved *inside* the root
        // there, and the assertion that it is refused failed on the first Linux
        // run that ever reached it. Refusing it there would have been the real
        // defect: a relative path under the root is exactly what confinement
        // allows.
        let elsewhere = if cfg!(windows) {
            "C:/elsewhere/app.rdb"
        } else {
            "/elsewhere/app.rdb"
        };
        assert!(context.confine(elsewhere).is_err());
        assert!(context.confine(":memory:").is_ok());
    }

    /// A path that reaches outside the root through a link is refused, and the
    /// refusal names where it landed.
    ///
    /// The unit-level half of `crates/inillucent-compat/tests/e2e/confinement.rs`:
    /// that suite proves the shipped binaries refuse it, and this one proves
    /// the message a person reads says which of the two things went wrong.
    #[test]
    fn a_refusal_through_a_link_names_the_target() {
        let base = std::env::temp_dir().join("inillucent-cli-confine-link");
        let root = base.join("root");
        let outside = base.join("outside");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::create_dir_all(&outside).unwrap();
        let link = root.join("escape");
        if !link.exists() {
            #[cfg(windows)]
            let made = std::process::Command::new("cmd")
                .args(["/C", "mklink", "/J"])
                .arg(&link)
                .arg(&outside)
                .output()
                .map(|produced| produced.status.success())
                .unwrap_or(false);
            #[cfg(unix)]
            let made = std::os::unix::fs::symlink(&outside, &link).is_ok();
            if !made {
                inillucent_base::testing::skipping("this machine cannot create a symlink here");
                return;
            }
        }
        let context = Context {
            // A unit test builds its own context and has no directory to read.
            strays: Vec::new(),
            shell: Shell::open(":memory:").unwrap(),
            path: ":memory:".to_string(),
            readonly: false,
            root: Some(Arc::new(Root::at(&root).unwrap())),
            limit: 200,
            max_rows: None,
            limits: inillucent_driver::StatementLimits::unbounded(),
            preserve_cancel: std::cell::Cell::new(false),
            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
            null: String::new(),
        };
        let failure = context
            .confine("escape/app.rdb")
            .expect_err("a link out of the root is refused");
        assert!(
            failure.message.contains("resolves to"),
            "the refusal did not say where the path landed: {}",
            failure.message
        );
    }
}