Skip to main content

inillucent_cli/command/
outcome.rs

1//! What a command produced, and the two ways it is read.
2//!
3//! Invariant: **the human rendering and the machine rendering come from one
4//! run.** An [`Outcome`] carries both - `text` for a person or an agent reading
5//! a terminal, and the structured fields for a script - and every command fills
6//! them in the same call. A front end that had to run the command twice to get
7//! the other view would be able to get two different answers, and the one it
8//! showed would be the one nobody checked.
9//!
10//! The failure side is [`Failed`], and its `status` is deliberately not a
11//! sentence: it is one of the driver's thirteen status names, so an MCP client,
12//! a shell script and a C binding all read the same word for the same class of
13//! failure. `drivers/README.md` argues the case for `unsupported` being its own
14//! status rather than a flavour of syntax error; this is that argument holding
15//! in a third front end.
16
17use inillucent_driver::Status;
18
19use crate::json::{self, Json};
20
21/// Why a command did not succeed.
22#[derive(Debug, Clone)]
23pub struct Failed {
24    /// Which class of failure it is, as the driver names them.
25    pub status: Status,
26    /// What went wrong, in a sentence.
27    pub message: String,
28    /// The construct the engine has not built, when the status is `unsupported`.
29    pub feature: Option<String>,
30    /// Where in the statement, when the failure knows.
31    pub offset: Option<u32>,
32}
33
34impl Failed {
35    /// Builds a failure of a given class.
36    ///
37    /// @param status - the class
38    /// @param message - what went wrong
39    pub fn said(status: Status, message: impl Into<String>) -> Failed {
40        Failed {
41            status,
42            message: message.into(),
43            feature: None,
44            offset: None,
45        }
46    }
47
48    /// Builds the failure a caller's own mistake deserves.
49    ///
50    /// @param message - what was wrong with the request
51    pub fn misuse(message: impl Into<String>) -> Failed {
52        Failed::said(Status::InvalidState, message)
53    }
54
55    /// Builds the failure for something this engine has not built.
56    ///
57    /// @param feature - the construct, named so a caller can act on it
58    /// @param message - the sentence to show
59    pub fn unsupported(feature: impl Into<String>, message: impl Into<String>) -> Failed {
60        let feature = feature.into();
61        Failed {
62            status: Status::Unsupported,
63            message: message.into(),
64            feature: Some(feature),
65            offset: None,
66        }
67    }
68
69    /// Classifies an engine error the way the driver does.
70    ///
71    /// **The classification is the driver's, called rather than copied.** There
72    /// is exactly one place in this repository that decides whether a refusal
73    /// is `unsupported` or `syntax`, and a second copy of that decision here
74    /// would drift the first time the engine grew a construct.
75    ///
76    /// @param error - the engine's error
77    pub fn from_engine(error: &inillucent_base::DbError) -> Failed {
78        let classified = inillucent_driver::Error::from_engine(error, false);
79        Failed {
80            status: classified.status,
81            message: classified.message,
82            feature: classified.feature,
83            offset: classified.offset,
84        }
85    }
86
87    /// Classifies a shell failure, which may or may not carry the engine's error.
88    ///
89    /// @param failure - what the shell reported
90    pub fn from_shell(failure: &crate::shell::Failure) -> Failed {
91        match failure.error.as_ref() {
92            Some(error) => Failed::from_engine(error),
93            // A failure with no error behind it came from the shell rather than
94            // the engine - a dot command's own complaint - and `syntax` is the
95            // honest reading of that: the request was not one the shell could
96            // carry out as written.
97            None => Failed {
98                status: Status::Syntax,
99                message: failure.message.clone(),
100                feature: None,
101                offset: failure.offset,
102            },
103        }
104    }
105
106    /// Renders this failure as the object every front end reports.
107    ///
108    /// @param command - the verb that failed
109    pub fn to_json(&self, command: &str) -> Json {
110        let mut pairs = vec![
111            ("ok", Json::Bool(false)),
112            ("command", json::text(command)),
113            ("status", json::text(self.status.name())),
114            ("message", json::text(&self.message)),
115        ];
116        if let Some(feature) = &self.feature {
117            pairs.push(("feature", json::text(feature)));
118        }
119        if let Some(offset) = self.offset {
120            pairs.push(("offset", Json::Int(i64::from(offset))));
121        }
122        pairs.push(("text", json::text(self.to_text())));
123        json::object(pairs)
124    }
125
126    /// Renders this failure the way a person reads it.
127    pub fn to_text(&self) -> String {
128        let mut line = format!("Error [{}]: {}", self.status.name(), self.message);
129        if let Some(feature) = &self.feature {
130            line.push_str(&format!("\n  not built yet: {feature}"));
131        }
132        if let Some(offset) = self.offset {
133            line.push_str(&format!("\n  at byte {offset} of the statement"));
134        }
135        line
136    }
137
138    /// Returns the process exit code this failure deserves.
139    ///
140    /// Three for `unsupported`, so a script can branch on "this engine has not
141    /// built that" without matching on a message; one for everything else.
142    pub fn exit_code(&self) -> i32 {
143        match self.status {
144            Status::Unsupported => 3,
145            _ => 1,
146        }
147    }
148}
149
150/// One result column: its name, and what the values under it turned out to be.
151#[derive(Debug, Clone)]
152pub struct Column {
153    /// The name the statement gave it.
154    pub name: String,
155    /// The storage class of its values.
156    ///
157    /// **Observed, not declared, and the word is chosen for that.** SQLite's
158    /// typing is dynamic, so a column's declared affinity is a hint about what
159    /// it will store rather than a fact about what it did. This is the fact:
160    /// the class every non-null value in the result actually had, `mixed` when
161    /// they disagreed and `null` when there were none.
162    pub kind: String,
163}
164
165/// What a command produced.
166#[derive(Debug, Clone)]
167pub struct Outcome {
168    /// The verb that ran.
169    pub command: String,
170    /// The result columns, empty for a command that produces no table.
171    pub columns: Vec<Column>,
172    /// The rows, already cut to whatever limit was asked for.
173    pub rows: Vec<Vec<Json>>,
174    /// How many rows the statement produced before the limit.
175    pub total: usize,
176    /// Whether the limit cut anything off.
177    pub more: bool,
178    /// How many rows the statement changed.
179    pub changes: i64,
180    /// The rowid the last insert assigned.
181    pub last_insert_rowid: i64,
182    /// How long it took.
183    pub elapsed_ms: f64,
184    /// The rendering a person reads.
185    pub text: String,
186    /// Anything this particular command has to say that the shape above cannot.
187    pub extra: Vec<(String, Json)>,
188}
189
190impl Outcome {
191    /// Builds an outcome that is only a message.
192    ///
193    /// @param command - the verb
194    /// @param text - what to show
195    pub fn said(command: &str, text: impl Into<String>) -> Outcome {
196        Outcome {
197            command: command.to_string(),
198            columns: Vec::new(),
199            rows: Vec::new(),
200            total: 0,
201            more: false,
202            changes: 0,
203            last_insert_rowid: 0,
204            elapsed_ms: 0.0,
205            text: text.into(),
206            extra: Vec::new(),
207        }
208    }
209
210    /// Adds a field only this command has.
211    ///
212    /// @param name - the member name
213    /// @param value - what to put under it
214    pub fn with(mut self, name: &str, value: Json) -> Outcome {
215        self.extra.push((name.to_string(), value));
216        self
217    }
218
219    /// Adds what opening the database did, when it did anything.
220    ///
221    /// **Only when something happened (task-1979, C10).** A `recovered` member
222    /// on every result of every command would change the envelope every caller
223    /// parses and every page documents, to carry `false` almost always. An
224    /// operator investigating a crash asks one question - was this file
225    /// recovered - and the answer is the presence of the member.
226    ///
227    /// A stray segment is reported the same way and for the same reason: it is
228    /// a file beside the database that nothing will replay and nothing will
229    /// remove, so naming it is the only way anybody finds out it is there.
230    ///
231    /// @param recovery - what the open reported
232    /// @param strays - segments beside the file the chain does not reach
233    pub fn with_recovery(
234        mut self,
235        recovery: &inillucent_driver::Recovery,
236        strays: &[u64],
237    ) -> Outcome {
238        // **A dropped record is reported even when nothing else was**
239        // (task-2066 §4.1.10). `recovered` is false for an open that had no work
240        // to restore, and a drop can happen on one of those - so the condition
241        // is either, not just the first.
242        if recovery.recovered || recovery.dropped > 0 {
243            self.extra.push((
244                "recovered".to_string(),
245                json::object(vec![
246                    ("records_scanned", Json::Int(recovery.scanned as i64)),
247                    ("records_applied", Json::Int(recovery.applied as i64)),
248                    ("records_dropped", Json::Int(recovery.dropped as i64)),
249                    (
250                        "transactions_committed",
251                        Json::Int(recovery.committed as i64),
252                    ),
253                    ("transactions_discarded", Json::Int(recovery.losers as i64)),
254                ]),
255            ));
256            // **"Recovered" only when there was something to recover from.**
257            // Replaying committed transactions the file has not taken yet is
258            // what every open does while another process has the file open
259            // and has not checkpointed, and printing "recovered the log" for
260            // that on every command told a person the database had been
261            // damaged when nothing had happened to it. A transaction with no
262            // commit record, or a dropped record, is what a crash leaves, and
263            // that keeps the word. The JSON member above is unchanged either
264            // way, so a caller asking whether the log was replayed still can.
265            let said = if recovery.losers > 0 || recovery.dropped > 0 {
266                format!(
267                    "recovered the log: {} records scanned, {} applied, {} transactions committed, {} discarded.",
268                    recovery.scanned, recovery.applied, recovery.committed, recovery.losers
269                )
270            } else {
271                format!(
272                    "replayed the log: {} committed transactions were in the log and not yet in the \
273                     database file ({} records scanned, {} applied). A connection that still has the \
274                     file open, or one that ended before a checkpoint, wrote them.",
275                    recovery.committed, recovery.scanned, recovery.applied
276                )
277            };
278            self.text = format!(
279                "{}{}{said}",
280                self.text,
281                if self.text.is_empty() { "" } else { "\n" },
282            );
283        }
284        // On its own line and only when it happened, because it is the one
285        // number here that means somebody should look.
286        if recovery.dropped > 0 {
287            self.text = format!(
288                    "{}
289{} log record(s) were DROPPED: they name a tree this recovery had no                      shape for. That is usually a table dropped inside the replayed window, and                      it is how task-1932 and task-2033 both lost rows silently. Run                      `inillucent integrity-check` and compare the row counts you expect.",
290                    self.text, recovery.dropped
291                );
292        }
293        if !strays.is_empty() {
294            let named: Vec<Json> = strays
295                .iter()
296                .map(|sequence| Json::Int(*sequence as i64))
297                .collect();
298            self.extra
299                .push(("stray_log_segments".to_string(), Json::Array(named)));
300            let listed = strays
301                .iter()
302                .map(|sequence| format!("{sequence:010}"))
303                .collect::<Vec<String>>()
304                .join(", ");
305            self.text = format!(
306                "{}{}log segments beside this database that its chain does not reach: {listed}. Nothing replays them.",
307                self.text,
308                if self.text.is_empty() { "" } else { "\n" }
309            );
310        }
311        self
312    }
313
314    /// Renders this outcome as the object every front end reports.
315    pub fn to_json(&self) -> Json {
316        let columns = self
317            .columns
318            .iter()
319            .map(|column| {
320                json::object(vec![
321                    ("name", json::text(&column.name)),
322                    ("type", json::text(&column.kind)),
323                ])
324            })
325            .collect();
326        let rows = self
327            .rows
328            .iter()
329            .map(|row| Json::Array(row.clone()))
330            .collect();
331        let mut pairs = vec![
332            ("ok", Json::Bool(true)),
333            ("command", json::text(&self.command)),
334            ("columns", Json::Array(columns)),
335            ("rows", Json::Array(rows)),
336            ("row_count", Json::Int(self.rows.len() as i64)),
337            ("total", Json::Int(self.total as i64)),
338            ("more", Json::Bool(self.more)),
339            ("changes", Json::Int(self.changes)),
340            ("last_insert_rowid", Json::Int(self.last_insert_rowid)),
341            ("elapsed_ms", Json::Real(self.elapsed_ms)),
342        ];
343        let mut object = json::object(std::mem::take(&mut pairs));
344        if let Json::Object(members) = &mut object {
345            for (name, value) in &self.extra {
346                members.push((name.clone(), value.clone()));
347            }
348            members.push(("text".to_string(), json::text(&self.text)));
349        }
350        object
351    }
352}
353
354/// Returns the storage class of one JSON value, as a column type name.
355///
356/// @param value - the cell
357fn class_of(value: &Json) -> &'static str {
358    match value {
359        Json::Null => "null",
360        Json::Int(_) => "integer",
361        Json::Real(_) => "real",
362        Json::Text(_) => "text",
363        Json::Bool(_) => "integer",
364        Json::Array(_) | Json::Object(_) => "blob",
365    }
366}
367
368/// Works out each column's storage class from the rows under it.
369///
370/// @param names - the column names, in order
371/// @param rows - every row of the result
372pub fn columns_from(names: &[String], rows: &[Vec<Json>]) -> Vec<Column> {
373    names
374        .iter()
375        .enumerate()
376        .map(|(nth, name)| {
377            let mut kind: Option<&'static str> = None;
378            for row in rows {
379                let Some(value) = row.get(nth) else { continue };
380                if matches!(value, Json::Null) {
381                    continue;
382                }
383                let seen = class_of(value);
384                kind = match kind {
385                    None => Some(seen),
386                    Some(previous) if previous == seen => Some(previous),
387                    Some(_) => Some("mixed"),
388                };
389            }
390            Column {
391                name: name.clone(),
392                kind: kind.unwrap_or("null").to_string(),
393            }
394        })
395        .collect()
396}
397
398/// Draws a result as an aligned table, which is what a person and a model read.
399///
400/// **Aligned rather than JSON by default, and that is a measured choice.** §6 of
401/// the ticket's TDD records it: the local 27B model answers questions about a
402/// table it can see the shape of, and loses columns out of a JSON array of
403/// objects. The JSON is one parameter away for anything that would rather have
404/// it.
405///
406/// @param columns - the result columns
407/// @param rows - the rows to draw
408/// @param null - what to show where a value is null
409pub fn table(columns: &[Column], rows: &[Vec<Json>], null: &str) -> String {
410    if columns.is_empty() {
411        return String::new();
412    }
413    let cells: Vec<Vec<String>> = rows
414        .iter()
415        .map(|row| {
416            columns
417                .iter()
418                .enumerate()
419                .map(|(nth, _)| match row.get(nth) {
420                    Some(Json::Null) | None => null.to_string(),
421                    Some(Json::Text(text)) => text.clone(),
422                    Some(other) => other.write(),
423                })
424                .collect()
425        })
426        .collect();
427    let widths: Vec<usize> = columns
428        .iter()
429        .enumerate()
430        .map(|(nth, column)| {
431            let widest = cells
432                .iter()
433                .filter_map(|row| row.get(nth))
434                .map(|cell| cell.chars().count())
435                .max()
436                .unwrap_or(0);
437            widest.max(column.name.chars().count())
438        })
439        .collect();
440    let mut lines = Vec::with_capacity(rows.len() + 2);
441    lines.push(join_padded(
442        &columns
443            .iter()
444            .map(|column| column.name.clone())
445            .collect::<Vec<_>>(),
446        &widths,
447    ));
448    lines.push(
449        widths
450            .iter()
451            .map(|width| "-".repeat(*width))
452            .collect::<Vec<_>>()
453            .join("  "),
454    );
455    for row in &cells {
456        lines.push(join_padded(row, &widths));
457    }
458    lines.join("\n")
459}
460
461/// Joins one row's cells, each padded to its column's width.
462///
463/// @param cells - the values, already rendered
464/// @param widths - the width of each column
465fn join_padded(cells: &[String], widths: &[usize]) -> String {
466    let padded: Vec<String> = cells
467        .iter()
468        .enumerate()
469        .map(|(nth, cell)| {
470            let width = widths.get(nth).copied().unwrap_or(0);
471            let short = width.saturating_sub(cell.chars().count());
472            format!("{cell}{}", " ".repeat(short))
473        })
474        .collect();
475    padded.join("  ").trim_end().to_string()
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    /// A column's type is what its values were, not what was declared.
483    #[test]
484    fn a_column_type_is_observed() {
485        let names = vec!["a".to_string(), "b".to_string(), "c".to_string()];
486        let rows = vec![
487            vec![Json::Int(1), Json::Null, Json::Null],
488            vec![json::text("x"), json::text("y"), Json::Null],
489        ];
490        let columns = columns_from(&names, &rows);
491        assert_eq!(columns[0].kind, "mixed");
492        assert_eq!(columns[1].kind, "text");
493        assert_eq!(columns[2].kind, "null");
494    }
495
496    /// The table pads to the widest cell and keeps the header over its column.
497    #[test]
498    fn the_table_lines_up() {
499        let columns = columns_from(&["id".to_string(), "name".to_string()], &[]);
500        let rows = vec![
501            vec![Json::Int(1), json::text("Ada")],
502            vec![Json::Int(1000), json::text("B")],
503        ];
504        let drawn = table(&columns, &rows, "");
505        let lines: Vec<&str> = drawn.lines().collect();
506        assert_eq!(lines[0], "id    name");
507        assert_eq!(lines[1], "----  ----");
508        assert_eq!(lines[2], "1     Ada");
509        assert_eq!(lines[3], "1000  B");
510    }
511
512    /// A null is the placeholder, and never an empty string pretending to be one.
513    #[test]
514    fn a_null_is_drawn_as_the_placeholder() {
515        let columns = columns_from(&["a".to_string()], &[]);
516        let drawn = table(&columns, &[vec![Json::Null]], "NULL");
517        assert!(drawn.contains("NULL"));
518    }
519
520    /// Unsupported gets its own exit code so a script can branch on it.
521    #[test]
522    fn unsupported_exits_three() {
523        assert_eq!(Failed::unsupported("vacuum", "not built").exit_code(), 3);
524        assert_eq!(Failed::misuse("nope").exit_code(), 1);
525    }
526
527    /// The outcome's JSON carries both views, and `text` is last.
528    #[test]
529    fn the_outcome_carries_both_views() {
530        let outcome = Outcome::said("version", "0.1.0").with("engine", json::text("inillucent"));
531        let written = outcome.to_json().write();
532        assert!(written.starts_with("{\"ok\":true,\"command\":\"version\""));
533        assert!(written.contains("\"engine\":\"inillucent\""));
534        assert!(written.ends_with("\"text\":\"0.1.0\"}"));
535    }
536
537    /// A replay of committed transactions is not reported as a recovery, and
538    /// a discarded transaction still is.
539    ///
540    /// **The first open while another process has the file open replays that
541    /// process's committed transactions**, and every command printed
542    /// "recovered the log" for it, which reads as damage. The JSON member is
543    /// the same in both cases, so a caller asking whether the log was replayed
544    /// still can.
545    #[test]
546    fn only_a_discarded_transaction_is_called_a_recovery() {
547        let routine = inillucent_driver::Recovery {
548            recovered: true,
549            scanned: 7,
550            applied: 4,
551            committed: 3,
552            ..Default::default()
553        };
554        let replayed = Outcome::said("query", "").with_recovery(&routine, &[]);
555        assert!(
556            replayed
557                .text
558                .starts_with("replayed the log: 3 committed transactions"),
559            "{}",
560            replayed.text
561        );
562        assert!(!replayed.text.contains("recovered"), "{}", replayed.text);
563        assert!(replayed.extra.iter().any(|(name, _)| name == "recovered"));
564
565        let crashed = inillucent_driver::Recovery {
566            losers: 1,
567            ..routine
568        };
569        let recovered = Outcome::said("query", "").with_recovery(&crashed, &[]);
570        assert!(
571            recovered
572                .text
573                .starts_with("recovered the log: 7 records scanned, 4 applied, 3 transactions committed, 1 discarded."),
574            "{}",
575            recovered.text
576        );
577    }
578}