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}